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
5 changes: 3 additions & 2 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,15 @@ jobs:
- name: Upload binaries, deployment files and checksums
run: |
cp docker.compose.yaml release-assets/docker.compose.yaml
cp scripts/install.sh scripts/updater.sh release-assets/
(
cd release-assets
sha256sum "sublane_${VERSION}_linux_amd64.tar.gz" "sublane_${VERSION}_linux_arm64.tar.gz" docker.compose.yaml > SHA256SUMS
sha256sum "sublane_${VERSION}_linux_amd64.tar.gz" "sublane_${VERSION}_linux_arm64.tar.gz" docker.compose.yaml install.sh updater.sh > SHA256SUMS
)
gh release upload "$TAG" \
"release-assets/sublane_${VERSION}_linux_amd64.tar.gz" \
"release-assets/sublane_${VERSION}_linux_arm64.tar.gz" \
release-assets/docker.compose.yaml release-assets/SHA256SUMS --clobber
release-assets/docker.compose.yaml release-assets/install.sh release-assets/updater.sh release-assets/SHA256SUMS --clobber
- name: Promote only the newest stable tag
id: channel
run: |
Expand Down
2 changes: 2 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ API channels offer mainstream platform presets and custom base URLs with Chat Co

## Capabilities and Constraints

Platform-owner software updates at `/admin/settings/updates` check official stable SubLane releases with bounded, cached requests. An opt-in systemd user worker reuses the checksum-verified installer for script-managed Linux Docker and binary deployments. The web process publishes one durable metadata-only job through a private host mailbox; it has no Docker socket or command execution capability. Installation requires a current-backup acknowledgement, only accepts a newer stable release, and reports worker completion separately from restart reconnection. Interrupted jobs are not replayed and migrated databases are never automatically downgraded. Custom deployments and development/prerelease builds retain release checks and manual upgrade instructions. Synthetic service, authorization, worker, installer and frontend checks cover the feature; real Linux service/container upgrades require separate acceptance.

Creating a resource allowance atomically links existing non-revoked keys in that pool belonging to its configured members, preserving pause/expiry settings and key values. Previously unbound keys show a self-service Restore access action only when the owner can join the active allowance. Owners can explicitly change their own keys to another authorized resource group within the same workspace without replacing the key or rewriting historical usage. Changes validate allocation membership and commit with audit; cross-workspace movement remains blocked by SQLite. Existing WebSocket connections reject later turns after a group change, and queued requests recheck the current group. Clients should start a new conversation after switching groups.

Model catalogs retain optional upstream-reported context windows, output limits, reasoning levels, image input and tool-call support in bounded account snapshots. Account and channel dialogs show these details; resource-group and personal-key catalogs merge permitted callable resources conservatively, preserving unknown values and hidden-model visibility rules. Public model discovery includes known capabilities without granting access or changing inference selection. Legacy snapshots remain readable and refresh to acquire metadata. An additive migration expands model snapshots to 512 KiB while preserving account identities, relationships and reset settings.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ Docker mode requires Docker Compose; binary mode requires Linux and a working sy

