Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker.compose.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
sublane:
image: ${SUBLANE_IMAGE:-ghcr.io/murongg/sublane:latest}
image: ${SUBLANE_IMAGE:-ghcr.io/alumos/sublane:latest}
ports:
- "${SUBLANE_BIND_ADDRESS:-127.0.0.1}:${SUBLANE_PORT:-8080}:8080"
environment:
Expand Down
4 changes: 1 addition & 3 deletions internal/channels/channels.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,7 @@ func (s *Service) Delete(ctx context.Context, id string) error {

// Update renames a channel and optionally replaces its key while keeping the channel ID.
func (s *Service) Update(ctx context.Context, id string, input Input) (Channel, error) {
if _, err := s.Get(ctx, id); err != nil {
return Channel{}, err
}
// UpdateAPIChannel rejects missing and non-channel resources itself.
row, err := s.resources.UpdateAPIChannel(ctx, id, accounts.APIChannelUpdate{Name: input.Name, APIKey: input.APIKey, BaseURL: input.BaseURL, Platform: input.Platform, Protocol: input.Protocol})
if errors.Is(err, accounts.ErrNotFound) {
return Channel{}, ErrNotFound
Expand Down
15 changes: 6 additions & 9 deletions internal/gateway/sticky_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,20 @@ import (
func stickyGateway(t *testing.T, sticky bool) (*Service, string, string) {
t.Helper()
s, parent, other := reviewGateway(t)
return stickyGatewayFrom(t, s, sticky, parent, other)
setSticky(t, s, sticky)
return s, parent, other
}

func stickyGatewayFrom(t *testing.T, s *Service, sticky bool, ids ...string) (*Service, string, string) {
// setSticky toggles session stickiness on the fixture's group, keeping its resources.
func setSticky(t *testing.T, s *Service, sticky bool) {
t.Helper()
parent, other := "", ""
if len(ids) == 2 {
parent, other = ids[0], ids[1]
}
detail, err := groups.New(s.db).Get(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
if _, err := groups.New(s.db).Save(context.Background(), 1, groups.Input{Name: detail.Name, Enabled: true, AccountIDs: detail.AccountIDs, Routing: &groups.Routing{Preference: "protocol", SessionSticky: sticky}}); err != nil {
t.Fatal(err)
}
return s, parent, other
}

func openSticky(t *testing.T, s *Service, headers http.Header, body string) string {
Expand Down Expand Up @@ -210,7 +207,7 @@ func TestStickinessHonorsBindingsMadeBeforeOptIn(t *testing.T) {
if err != nil {
t.Fatal(err)
}
s, _, _ = stickyGatewayFrom(t, s, true)
setSticky(t, s, true)
if _, err := s.accounts.SetEnabled(ctx, original, false); err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -246,7 +243,7 @@ func TestNormalizedBindingsSurviveOptOut(t *testing.T) {
if err != nil {
t.Fatal(err)
}
s, _, _ = stickyGatewayFrom(t, s, false)
setSticky(t, s, false)
if _, err := s.accounts.SetEnabled(ctx, original, false); err != nil {
t.Fatal(err)
}
Expand Down
16 changes: 10 additions & 6 deletions internal/updates/updates.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@ import (
"time"
)

const releasesURL = "https://api.github.com/repos/murongg/SubLane/releases/latest"
const releaseBase = "https://github.com/murongg/SubLane/releases/tag/v"
// This fork publishes its own releases; updates never switch an installation to another repository.
const repository = "Alumos/SubLane"
const releasesURL = "https://api.github.com/repos/" + repository + "/releases/latest"
const releaseBase = "https://github.com/" + repository + "/releases/tag/v"

var stable = regexp.MustCompile(`^(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})$`)
// Stable fork releases are X.Y.Z or X.Y.Z-revised.N, ordered by (X, Y, Z, N) with N=0 for X.Y.Z.
var stable = regexp.MustCompile(`^(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})(?:-revised\.([1-9][0-9]{0,5}))?$`)
var jobID = regexp.MustCompile(`^[a-f0-9]{32}$`)
var (
ErrCheck = errors.New("release check failed")
Expand Down Expand Up @@ -186,11 +189,12 @@ func (s *Service) fetch(ctx context.Context) (string, error) {
return version, nil
}
func newer(next, current string) bool {
if !stable.MatchString(next) || !stable.MatchString(current) {
a, b := stable.FindStringSubmatch(next), stable.FindStringSubmatch(current)
if a == nil || b == nil {
return false
}
a, b := strings.Split(next, "."), strings.Split(current, ".")
for i := range a {
for i := 1; i <= 4; i++ {
// An absent revision sorts before every revision of the same base release.
x, _ := strconv.Atoi(a[i])
y, _ := strconv.Atoi(b[i])
if x != y {
Expand Down
32 changes: 32 additions & 0 deletions internal/updates/updates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,35 @@ func TestMailboxRefusesSymlinksAndOversizedStatus(t *testing.T) {
t.Fatal("oversized status trusted")
}
}

func TestForkRevisionsOrderAfterTheirBaseRelease(t *testing.T) {
for _, tc := range []struct {
next, current string
want bool
}{
{"0.19.2-revised.8", "0.19.2-revised.7", true},
{"0.19.2-revised.10", "0.19.2-revised.9", true},
{"0.19.2-revised.1", "0.19.2", true},
{"0.19.3", "0.19.2-revised.9", true},
{"0.19.2-revised.7", "0.19.2-revised.7", false},
{"0.19.2", "0.19.2-revised.1", false},
{"0.19.2-revised.7", "0.20.0", false},
{"0.19.2-rc.1", "0.19.1", false},
{"0.19.2-revised.01", "0.19.1", false},
{"0.19.2-revised.0", "0.19.1", false},
} {
if got := newer(tc.next, tc.current); got != tc.want {
t.Fatal(tc.next, tc.current, got)
}
}
var requested string
s := New("0.19.2-revised.7", "", transport(func(r *http.Request) (*http.Response, error) {
requested = r.URL.String()
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"tag_name":"v0.19.2-revised.8"}`))}, nil
}))
defer s.Close()
state, err := s.Check(context.Background(), true)
if err != nil || !state.Available || requested != "https://api.github.com/repos/Alumos/SubLane/releases/latest" {
t.Fatal("fork release was not offered", requested, state, err)
}
}
3 changes: 2 additions & 1 deletion internal/upstream/images.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ func (c *Client) Images(ctx context.Context, credential accounts.Credential, raw
}
return nil, ErrUpstream
}
return &Stream{Response: response, registry: c.registry, model: model, original: raw, request: body}, nil
// Images responses are complete JSON documents; they never use stream translation state.
return &Stream{Response: response}, nil
}

// MultipartImageEdit converts an OpenAI multipart image edit into the JSON form the Codex endpoint accepts.
Expand Down
73 changes: 20 additions & 53 deletions internal/upstream/ipreport.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,29 +21,25 @@ var IPReportClient = &http.Client{Timeout: 15 * time.Second}

// IPReport is a bounded subset of the third-party lookup. Unknown values stay empty or nil.
type IPReport struct {
IP string `json:"ip"`
TrustScore *int64 `json:"trust_score"`
CompanyType string `json:"company_type"`
Residential bool `json:"residential"`
Datacenter bool `json:"datacenter"`
Mobile bool `json:"mobile"`
VPN bool `json:"vpn"`
Proxy bool `json:"proxy"`
Tor bool `json:"tor"`
Abuser bool `json:"abuser"`
Crawler bool `json:"crawler"`
ASN int64 `json:"asn"`
ASOrganization string `json:"as_organization"`
ISP string `json:"isp"`
CountryCode string `json:"country_code"`
Country string `json:"country"`
Region string `json:"region"`
City string `json:"city"`
RDNS string `json:"rdns"`
AbuserLevel string `json:"abuser_level"`
Verdict string `json:"verdict"`
Threats []string `json:"threats"`
FetchedAt int64 `json:"fetched_at"`
IP string `json:"ip"`
TrustScore *int64 `json:"trust_score"`
CompanyType string `json:"company_type"`
Residential bool `json:"residential"`
Datacenter bool `json:"datacenter"`
Mobile bool `json:"mobile"`
VPN bool `json:"vpn"`
Proxy bool `json:"proxy"`
Tor bool `json:"tor"`
Abuser bool `json:"abuser"`
Crawler bool `json:"crawler"`
ASN int64 `json:"asn"`
ASOrganization string `json:"as_organization"`
ISP string `json:"isp"`
CountryCode string `json:"country_code"`
Country string `json:"country"`
Region string `json:"region"`
City string `json:"city"`
Verdict string `json:"verdict"`
}

func boundedText(value string, limit int) string {
Expand Down Expand Up @@ -110,14 +106,9 @@ func LookupIPReport(ctx context.Context, ip string) (IPReport, error) {
Country string `json:"country"`
Region string `json:"region"`
City string `json:"city"`
RDNS string `json:"rdns"`
Verdict *struct {
Label string `json:"label"`
} `json:"ai_verdict"`
Intelligence *struct {
Level string `json:"abuser_level"`
Threats []json.RawMessage `json:"threats"`
} `json:"intelligence"`
}
if json.Unmarshal(raw, &payload) != nil {
return IPReport{}, ErrResponse
Expand All @@ -127,7 +118,7 @@ func LookupIPReport(ctx context.Context, ip string) (IPReport, error) {
}
report := IPReport{IP: address.String(), CompanyType: boundedText(payload.CompanyType, 32), Residential: payload.Residential, Datacenter: payload.Datacenter, Mobile: payload.Mobile, VPN: payload.VPN, Proxy: payload.Proxy, Tor: payload.Tor, Abuser: payload.Abuser, Crawler: payload.Crawler,
ASOrganization: boundedText(payload.ASOrg, 128), ISP: boundedText(payload.ISP, 128), CountryCode: strings.ToUpper(boundedText(payload.CountryCode, 2)), Country: boundedText(payload.Country, 64),
Region: boundedText(payload.Region, 64), City: boundedText(payload.City, 64), RDNS: boundedText(payload.RDNS, 253), Threats: []string{}, FetchedAt: time.Now().Unix()}
Region: boundedText(payload.Region, 64), City: boundedText(payload.City, 64)}
if payload.ASN > 0 && payload.ASN < 1<<32 {
report.ASN = payload.ASN
}
Expand All @@ -140,29 +131,5 @@ func LookupIPReport(ctx context.Context, ip string) (IPReport, error) {
if payload.Verdict != nil {
report.Verdict = boundedText(payload.Verdict.Label, 48)
}
if payload.Intelligence != nil {
report.AbuserLevel = boundedText(payload.Intelligence.Level, 32)
for _, raw := range payload.Intelligence.Threats {
if len(report.Threats) == 8 {
break
}
var name string
if json.Unmarshal(raw, &name) != nil {
var named struct {
Name string `json:"name"`
Type string `json:"type"`
}
if json.Unmarshal(raw, &named) == nil {
name = named.Name
if name == "" {
name = named.Type
}
}
}
if name = boundedText(name, 48); name != "" {
report.Threats = append(report.Threats, name)
}
}
}
return report, nil
}
2 changes: 1 addition & 1 deletion internal/upstream/ipreport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func TestLookupIPReportBoundsThirdPartyFields(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if report.TrustScore == nil || *report.TrustScore != 87 || !report.Residential || !report.Proxy || report.CountryCode != "JP" || report.ASN != 64500 || report.Verdict != "住宅宽带" || len(report.Threats) != 2 || report.Threats[1] != "scanner" || report.FetchedAt == 0 {
if report.TrustScore == nil || *report.TrustScore != 87 || !report.Residential || !report.Proxy || report.CountryCode != "JP" || report.ASN != 64500 || report.Verdict != "住宅宽带" {
t.Fatalf("report: %+v", report)
}
for _, private := range []string{"10.0.0.1", "127.0.0.1", "", "not-an-ip", "::1"} {
Expand Down
8 changes: 5 additions & 3 deletions scripts/install.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# Releases and images come from this fork so installs and updates stay on its channel.
install_repository=Alumos/SubLane

fail() {
printf 'Error: %s\n' "$*" >&2
Expand Down Expand Up @@ -232,7 +234,7 @@ release_metadata() {
install_api_status=$(curl --silent --show-error --location --proto '=https' --proto-redir '=https' \
--connect-timeout 10 --max-time 120 --retry 2 --header 'Accept: application/vnd.github+json' \
--output "$install_stage/releases.json" --write-out '%{http_code}' \
"https://api.github.com/repos/murongg/SubLane/$1") || fail 'Could not fetch GitHub releases. Retry or pass --version explicitly.'
"https://api.github.com/repos/$install_repository/$1") || fail 'Could not fetch GitHub releases. Retry or pass --version explicitly.'
}

resolve_version() {
Expand Down Expand Up @@ -630,8 +632,8 @@ prepare_release() {
trap 'exit 130' INT TERM
if [[ -z $install_version ]]; then resolve_version; fi
validate_version
install_release="https://github.com/murongg/SubLane/releases/download/v$install_version"
install_image="ghcr.io/murongg/sublane:$install_version"
install_release="https://github.com/$install_repository/releases/download/v$install_version"
install_image="ghcr.io/alumos/sublane:$install_version"
}

download_release() {
Expand Down
14 changes: 7 additions & 7 deletions scripts/install.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ function existing(t, runtime = "docker", mode = "", directory = "sublane") {
writeFileSync(join(f.target, ".env"), [
"# Operator settings",
"COMPOSE_PROJECT_NAME=sublane-synthetic",
"SUBLANE_IMAGE=ghcr.io/murongg/sublane:1.2.2",
"SUBLANE_IMAGE=ghcr.io/alumos/sublane:1.2.2",
"SUBLANE_BIND_ADDRESS=127.0.0.1",
"SUBLANE_PORT=18080",
"SUBLANE_PUBLIC_URL=https://gateway.example.test",
Expand Down Expand Up @@ -310,7 +310,7 @@ test("updates an existing Docker deployment to the latest release while preservi
for (const call of calls) {
assert.equal(call.args[call.args.indexOf("--project-name") + 1], "sublane-synthetic");
assert.equal(realpathSync(call.args[call.args.indexOf("--project-directory") + 1]), realpathSync(f.target));
assert.equal(call.image, "ghcr.io/murongg/sublane:1.2.3");
assert.equal(call.image, "ghcr.io/alumos/sublane:1.2.3");
assert.equal(call.port, "18080");
assert.equal(call.demo, "true");
assert.equal(call.logLevel, "warn");
Expand Down Expand Up @@ -784,7 +784,7 @@ test("installs a pinned release with checksums and waits for healthy Compose sta
assert.equal(result.status, 0, result.stderr);
assert.match(
readFileSync(join(f.target, ".env"), "utf8"),
/SUBLANE_IMAGE=ghcr.io\/murongg\/sublane:0\.1\.0-rc\.1/,
/SUBLANE_IMAGE=ghcr.io\/alumos\/sublane:0\.1\.0-rc\.1/,
);
assert.equal(
readFileSync(join(f.target, "docker.compose.yaml"), "utf8"),
Expand Down Expand Up @@ -813,7 +813,7 @@ test("supports piped execution, explicit versions, paths with spaces and custom
);
assert.equal(result.status, 0, result.stderr);
const env = readFileSync(join(target, ".env"), "utf8");
assert.match(env, /SUBLANE_IMAGE=ghcr.io\/murongg\/sublane:1\.2\.3-beta\.2/);
assert.match(env, /SUBLANE_IMAGE=ghcr.io\/alumos\/sublane:1\.2\.3-beta\.2/);
assert.match(env, /SUBLANE_PORT=18851/);
assert.match(env, /COMPOSE_PROJECT_NAME=sublane-[a-z0-9-]+/);
assert.match(result.stdout, /127\.0\.0\.1:18851/);
Expand Down Expand Up @@ -1022,7 +1022,7 @@ test("explicit installer settings take precedence over inherited Compose interpo
.filter((call) => call.args.includes("--project-name"));
assert.ok(calls.length >= 3);
for (const call of calls) {
assert.equal(call.image, "ghcr.io/murongg/sublane:0.1.0-rc.1");
assert.equal(call.image, "ghcr.io/alumos/sublane:0.1.0-rc.1");
assert.equal(call.port, "8080");
assert.notEqual(
call.args[call.args.indexOf("--project-name") + 1],
Expand Down Expand Up @@ -1065,7 +1065,7 @@ test("prefers the latest stable release without consulting prereleases", (t) =>
assert.equal(result.status, 0, result.stderr);
assert.match(
readFileSync(join(f.target, ".env"), "utf8"),
/SUBLANE_IMAGE=ghcr.io\/murongg\/sublane:1\.2\.3\n/,
/SUBLANE_IMAGE=ghcr.io\/alumos\/sublane:1\.2\.3\n/,
);
assert.equal(
f
Expand Down Expand Up @@ -1112,7 +1112,7 @@ test("without stable releases, selects the most recently published prerelease ac
assert.equal(result.status, 0, result.stderr);
assert.match(
readFileSync(join(f.target, ".env"), "utf8"),
/SUBLANE_IMAGE=ghcr.io\/murongg\/sublane:2\.0\.0-rc\.2\n/,
/SUBLANE_IMAGE=ghcr.io\/alumos\/sublane:2\.0\.0-rc\.2\n/,
);
});

Expand Down
16 changes: 12 additions & 4 deletions scripts/release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,26 @@ export function releaseMetadata(tag, repository) {
) {
throw new Error("Expected a GitHub owner/repository name.");
}
// Fork revisions (X.Y.Z-revised.N) are stable releases of this fork, not prereleases.
const revision = /^revised\.([1-9]\d*)$/.exec(match[4] ?? "");
return {
tag,
version: tag.slice(1),
prerelease: Boolean(match[4]),
prerelease: Boolean(match[4]) && !revision,
image: `ghcr.io/${repository.toLowerCase()}`,
};
}

// Stable versions order by (major, minor, patch, revision); a base release has revision 0.
function stableKey(version) {
const [base, revision = "0"] = version.split("-revised.");
return [...base.split(".").map(BigInt), BigInt(revision)];
}

export function isLatestStable(tag, tags) {
const current = releaseMetadata(tag, "owner/repo");
if (current.prerelease) return false;
const version = current.version.split(".").map(BigInt);
const version = stableKey(current.version);
for (const candidate of tags) {
let other;
try {
Expand All @@ -44,8 +52,8 @@ export function isLatestStable(tag, tags) {
continue;
}
if (other.prerelease) continue;
const parts = other.version.split(".").map(BigInt);
for (let i = 0; i < 3; i++) {
const parts = stableKey(other.version);
for (let i = 0; i < version.length; i++) {
if (parts[i] > version[i]) return false;
if (parts[i] < version[i]) break;
}
Expand Down
Loading