The script installs the latest stable release, or the newest prerelease if no stable release exists, in `./sublane`. Docker mode uses a named volume; binary mode uses a private data directory and systemd user service. For an existing script-managed installation, it offers an update in interactive mode; pass `--update --dir ./sublane` to update explicitly while preserving settings and data. Export and verify a backup before updating. For options, generated proxy files, HTTPS and upgrades, see the [deployment guide](https://sublane.dev/docs/deployment).

Add `--enable-updates` on a supported Linux installation to enable **System settings → Software updates** through a separate host worker. See [web updates](https://sublane.dev/docs/deployment#web-updates) for prerequisites and one-time setup.

## Development

Requires Go 1.26+, Node.js 24 and pnpm 9.12.2.
Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ Docker 模式需要 Docker Compose;二进制模式需要 Linux 和可用的 sy

脚本会在 `./sublane` 安装最新正式版,没有正式版时使用最新预发布版。Docker 模式使用数据卷;二进制模式使用私有数据目录和 systemd 用户服务。交互模式遇到脚本安装的已有实例时会提供更新选项;也可以传入 `--update --dir ./sublane` 明确更新,并保留配置和数据。更新前请导出并验证备份。自定义参数、代理配置、HTTPS 和升级步骤见[部署文档](https://sublane.dev/docs/zh/deployment)。

受支持的 Linux 安装可添加 `--enable-updates`,通过独立宿主机进程启用「系统设置 → 软件更新」。前置条件和首次配置见[网页更新](https://sublane.dev/docs/zh/deployment#网页更新)。

## 本地开发

需要 Go 1.26+、Node.js 24、pnpm 9.12.2。
Expand Down
5 changes: 4 additions & 1 deletion cmd/sublane/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/murongg/SubLane/internal/tenants"
"github.com/murongg/SubLane/internal/timezone"
"github.com/murongg/SubLane/internal/turnstile"
"github.com/murongg/SubLane/internal/updates"
"github.com/murongg/SubLane/internal/upstream"
"github.com/murongg/SubLane/internal/versions"
"github.com/murongg/SubLane/web"
Expand Down Expand Up @@ -176,7 +177,9 @@ func run() error {
if cfg.MetricsToken != "" {
telemetry = &metrics.Collector{}
}
registry := &tenantRegistry{metrics: telemetry, ctx: ctx, db: db, vault: cipher, auth: authentication,
softwareUpdates := updates.New(version, cfg.UpdateDir, nil)
defer softwareUpdates.Close()
registry := &tenantRegistry{updates: softwareUpdates, metrics: telemetry, ctx: ctx, db: db, vault: cipher, auth: authentication,
turnstile: loginVerification,
alerts: notifications,
tenants: tenancy, provider: provider, pricing: priceCatalog, versions: codexVersions, grokVersions: grokVersions, claudeVersions: claudeVersions, timeZone: timeZone,
Expand Down
4 changes: 3 additions & 1 deletion cmd/sublane/tenants.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/murongg/SubLane/internal/tenants"
"github.com/murongg/SubLane/internal/timezone"
"github.com/murongg/SubLane/internal/turnstile"
"github.com/murongg/SubLane/internal/updates"
"github.com/murongg/SubLane/internal/upstream"
"github.com/murongg/SubLane/internal/vault"
"github.com/murongg/SubLane/internal/versions"
Expand Down Expand Up @@ -81,6 +82,7 @@ type tenantRegistry struct {
provider *upstream.Client
pricing *pricing.Service
versions *versions.Service
updates *updates.Service
grokVersions *versions.Service
claudeVersions *versions.Service
timeZone *timezone.Service
Expand Down Expand Up @@ -162,7 +164,7 @@ func (r *tenantRegistry) acquire(id int64) *tenantRuntime {
Groups: groups.NewForTenant(r.db, id), Tenants: r.tenants, TenantID: id,
PublicURL: r.publicURL, TrustedProxies: r.trustedProxies, LoginLimiter: r.loginLimiter, EnrollmentLimiter: r.enrollmentLimiter,
CodexVersions: codexVersions, GrokVersions: grokVersions, ClaudeVersions: claudeVersions, TimeZone: r.timeZone,
Turnstile: r.turnstile,
Turnstile: r.turnstile, Updates: r.updates,
})
if r.runtimes == nil {
r.runtimes = make(map[int64]*tenantRuntime)
Expand Down
1 change: 1 addition & 0 deletions internal/audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ var actions = map[string]string{
"backup.export": "backup", "backup.prepare": "backup", "backup.verify": "backup",
"settings.update": "settings",
"settings.test": "settings",
"updates.request": "settings",
"key.reveal": "key", "key.create": "key", "key.update": "key", "key.revoke": "key",
"group.create": "group", "group.update": "group",
"member.budget": "member", "member.budget_settle": "member", "member.create": "member", "member.update": "member", "member.groups": "member", "member.limits": "member", "member.password": "member",
Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net"
"net/netip"
"net/url"
"path/filepath"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -34,12 +35,17 @@ type Config struct {
PricingOverride string
PricingInterval time.Duration
MetricsToken string
UpdateDir string
Gateway gateway.Policy
InferenceHeaderTimeout time.Duration
}

func Load(getenv func(string) string) (Config, error) {
c := Config{Addr: "127.0.0.1:8080", DataDir: "./data", LogLevel: slog.LevelInfo, MaxRequestBody: 128 << 20, PricingInterval: 6 * time.Hour, Gateway: gateway.DefaultPolicy(), InferenceHeaderTimeout: 5 * time.Minute}
c.UpdateDir = getenv("SUBLANE_UPDATE_DIR")
if c.UpdateDir != "" && !filepath.IsAbs(c.UpdateDir) {
return c, fmt.Errorf("SUBLANE_UPDATE_DIR must be an absolute host-worker mailbox path")
}
if err := c.loadGateway(getenv); err != nil {
return c, err
}
Expand Down
6 changes: 6 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/murongg/SubLane/internal/tenants"
"github.com/murongg/SubLane/internal/timezone"
"github.com/murongg/SubLane/internal/turnstile"
"github.com/murongg/SubLane/internal/updates"
"github.com/murongg/SubLane/internal/upstream"
"github.com/murongg/SubLane/internal/versions"
)
Expand Down Expand Up @@ -62,6 +63,7 @@ type Options struct {
ClaudeVersions *versions.Service
TimeZone *timezone.Service
Turnstile *turnstile.Service
Updates *updates.Service
ProxyCheck func(context.Context, string) (upstream.ProxyCheck, error)
}

Expand Down Expand Up @@ -227,6 +229,10 @@ func New(o Options) http.Handler {
management.Route("/alerts", (&alertHTTP{service: o.Alerts, tenantID: tenantID}).register)
management.Route("/content", (&contentHTTP{service: o.Content}).register)
management.Get("/audit", (&auditHTTP{service: o.Audit}).list)
management.Route("/settings/updates", func(settings chi.Router) {
settings.Use(requirePlatformAdmin(tenantID))
(&updateHTTP{service: o.Updates, audit: o.Audit, version: o.Version}).register(settings)
})
management.Route("/settings/backup", func(settings chi.Router) {
settings.Use(requirePlatformAdmin(tenantID))
(&backupHTTP{directory: o.DataDir, keyFile: o.CredentialsKeyFile, version: o.Version, auth: o.Auth, audit: o.Audit, slots: make(chan struct{}, 1)}).register(settings)
Expand Down
92 changes: 92 additions & 0 deletions internal/server/updates.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package server

import (
"errors"
"net/http"

"github.com/go-chi/chi/v5"
"github.com/murongg/SubLane/internal/audit"
"github.com/murongg/SubLane/internal/updates"
)

type updateHTTP struct {
service *updates.Service
audit *audit.Service
version string
}

func (h *updateHTTP) register(router chi.Router) {
routeErrors(router)
router.Get("/", h.get)
router.Post("/check", h.check)
router.Post("/install", h.install)
}
func (h *updateHTTP) get(w http.ResponseWriter, r *http.Request) {
if h.service == nil {
writeJSON(w, 200, updates.State{CurrentVersion: h.version})
return
}
state, _ := h.service.Check(r.Context(), false)
writeJSON(w, 200, state)
}
func (h *updateHTTP) check(w http.ResponseWriter, r *http.Request) {
var input struct{}
if !decodeJSONLimit(w, r, &input, 1024) {
return
}
if h.service == nil {
h.failed(w, updates.ErrDisabled)
return
}
state, err := h.service.Check(r.Context(), true)
if err != nil {
h.failed(w, err)
return
}
writeJSON(w, 200, state)
}
func (h *updateHTTP) install(w http.ResponseWriter, r *http.Request) {
var input struct {
Version string `json:"version"`
BackupConfirmed bool `json:"backup_confirmed"`
}
if !decodeJSONLimit(w, r, &input, 1024) {
return
}
if !input.BackupConfirmed {
h.failed(w, updates.ErrInput)
return
}
if h.service == nil {
h.failed(w, updates.ErrDisabled)
return
}
// Record the authorized request before publishing any host-side work, not an installation success.
if h.audit == nil || h.audit.Observation(r.Context(), "updates.request", "settings", "") != nil {
writeJSON(w, 503, map[string]string{"error": "update_audit_failed"})
return
}
state, err := h.service.Queue(r.Context(), input.Version, input.BackupConfirmed)
if err != nil {
h.failed(w, err)
return
}
writeJSON(w, 202, state)
}
func (h *updateHTTP) failed(w http.ResponseWriter, err error) {
status, code := 503, "update_failed"
switch {
case errors.Is(err, updates.ErrInput):
status, code = 400, "invalid_update"
case errors.Is(err, updates.ErrDisabled):
code = "update_unavailable"
case errors.Is(err, updates.ErrBusy):
status, code = 409, "update_busy"
case errors.Is(err, updates.ErrCooldown):
status, code = 429, "update_cooldown"
w.Header().Set("Retry-After", "60")
case errors.Is(err, updates.ErrCheck):
code = "update_check_failed"
}
writeJSON(w, status, map[string]string{"error": code})
}
96 changes: 96 additions & 0 deletions internal/server/updates_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package server

import (
"context"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"

"github.com/murongg/SubLane/internal/audit"
"github.com/murongg/SubLane/internal/auth"
"github.com/murongg/SubLane/internal/storage"
"github.com/murongg/SubLane/internal/updates"
)

type updateTransport struct{}

func (updateTransport) RoundTrip(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"tag_name":"v1.3.0"}`))}, nil
}

func TestSoftwareUpdatesRequirePlatformOwnerAndSameOrigin(t *testing.T) {
ctx := context.Background()
connection, err := storage.Open(ctx, filepath.Join(t.TempDir(), "synthetic.db"))
if err != nil {
t.Fatal(err)
}
defer connection.Close()
identity, err := auth.New(connection)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "heartbeat"), []byte("ready"), 0644)
service := updates.New("1.2.0", dir, updateTransport{})
defer service.Close()
h := New(Options{Auth: identity, Audit: audit.New(connection), Updates: service})
origin := "http://example.test"
owner := request(h, "POST", "/api/auth/setup", origin, map[string]string{"username": "synthetic-owner", "password": "synthetic-pass", "workspace_name": "Synthetic workspace"}, nil).Result().Cookies()[0]
input := map[string]string{"username": "synthetic-member", "password": "synthetic-pass"}
if r := request(h, "POST", "/api/members", origin, input, owner); r.Code != 201 {
t.Fatal(r.Code, r.Body.String())
}
member := request(h, "POST", "/api/auth/login", origin, input, nil).Result().Cookies()[0]
path := "/api/settings/updates"
for _, suffix := range []string{"", "/check", "/install", "/missing"} {
for _, method := range []string{"GET", "POST", "DELETE"} {
if r := request(h, method, path+suffix, origin, map[string]any{}, nil); r.Code != 401 {
t.Fatal("anonymous", method, suffix, r.Code)
}
if r := request(h, method, path+suffix, origin, map[string]any{}, member); r.Code != 403 {
t.Fatal("member", method, suffix, r.Code)
}
}
}
if r := request(h, "GET", path, origin, nil, owner); r.Code != 200 || !strings.Contains(r.Body.String(), `"available":true`) {
t.Fatal(r.Code, r.Body.String())
}
if r := request(h, "POST", path+"/install", "https://foreign.example.test", map[string]any{}, owner); r.Code != 403 {
t.Fatal("origin", r.Code)
}
if r := request(h, "POST", path+"/install", origin, map[string]any{"version": "1.3.0", "backup_confirmed": false}, owner); r.Code != 400 {
t.Fatal("backup", r.Code, r.Body.String())
}
if r := request(h, "POST", path+"/install", origin, map[string]any{"version": "1.3.0", "backup_confirmed": true}, owner); r.Code != 202 {
t.Fatal("install", r.Code, r.Body.String())
}
if r := request(h, "POST", path+"/install", origin, map[string]any{"version": "1.3.0", "backup_confirmed": true}, owner); r.Code != 409 {
t.Fatal("duplicate", r.Code, r.Body.String())
}
if r := request(h, "DELETE", path, origin, nil, owner); r.Code != 405 {
t.Fatal("method", r.Code)
}
if r := request(h, "GET", path+"/missing", origin, nil, owner); r.Code != 404 {
t.Fatal("unknown", r.Code)
}
if _, err := os.Stat(filepath.Join(dir, "request.json")); err != nil {
t.Fatal(err)
}
if _, err := connection.Exec("UPDATE memberships SET role='admin' WHERE tenant_id=1 AND user_id=2"); err != nil {
t.Fatal(err)
}
if r := request(h, "GET", path, origin, nil, member); r.Code != 403 {
t.Fatal("workspace administrator is not platform owner", r.Code)
}
var audited int
if err := connection.QueryRow("SELECT count(*) FROM audit_events WHERE action='updates.request'").Scan(&audited); err != nil || audited != 2 {
t.Fatal("authorized update requests must be audited", audited, err)
}
second := New(Options{Auth: identity, TenantID: 2, Updates: service})
if r := request(second, "GET", path, origin, nil, owner); r.Code != 401 {
t.Fatal("workspace", r.Code)
}
}
Loading