From fd5147e5cca2bd496886ab4fac66f0698a6e4a53 Mon Sep 17 00:00:00 2001 From: MuRong Date: Sun, 1 Nov 2026 15:13:46 +0800 Subject: [PATCH 1/2] feat(updates): add owner-controlled web updates --- .github/workflows/release.yaml | 5 +- PRODUCT.md | 2 + README.md | 2 + README.zh-CN.md | 2 + cmd/sublane/main.go | 5 +- cmd/sublane/tenants.go | 4 +- internal/audit/audit.go | 1 + internal/config/config.go | 6 + internal/server/server.go | 6 + internal/server/updates.go | 92 ++++++++++ internal/server/updates_test.go | 96 +++++++++++ internal/updates/updates.go | 254 +++++++++++++++++++++++++++ internal/updates/updates_test.go | 194 +++++++++++++++++++++ scripts/install.sh | 109 +++++++++++- scripts/install.test.mjs | 79 ++++++++- scripts/package.sh | 3 +- scripts/updater.sh | 126 ++++++++++++++ scripts/updater.test.mjs | 86 +++++++++ web/src/components/Layout.tsx | 1 + web/src/lib/audit.ts | 1 + web/src/lib/updates.ts | 81 +++++++++ web/src/locales/ar.json | 29 +++- web/src/locales/de.json | 29 +++- web/src/locales/en.ts | 35 ++++ web/src/locales/es.json | 29 +++- web/src/locales/fr.json | 29 +++- web/src/locales/ja.json | 29 +++- web/src/locales/ko.json | 29 +++- web/src/locales/pt.json | 29 +++- web/src/locales/ru.json | 29 +++- web/src/locales/zh-TW.json | 29 +++- web/src/locales/zh.ts | 31 ++++ web/src/pages/Updates.test.tsx | 164 ++++++++++++++++++ web/src/pages/Updates.tsx | 287 +++++++++++++++++++++++++++++++ web/src/router.tsx | 5 + 35 files changed, 1919 insertions(+), 19 deletions(-) create mode 100644 internal/server/updates.go create mode 100644 internal/server/updates_test.go create mode 100644 internal/updates/updates.go create mode 100644 internal/updates/updates_test.go create mode 100644 scripts/updater.sh create mode 100644 scripts/updater.test.mjs create mode 100644 web/src/lib/updates.ts create mode 100644 web/src/pages/Updates.test.tsx create mode 100644 web/src/pages/Updates.tsx diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 567ee3b5..c7442288 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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: | diff --git a/PRODUCT.md b/PRODUCT.md index 3705a62e..e776378f 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -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. diff --git a/README.md b/README.md index 6fe1b9d3..34b7be6e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.zh-CN.md b/README.zh-CN.md index b6d72553..1ce28532 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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。 diff --git a/cmd/sublane/main.go b/cmd/sublane/main.go index 59ce8041..13bf542a 100644 --- a/cmd/sublane/main.go +++ b/cmd/sublane/main.go @@ -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" @@ -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, diff --git a/cmd/sublane/tenants.go b/cmd/sublane/tenants.go index 5482f24c..f8994028 100644 --- a/cmd/sublane/tenants.go +++ b/cmd/sublane/tenants.go @@ -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" @@ -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 @@ -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) diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 5d2adea4..dc654ce0 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -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", diff --git a/internal/config/config.go b/internal/config/config.go index f92a15e1..a9fc4d71 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "net" "net/netip" "net/url" + "path/filepath" "strconv" "strings" "time" @@ -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 } diff --git a/internal/server/server.go b/internal/server/server.go index 47f2f3c4..5e58a2b4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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" ) @@ -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) } @@ -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) diff --git a/internal/server/updates.go b/internal/server/updates.go new file mode 100644 index 00000000..6cea625f --- /dev/null +++ b/internal/server/updates.go @@ -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}) +} diff --git a/internal/server/updates_test.go b/internal/server/updates_test.go new file mode 100644 index 00000000..f7993223 --- /dev/null +++ b/internal/server/updates_test.go @@ -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) + } +} diff --git a/internal/updates/updates.go b/internal/updates/updates.go new file mode 100644 index 00000000..34b3a80a --- /dev/null +++ b/internal/updates/updates.go @@ -0,0 +1,254 @@ +// Package updates checks SubLane releases and publishes jobs to an opt-in host worker. +// It never executes programs or controls Docker from the web process. +package updates + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +const releasesURL = "https://api.github.com/repos/murongg/SubLane/releases/latest" +const releaseBase = "https://github.com/murongg/SubLane/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})$`) +var jobID = regexp.MustCompile(`^[a-f0-9]{32}$`) +var ( + ErrCheck = errors.New("release check failed") + ErrCooldown = errors.New("release check cooldown") + ErrInput = errors.New("invalid update request") + ErrDisabled = errors.New("host updater unavailable") + ErrBusy = errors.New("update already queued") +) + +type Job struct { + ID string `json:"id"` + Version string `json:"version"` + Status string `json:"status"` + UpdatedAt int64 `json:"updated_at"` +} +type State struct { + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version"` + ReleaseURL string `json:"release_url"` + CheckedAt int64 `json:"checked_at"` + CheckFailed bool `json:"check_failed"` + Available bool `json:"available"` + Enabled bool `json:"enabled"` + Job *Job `json:"job"` +} +type Service struct { + mu sync.Mutex + current, directory, latest string + checkedAt, attemptedAt time.Time + checkFailed bool + client *http.Client +} + +func New(current, directory string, transport http.RoundTripper) *Service { + if transport == nil { + t := http.DefaultTransport.(*http.Transport).Clone() + t.ResponseHeaderTimeout = 10 * time.Second + t.MaxIdleConnsPerHost = 1 + transport = t + } + return &Service{current: current, directory: directory, client: &http.Client{Transport: transport, Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} +} +func (s *Service) Close() { s.client.CloseIdleConnections() } +func (s *Service) View() State { + s.mu.Lock() + defer s.mu.Unlock() + return s.view() +} +func (s *Service) view() State { + state := State{CurrentVersion: s.current, LatestVersion: s.latest, CheckFailed: s.checkFailed} + if !s.checkedAt.IsZero() { + state.CheckedAt = s.checkedAt.Unix() + } + if s.latest != "" { + state.ReleaseURL = releaseBase + s.latest + } + state.Available = !s.checkFailed && time.Since(s.checkedAt) < time.Hour && newer(s.latest, s.current) + root, err := s.mailbox() + if err != nil { + return state + } + defer root.Close() + if info, err := root.Lstat("heartbeat"); err == nil && info.Mode().IsRegular() { + age := time.Since(info.ModTime()) + state.Enabled = age >= 0 && age < 15*time.Second + } + // A pending request remains present until readiness has been checked by the host. + var request Job + if readJob(root, "request.json", &request) == nil { + state.Job = &request + var status Job + if readJob(root, "status.json", &status) == nil && status.ID == request.ID { + state.Job = &status + } + } else { + var status Job + if readJob(root, "status.json", &status) == nil { + state.Job = &status + } + } + return state +} +func (s *Service) mailbox() (*os.Root, error) { + if s.directory == "" { + return nil, ErrDisabled + } + info, err := os.Lstat(s.directory) + if err != nil || !info.IsDir() { + return nil, ErrDisabled + } + return os.OpenRoot(s.directory) +} +func readJob(root *os.Root, name string, value *Job) error { + info, err := root.Lstat(name) + if err != nil || !info.Mode().IsRegular() || info.Size() > 2048 { + return ErrInput + } + f, err := root.Open(name) + if err != nil { + return err + } + defer f.Close() + decoder := json.NewDecoder(io.LimitReader(f, 2049)) + decoder.DisallowUnknownFields() + if decoder.Decode(value) != nil || decoder.Decode(new(any)) != io.EOF || !jobID.MatchString(value.ID) || !stable.MatchString(value.Version) || value.UpdatedAt <= 0 { + return ErrInput + } + switch value.Status { + case "queued", "running", "succeeded", "failed", "interrupted": + return nil + } + return ErrInput +} +func (s *Service) Check(ctx context.Context, force bool) (State, error) { + s.mu.Lock() + defer s.mu.Unlock() + if !force && time.Since(s.attemptedAt) < time.Hour { + return s.view(), nil + } + if time.Since(s.attemptedAt) < time.Minute { + return s.view(), ErrCooldown + } + s.attemptedAt = time.Now() + latest, err := s.fetch(ctx) + s.checkFailed = err != nil + if err != nil { + return s.view(), ErrCheck + } + s.latest = latest + s.checkedAt = time.Now() + return s.view(), nil +} +func (s *Service) fetch(ctx context.Context) (string, error) { + r, err := http.NewRequestWithContext(ctx, http.MethodGet, releasesURL, nil) + if err != nil { + return "", err + } + r.Header.Set("Accept", "application/vnd.github+json") + r.Header.Set("User-Agent", "SubLane") + response, err := s.client.Do(r) + if err != nil { + return "", err + } + defer response.Body.Close() + if response.StatusCode != 200 { + return "", ErrCheck + } + limited := &io.LimitedReader{R: response.Body, N: (2 << 20) + 1} + decoder := json.NewDecoder(limited) + var release struct { + Tag string `json:"tag_name"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + } + if decoder.Decode(&release) != nil || decoder.Decode(new(any)) != io.EOF || limited.N <= 0 || release.Draft || release.Prerelease || !strings.HasPrefix(release.Tag, "v") { + return "", ErrCheck + } + version := strings.TrimPrefix(release.Tag, "v") + if !stable.MatchString(version) { + return "", ErrCheck + } + return version, nil +} +func newer(next, current string) bool { + if !stable.MatchString(next) || !stable.MatchString(current) { + return false + } + a, b := strings.Split(next, "."), strings.Split(current, ".") + for i := range a { + x, _ := strconv.Atoi(a[i]) + y, _ := strconv.Atoi(b[i]) + if x != y { + return x > y + } + } + return false +} +func (s *Service) Queue(ctx context.Context, version string, backupConfirmed bool) (State, error) { + s.mu.Lock() + defer s.mu.Unlock() + state := s.view() + if !backupConfirmed || !state.Available || version != s.latest { + return state, ErrInput + } + if !state.Enabled { + return state, ErrDisabled + } + if err := ctx.Err(); err != nil { + return state, err + } + root, err := s.mailbox() + if err != nil { + return state, ErrDisabled + } + defer root.Close() + var bytes [16]byte + if _, err := rand.Read(bytes[:]); err != nil { + return state, err + } + job := Job{ID: hex.EncodeToString(bytes[:]), Version: version, Status: "queued", UpdatedAt: time.Now().Unix()} + temp := ".request-" + job.ID + f, err := root.OpenFile(temp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + if err != nil { + return state, err + } + defer root.Remove(temp) + // The host worker can have a different UID from the application container. + err = f.Chmod(0644) + if err == nil { + err = json.NewEncoder(f).Encode(job) + } + if err == nil { + err = f.Sync() + } + closeErr := f.Close() + if err != nil { + return state, err + } + if closeErr != nil { + return state, closeErr + } + // Linking publishes a complete job without replacing a job from another process. + if err := root.Link(temp, "request.json"); err != nil { + if os.IsExist(err) { + return s.view(), ErrBusy + } + return state, err + } + return s.view(), nil +} diff --git a/internal/updates/updates_test.go b/internal/updates/updates_test.go new file mode 100644 index 00000000..fb32694b --- /dev/null +++ b/internal/updates/updates_test.go @@ -0,0 +1,194 @@ +package updates + +import ( + "context" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +type transport func(*http.Request) (*http.Response, error) + +func (f transport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func releaseClient(body string, calls *int) transport { + return func(r *http.Request) (*http.Response, error) { + *calls++ + if r.URL.String() != releasesURL || r.Header.Get("Accept") != "application/vnd.github+json" { + return nil, errors.New("unexpected release request") + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + } +} + +const stableRelease = `{"tag_name":"v1.3.0","draft":false,"prerelease":false}` + +func TestChecksAreBoundedCachedAndNeverQueueInstallation(t *testing.T) { + calls := 0 + s := New("1.2.0", t.TempDir(), releaseClient(stableRelease, &calls)) + defer s.Close() + state, err := s.Check(context.Background(), false) + if err != nil || !state.Available || state.LatestVersion != "1.3.0" || state.Enabled || state.Job != nil { + t.Fatal(state, err) + } + if _, err := s.Check(context.Background(), false); err != nil || calls != 1 { + t.Fatal(calls, err) + } + if _, err := s.Check(context.Background(), true); !errors.Is(err, ErrCooldown) { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(s.directory, "request.json")); !os.IsNotExist(err) { + t.Fatal(err) + } +} + +func TestRejectsUnusableReleaseMetadata(t *testing.T) { + for _, body := range []string{ + `{"tag_name":"v1.3.0","draft":true}`, `{"tag_name":"v1.3.0-rc.1","prerelease":true}`, + `{"tag_name":"v01.3.0"}`, `{"tag_name":"v1.3.0;echo bad"}`, stableRelease + `{}`, + `{"tag_name":"v1.3.0","body":"` + strings.Repeat("x", 2<<20) + `"}`, + } { + calls := 0 + s := New("1.2.0", "", releaseClient(body, &calls)) + if state, err := s.Check(context.Background(), true); !errors.Is(err, ErrCheck) || !state.CheckFailed || state.Available { + t.Fatal(len(body), state, err) + } + s.Close() + } + s := New("1.2.0", "", transport(func(*http.Request) (*http.Response, error) { return nil, errors.New("synthetic failure") })) + defer s.Close() + if state, err := s.Check(context.Background(), false); !errors.Is(err, ErrCheck) || !state.CheckFailed { + t.Fatal(state, err) + } +} + +func TestOnlyNewerStableVersionsCanBeInstalled(t *testing.T) { + for _, current := range []string{"1.3.0", "1.4.0", "0.1.0-dev", "unknown"} { + calls := 0 + s := New(current, "", releaseClient(stableRelease, &calls)) + state, err := s.Check(context.Background(), false) + if err != nil || state.Available { + t.Fatal(current, state, err) + } + s.Close() + } +} + +func TestQueueRequiresBackupFreshMetadataAndLiveWorker(t *testing.T) { + dir := t.TempDir() + calls := 0 + s := New("1.2.0", dir, releaseClient(stableRelease, &calls)) + defer s.Close() + if _, err := s.Queue(context.Background(), "1.3.0", true); !errors.Is(err, ErrInput) { + t.Fatal(err) + } + if _, err := s.Check(context.Background(), false); err != nil { + t.Fatal(err) + } + if _, err := s.Queue(context.Background(), "1.3.0", true); !errors.Is(err, ErrDisabled) { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "heartbeat"), []byte("ready"), 0644); err != nil { + t.Fatal(err) + } + for _, input := range []struct { + version string + backup bool + }{{"1.3.0", false}, {"1.4.0", true}, {"../1.3.0", true}, {"1.2.0", true}} { + if _, err := s.Queue(context.Background(), input.version, input.backup); !errors.Is(err, ErrInput) { + t.Fatal(input, err) + } + } + s.checkedAt = time.Now().Add(-2 * time.Hour) + if _, err := s.Queue(context.Background(), "1.3.0", true); !errors.Is(err, ErrInput) { + t.Fatal("stale metadata", err) + } + s.checkedAt = time.Now() + stale := time.Now().Add(-time.Minute) + if err := os.Chtimes(filepath.Join(dir, "heartbeat"), stale, stale); err != nil { + t.Fatal(err) + } + if _, err := s.Queue(context.Background(), "1.3.0", true); !errors.Is(err, ErrDisabled) { + t.Fatal("stale worker", err) + } +} + +func TestConcurrentQueueAndProcessRestartRetainOneJob(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "heartbeat"), []byte("ready"), 0644); err != nil { + t.Fatal(err) + } + calls := 0 + s := New("1.2.0", dir, releaseClient(stableRelease, &calls)) + defer s.Close() + if _, err := s.Check(context.Background(), false); err != nil { + t.Fatal(err) + } + peerCalls := 0 + peer := New("1.2.0", dir, releaseClient(stableRelease, &peerCalls)) + defer peer.Close() + if _, err := peer.Check(context.Background(), false); err != nil { + t.Fatal(err) + } + var group sync.WaitGroup + results := make(chan error, 10) + for i := range 10 { + service := s + if i%2 == 1 { + service = peer + } + group.Go(func() { _, err := service.Queue(context.Background(), "1.3.0", true); results <- err }) + } + group.Wait() + close(results) + success := 0 + for err := range results { + if err == nil { + success++ + } else if !errors.Is(err, ErrBusy) { + t.Fatal(err) + } + } + if success != 1 { + t.Fatal(success) + } + other := New("1.2.0", dir, nil) + defer other.Close() + job := other.View().Job + if job == nil || job.Version != "1.3.0" || job.Status != "queued" { + t.Fatal(job) + } +} + +func TestMailboxRefusesSymlinksAndOversizedStatus(t *testing.T) { + dir := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside") + if err := os.WriteFile(outside, []byte("private synthetic data"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(dir, "heartbeat")); err != nil { + t.Fatal(err) + } + s := New("1.2.0", dir, nil) + defer s.Close() + if s.View().Enabled { + t.Fatal("symlink heartbeat trusted") + } + if err := os.Symlink(outside, filepath.Join(dir, "status.json")); err != nil { + t.Fatal(err) + } + if s.View().Job != nil { + t.Fatal("symlink status trusted") + } + os.Remove(filepath.Join(dir, "status.json")) + os.WriteFile(filepath.Join(dir, "status.json"), []byte(strings.Repeat("x", 2049)), 0644) + if s.View().Job != nil { + t.Fatal("oversized status trusted") + } +} diff --git a/scripts/install.sh b/scripts/install.sh index 4181f65e..9362c2b5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -10,14 +10,15 @@ usage() { cat <<'HELP' Install or update SubLane from a published Docker image or Linux binary. -Usage: bash install.sh [--version VERSION] [--dir DIRECTORY] [--port PORT] +Usage: bash install.sh [--version VERSION] [--dir DIRECTORY] [--port PORT] [--enable-updates] [--runtime docker|binary] [--proxy none|caddy|nginx] [--domain DOMAIN] [--trusted-proxies CIDRS] [--wait-timeout SECONDS] [--demo] [--non-interactive] - bash install.sh --update [--dir DIRECTORY] [--version VERSION] + bash install.sh --update [--dir DIRECTORY] [--version VERSION] [--enable-updates] [--wait-timeout SECONDS] [--non-interactive] --update Update an existing script-managed installation in place + --enable-updates Enable owner-only web updates through a systemd user worker --version VERSION Published version, with or without v (default: latest stable, or newest published prerelease when no stable release exists) --dir DIRECTORY Installation directory (default: ./sublane) @@ -515,6 +516,15 @@ load_installation() { || fail 'Update requires an existing installation directory, not a symlink.' command -v awk >/dev/null || fail 'Install awk first.' install_dir=$(cd -- "$install_dir" && pwd -P) + [[ ! -L $install_dir/.updater && ! -L $install_dir/.updater/mailbox ]] \ + || fail 'Update does not follow symlinked worker directories.' + if [[ -e $install_dir/.updater/compose.yaml || -L $install_dir/.updater/compose.yaml ]]; then + [[ -f $install_dir/.updater/compose.yaml && ! -L $install_dir/.updater/compose.yaml ]] \ + || fail 'Update does not follow a symlinked worker overlay.' + command -v jq >/dev/null || fail 'Install jq for the enabled host updater.' + cmp -s "$install_dir/.updater/compose.yaml" <(online_compose) \ + || fail 'The host-worker overlay was customized; use the manual upgrade procedure.' + fi if [[ -f $install_dir/.env && -f $install_dir/docker.compose.yaml && ! -e $install_dir/sublane.env ]]; then local image override local -a overrides=("$install_dir"/docker.compose.*.yaml "$install_dir"/docker.compose.*.yml) @@ -567,6 +577,22 @@ prepare_release() { for dependency in curl awk mktemp; do command -v "$dependency" >/dev/null || fail "Install $dependency first." done + if [[ $install_enable_updates == true ]]; then + for dependency in jq systemctl uname; do + command -v "$dependency" >/dev/null || fail "Install $dependency to enable web updates." + done + [[ $(uname -s) == Linux && $install_demo != true ]] || fail 'Web updates require a normal Linux installation.' + systemctl --user show-environment >/dev/null 2>&1 || fail 'A running systemd user manager is required for web updates.' + case "$install_dir" in + *'%'*|*'$'*|*'"'*|*$'\\'*) fail 'Web update paths cannot contain %, $, quotes, or backslashes.' ;; + esac + local helper + for helper in .updater .updater/mailbox .updater/install.sh .updater/updater.sh .updater/compose.yaml; do + [[ ! -L $install_dir/$helper ]] || fail 'Web updates refuse symlinked worker files.' + done + [[ ! -e $install_dir/.updater/mailbox/request.json && ! -L $install_dir/.updater/mailbox/request.json ]] \ + || fail 'A web update is already queued.' + fi if [[ -z $install_version ]]; then command -v jq >/dev/null || fail 'Install jq for automatic version selection, or pass --version explicitly.' fi @@ -618,6 +644,14 @@ download_release() { download "$install_artifact" download SHA256SUMS verify_download "$install_artifact" + if [[ $install_enable_updates == true ]]; then + local helper + for helper in install.sh updater.sh; do + download "$helper" + verify_download "$helper" + bash -n "$install_stage/$helper" || fail "Invalid verified $helper." + done + fi if [[ $install_runtime == docker ]]; then require_demo_support "$install_stage/docker.compose.yaml" else @@ -643,12 +677,77 @@ update_compose() { # Let the saved .env win over unrelated settings inherited from the caller. for variable in ${!SUBLANE_@}; do unset "$variable"; done unset COMPOSE_PROJECT_NAME COMPOSE_FILE COMPOSE_ENV_FILES COMPOSE_PROFILES + local -a files=(-f "$directory/docker.compose.yaml") + if [[ -f $install_dir/.updater/compose.yaml ]]; then files+=(-f "$install_dir/.updater/compose.yaml"); fi # Relative bind mounts must resolve against the live deployment, including during pull. docker compose --project-name "$install_project" --project-directory "$install_dir" \ - --env-file "$directory/.env" -f "$directory/docker.compose.yaml" "$@" "$private/$unit" < "$private/compose.yaml" + update_compose "$install_dir" config --quiet || fail 'Web update Compose configuration is invalid.' + update_compose "$install_dir" up --detach --wait --wait-timeout "$install_wait_timeout" --no-build \ + || fail 'SubLane did not become ready with the update mailbox.' + else + [[ ! -L $install_dir/sublane.env ]] || fail 'Web updates refuse symlinked configuration.' + awk '!/^[[:space:]]*SUBLANE_UPDATE_DIR[[:space:]]*=/' "$install_dir/sublane.env" > "$private/sublane.env" + printf '\nSUBLANE_UPDATE_DIR=%q\n' "$private/mailbox" >> "$private/sublane.env" + mv -- "$private/sublane.env" "$install_dir/sublane.env" + systemctl --user restart "$install_unit" || fail 'Could not restart SubLane with the update mailbox.' + binary_ready || fail 'SubLane did not become ready with the update mailbox.' + fi + systemctl --user link "$private/$unit" \ + && systemctl --user daemon-reload \ + && systemctl --user enable "$unit" \ + && systemctl --user restart "$unit" || fail 'Could not enable the host updater. Inspect its systemd user unit.' + printf '\nWeb updates enabled. Host diagnostics: %s/last.log\n' "$private" +} + update_failed() { printf 'Previous files are retained at %s; current configuration and data are retained at %s.\n' \ "$install_backup" "$install_dir" >&2 @@ -709,6 +808,7 @@ update_installation() { binary_ready || update_failed "Updated systemd service did not become ready within $install_wait_timeout seconds." fi printf '\nUpdate complete: %s\nPrevious files: %s\n' "$install_dir" "$install_backup" + if [[ $install_enable_updates == true ]]; then enable_online_updates; fi } main() { @@ -724,6 +824,7 @@ main() { install_no_prompt=false install_prompted=false install_update=false + install_enable_updates=false install_options=false install_lock='' while [[ $# -gt 0 ]]; do @@ -747,6 +848,7 @@ main() { ;; --non-interactive) install_no_prompt=true; shift ;; --update) install_update=true; shift ;; + --enable-updates) install_enable_updates=true; shift ;; --demo) install_demo=true; install_options=true; shift ;; --help|-h) usage; return ;; *) fail "Unknown argument: $1. Use --help." ;; @@ -855,6 +957,7 @@ main() { generate_proxy_config show_configuration if [[ $install_runtime == docker ]]; then install_docker; else install_binary; fi + if [[ $install_enable_updates == true ]]; then enable_online_updates; fi printf '\nInstallation complete: %s\n' "$install_dir" if [[ $install_proxy == none ]]; then if [[ $install_demo == true ]]; then diff --git a/scripts/install.test.mjs b/scripts/install.test.mjs index d2863aa9..fd73088b 100644 --- a/scripts/install.test.mjs +++ b/scripts/install.test.mjs @@ -20,6 +20,8 @@ import { test } from "node:test"; const script = fileURLToPath(new URL("./install.sh", import.meta.url)); const compose = "services:\n sublane:\n image: ${SUBLANE_IMAGE}\n environment:\n SUBLANE_DEMO: ${SUBLANE_DEMO:-false}\n"; +const helpers={"install.sh":"#!/bin/bash\necho 'Synthetic installer'\n","updater.sh":"#!/bin/bash\necho 'Synthetic updater'\n"}; +const helperChecksums=Object.entries(helpers).map(([name,body])=>createHash("sha256").update(body).digest("hex")+" "+name+"\n").join(""); function fixture(t, mode = "", api = {}, portTool = "ss") { const deployment = mode === "legacy-demo" @@ -84,8 +86,8 @@ if (command === 'curl') { const body = url.endsWith('/SHA256SUMS') ? (mode === 'bad-checksum' ? '0'.repeat(64) : ${JSON.stringify(checksum)}) + ' docker.compose.yaml\\n' + (mode === 'bad-checksum' || mode === 'bad-binary-checksum' ? '0'.repeat(64) : process.env.INSTALL_TEST_ARCHIVE_CHECKSUM) - + ' ' + process.env.INSTALL_TEST_ARCHIVE_NAME + '\\n' - : ${JSON.stringify(deployment)}; + + ' ' + process.env.INSTALL_TEST_ARCHIVE_NAME + '\\n' + (mode === 'bad-helper-checksum' ? ${JSON.stringify(helperChecksums)}.replace(/^[a-f0-9]{64}/,'0'.repeat(64)) : ${JSON.stringify(helperChecksums)}) + : ${JSON.stringify(helpers)}[url.split('/').at(-1)] ?? ${JSON.stringify(deployment)}; fs.writeFileSync(output, body); } } else if (command === 'docker') { @@ -213,6 +215,79 @@ function assertDataPreserved(f) { assert.equal(existsSync(join(f.target, ".update-lock")), false); } +for(const runtime of ["docker","binary"]){ + test(runtime+" web updates install a verified host worker and a mailbox without exposing the Docker socket",t=>{ + const f=existing(t,runtime,"","synthetic gateway"); + writeFileSync(join(f.target,f.settings),f.snapshot[f.settings].replace("SUBLANE_DEMO=true","SUBLANE_DEMO=false")); + const result=install(f,["--update","--enable-updates","--dir",f.target,"--non-interactive"]); + assert.equal(result.status,0,result.stderr); + const privateDir=join(f.target,".updater"); + assert.equal(readFileSync(join(privateDir,"install.sh"),"utf8"),helpers["install.sh"]); + assert.equal(readFileSync(join(privateDir,"updater.sh"),"utf8"),helpers["updater.sh"]); + assert.equal(statSync(privateDir).mode&0o777,0o700); + assert.equal(statSync(join(privateDir,"mailbox")).mode&0o777,0o777); + const unit=readFileSync(join(privateDir,"updater-sublane-synthetic.service"),"utf8"); + assert.match(unit,/Restart=on-failure/); + assert.ok(unit.includes(`"${realpathSync(privateDir)}/updater.sh" --run "${realpathSync(f.target)}"`)); + assert.ok(f.calls().some(call=>call.command==="systemctl"&&call.args.includes("enable")&&call.args.includes("updater-sublane-synthetic.service"))); + if(runtime==="docker"){ + const overlay=readFileSync(join(privateDir,"compose.yaml"),"utf8"); + assert.match(overlay,/SUBLANE_UPDATE_DIR: \/run\/sublane-updates/); + assert.ok(overlay.includes(JSON.stringify(join(realpathSync(f.target),".updater","mailbox")))); + assert.equal(overlay.includes("docker.sock"),false); + assert.ok(f.calls().some(call=>call.command==="docker"&&call.args.includes(join(realpathSync(privateDir),"compose.yaml"))&&call.args.includes("up"))); + }else assert.match(readFileSync(join(f.target,"sublane.env"),"utf8"),/SUBLANE_UPDATE_DIR=.*\.updater\/mailbox/); + assertDataPreserved(f); + }); +} + +test("web update setup rejects unsafe worker paths and corrupt helpers before replacing files",t=>{ + for(const mode of ["symlink","bad-helper-checksum"]){ + const f=existing(t,"docker",mode==="symlink"?"":mode); + writeFileSync(join(f.target,f.settings),f.snapshot[f.settings].replace("SUBLANE_DEMO=true","SUBLANE_DEMO=false")); + const saved=readFileSync(join(f.target,f.settings),"utf8"); + if(mode==="symlink")symlinkSync(f.root,join(f.target,".updater")); + const result=install(f,["--update","--enable-updates","--non-interactive"]); + assert.notEqual(result.status,0); + assert.equal(readFileSync(join(f.target,f.settings),"utf8"),saved); + assert.equal(f.calls().some(call=>call.command==="docker"&&call.args.includes("up")),false); + } +}); + +test("Docker updates reject customized or symlinked host-worker overlays",t=>{ + for(const link of [false,true]){ + const f=existing(t);const privateDir=join(f.target,".updater");mkdirSync(privateDir); + if(link)symlinkSync(join(f.target,"docker.compose.yaml"),join(privateDir,"compose.yaml")); + else writeFileSync(join(privateDir,"compose.yaml"),"services:\n sublane:\n volumes: [./another-data:/data]\n"); + const result=install(f,["--update","--version","1.2.3","--non-interactive"]); + assert.notEqual(result.status,0); + assert.equal(readFileSync(join(f.target,".env"),"utf8"),f.snapshot[".env"]); + assert.equal(f.calls().some(call=>call.command==="docker"&&call.args.includes("up")),false); + } +}); + +test("a new web-update installation canonicalizes parent symlinks and retains its generated overlay on later updates",t=>{ + const f=fixture(t);const actual=join(f.root,"actual");mkdirSync(actual); + const alias=join(f.root,"alias");symlinkSync(actual,alias); + const target=join(alias,"gateway"); + const result=install(f,["--version","1.2.3","--dir",target,"--runtime","docker","--enable-updates","--non-interactive"]); + assert.equal(result.status,0,result.stderr); + const overlay=readFileSync(join(target,".updater","compose.yaml"),"utf8"); + assert.ok(overlay.includes(JSON.stringify(realpathSync(join(target,".updater","mailbox"))))); + const update=install(f,["--update","--version","1.2.3","--dir",target,"--non-interactive"]); + assert.equal(update.status,0,update.stderr); +}); + +test("enabling binary web updates replaces an empty or obsolete mailbox setting",t=>{ + const f=existing(t,"binary"); + writeFileSync(join(f.target,f.settings),f.snapshot[f.settings].replace("SUBLANE_DEMO=true","SUBLANE_DEMO=false")+"SUBLANE_UPDATE_DIR=\n"); + const result=install(f,["--update","--version","1.2.3","--enable-updates","--non-interactive"]); + assert.equal(result.status,0,result.stderr); + const settings=readFileSync(join(f.target,f.settings),"utf8"); + assert.equal(settings.split("\n").filter(line=>line.startsWith("SUBLANE_UPDATE_DIR=")).length,1); + assert.ok(settings.includes("SUBLANE_UPDATE_DIR="+join(realpathSync(f.target),".updater","mailbox"))); +}); + test("updates an existing Docker deployment to the latest release while preserving its identity and settings", (t) => { const f = existing(t); Object.assign(f.env, { diff --git a/scripts/package.sh b/scripts/package.sh index 73a0b76d..fffb934c 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -22,9 +22,10 @@ trap 'rm -rf "$staging"' EXIT cp "$binary" "$staging/sublane" chmod 755 "$staging/sublane" cp LICENSE THIRD_PARTY_NOTICES.md .env.example docker.compose.yaml "$staging/" +cp scripts/install.sh scripts/updater.sh "$staging/" cp -R licenses "$staging/licenses" mkdir "$staging/docs" cp docs/README.md "$staging/docs/" # The tar archive retains the executable bit that Actions artifact ZIPs do not preserve. -tar -czf "$archive" -C "$staging" sublane LICENSE THIRD_PARTY_NOTICES.md licenses .env.example docker.compose.yaml docs +tar -czf "$archive" -C "$staging" sublane LICENSE THIRD_PARTY_NOTICES.md licenses .env.example docker.compose.yaml install.sh updater.sh docs printf '%s\n' "$archive" diff --git a/scripts/updater.sh b/scripts/updater.sh new file mode 100644 index 00000000..167f8ba5 --- /dev/null +++ b/scripts/updater.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -euo pipefail + +publish_state() { + local state=$1 temporary + temporary=$(mktemp "$mailbox/.status.XXXXXXXX") + jq -n --arg id "$request_id" --arg version "$request_version" --arg status "$state" \ + --argjson updated_at "$(date +%s)" \ + '{id:$id,version:$version,status:$status,updated_at:$updated_at}' > "$temporary" + chmod 0644 "$temporary" + mv -f -- "$temporary" "$mailbox/status.json" +} + +heartbeat() { + local temporary + while true; do + temporary=$(mktemp "$mailbox/.heartbeat.XXXXXXXX") + printf 'ready\n' > "$temporary" + chmod 0644 "$temporary" + mv -f -- "$temporary" "$mailbox/heartbeat" + sleep 2 + done +} + +valid_job() { + jq -e ' + type == "object" and keys == ["id","status","updated_at","version"] and + (.id | type == "string" and test("^[a-f0-9]{32}$")) and + (.version | type == "string" and test("^(0|[1-9][0-9]{0,5})\\.(0|[1-9][0-9]{0,5})\\.(0|[1-9][0-9]{0,5})$")) and + (.updated_at | type == "number" and . > 0 and floor == .) and + (.status == "queued" or .status == "running" or .status == "succeeded" or .status == "failed" or .status == "interrupted") + ' "$1" >/dev/null 2>&1 +} + +is_upgrade() { + local installed + if [[ -f $installation/sublane.env && -x $installation/sublane ]]; then + installed=$("$installation/sublane" --version) || return 1 + elif [[ -f $installation/.env && ! -L $installation/.env ]]; then + installed=$(awk -F= '$1 == "SUBLANE_IMAGE" { count++; value=$2 } END { if(count != 1) exit 1; print value }' "$installation/.env") || return 1 + [[ $installed == ghcr.io/murongg/sublane:* ]] || return 1 + installed=${installed#ghcr.io/murongg/sublane:} + else + return 1 + fi + jq -ne --arg current "$installed" --arg target "$request_version" ' + ($current | test("^(0|[1-9][0-9]{0,5})\\.(0|[1-9][0-9]{0,5})\\.(0|[1-9][0-9]{0,5})$")) and + (($target | split(".") | map(tonumber)) > ($current | split(".") | map(tonumber))) + ' >/dev/null +} + +cleanup() { + if [[ -n ${heartbeat_pid:-} ]]; then + kill "$heartbeat_pid" 2>/dev/null || true + wait "$heartbeat_pid" 2>/dev/null || true + fi + rm -f -- "$mailbox/heartbeat" "$private/snapshot" "$private/worker.lock/pid" + rmdir -- "$private/worker.lock" 2>/dev/null || true +} + +run_worker() { + [[ $# == 2 && $1 == --run && -d $2 && ! -L $2 ]] || { echo 'Usage: updater.sh --run INSTALLATION_DIRECTORY' >&2; exit 1; } + installation=$(cd -- "$2" && pwd -P) + private=$installation/.updater + mailbox=$private/mailbox + [[ -d $private && ! -L $private && -d $mailbox && ! -L $mailbox \ + && -f $private/install.sh && ! -L $private/install.sh ]] || exit 1 + command -v jq >/dev/null || exit 1 + umask 077 + if ! mkdir "$private/worker.lock" 2>/dev/null; then + [[ -d $private/worker.lock && ! -L $private/worker.lock && -f $private/worker.lock/pid ]] || exit 1 + local previous_pid + read -r previous_pid < "$private/worker.lock/pid" + [[ $previous_pid =~ ^[1-9][0-9]*$ ]] || exit 1 + if kill -0 "$previous_pid" 2>/dev/null; then exit 1; fi + rm -- "$private/worker.lock/pid" + rmdir -- "$private/worker.lock" + mkdir "$private/worker.lock" + fi + printf '%s\n' "$$" > "$private/worker.lock/pid" + trap cleanup EXIT + trap 'exit 130' INT TERM + # Never replay an installer that may already have migrated the database. + if [[ -f $mailbox/status.json && ! -L $mailbox/status.json ]] \ + && head -c 2049 "$mailbox/status.json" > "$private/snapshot" \ + && valid_job "$private/snapshot"; then + request_id=$(jq -r .id "$private/snapshot") + request_version=$(jq -r .version "$private/snapshot") + if [[ $(jq -r .status "$private/snapshot") == running ]]; then publish_state interrupted; fi + if [[ -f $mailbox/request.json && ! -L $mailbox/request.json ]] \ + && [[ $(head -c 2049 "$mailbox/request.json" | jq -r '.id // empty' 2>/dev/null) == "$request_id" ]]; then + rm -f -- "$mailbox/request.json" + fi + fi + heartbeat & + heartbeat_pid=$! + while true; do + if [[ -e $mailbox/request.json || -L $mailbox/request.json ]]; then + if [[ ! -f $mailbox/request.json || -L $mailbox/request.json ]] \ + || ! head -c 2049 "$mailbox/request.json" > "$private/snapshot" \ + || [[ $(wc -c < "$private/snapshot") -gt 2048 ]] \ + || ! valid_job "$private/snapshot" \ + || [[ $(jq -r .status "$private/snapshot") != queued ]]; then + rm -f -- "$mailbox/request.json" + else + request_id=$(jq -r .id "$private/snapshot") + request_version=$(jq -r .version "$private/snapshot") + publish_state running + # Arguments are fixed; the only untrusted value is a validated stable version. + if ! is_upgrade; then + printf 'Update refused: the target must be a newer stable version of this installation.\n' > "$private/last.log" + publish_state failed + elif bash "$private/install.sh" --update --dir "$installation" --version "$request_version" --non-interactive \ + > "$private/last.log" 2>&1; then + publish_state succeeded + else + publish_state failed + fi + rm -f -- "$mailbox/request.json" + fi + fi + sleep 1 + done +} + +run_worker "$@" diff --git a/scripts/updater.test.mjs b/scripts/updater.test.mjs new file mode 100644 index 00000000..bb7f00ad --- /dev/null +++ b/scripts/updater.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; +import { test } from "node:test"; + +const script=fileURLToPath(new URL("./updater.sh",import.meta.url)); +const job={id:"a".repeat(32),version:"1.3.0",status:"queued",updated_at:1900000000}; +async function until(predicate,child){ + for(let i=0;i<150;i++){ + if(predicate())return; + if(child.exitCode!==null)throw new Error(`Worker exited ${child.exitCode}`); + await delay(30); + } + throw new Error("Synthetic worker did not publish its state"); +} +function fixture(t,fail=false){ + const dir=realpathSync(mkdtempSync(join(tmpdir(),"sublane-updater-"))); + const privateDir=join(dir,".updater");mkdirSync(privateDir,{mode:0o700}); + const mailbox=join(privateDir,"mailbox");mkdirSync(mailbox,{mode:0o777}); + const calls=join(dir,"args"); + writeFileSync(join(dir,".env"),"SUBLANE_IMAGE=ghcr.io/murongg/sublane:1.2.0\n"); + writeFileSync(join(privateDir,"install.sh"),`#!/bin/bash\nprintf '%s\\n' "$@" > "$UPDATE_TEST_ARGS"\necho 'Synthetic private diagnostic'\nexit ${fail?1:0}\n`,{mode:0o700}); + const child=spawn("bash",[script,"--run",dir],{env:{...process.env,UPDATE_TEST_ARGS:calls},detached:true,stdio:"ignore"}); + t.after(async()=>{ + try{process.kill(-child.pid,"SIGTERM")}catch{} + await new Promise(resolve=>child.exitCode!==null||child.signalCode!==null?resolve():child.once("exit",resolve)); + rmSync(dir,{recursive:true,force:true}); + }); + return {dir,privateDir,mailbox,calls,child, + request(value=job){writeFileSync(join(mailbox,"request.json"),JSON.stringify(value));}, + status(){try{return JSON.parse(readFileSync(join(mailbox,"status.json"),"utf8"))}catch{return null}} + }; +} +test("host updater runs only the pinned installer and retains completion outside the app process",async t=>{ + const f=fixture(t); + await until(()=>existsSync(join(f.mailbox,"heartbeat")),f.child); + f.request(); + await until(()=>f.status()?.status==="succeeded"&&!existsSync(join(f.mailbox,"request.json")),f.child); + assert.deepEqual(readFileSync(f.calls,"utf8").trim().split("\n"),["--update","--dir",f.dir,"--version","1.3.0","--non-interactive"]); + assert.equal(f.status().id,job.id); + assert.match(readFileSync(join(f.privateDir,"last.log"),"utf8"),/Synthetic private diagnostic/); + assert.equal(existsSync(join(f.mailbox,"last.log")),false); +}); +test("failed readiness reports failure and never runs a downgrade",async t=>{ + const f=fixture(t,true);await until(()=>existsSync(join(f.mailbox,"heartbeat")),f.child);f.request(); + await until(()=>f.status()?.status==="failed",f.child); + assert.equal(readFileSync(f.calls,"utf8").includes("rollback"),false); + assert.equal(f.status().version,"1.3.0"); +}); +test("interrupted installations are reported without replaying the request",async t=>{ + const f=fixture(t); + await until(()=>existsSync(join(f.mailbox,"heartbeat")),f.child); + // Stop the first worker, then restart with a durable in-flight job. + process.kill(-f.child.pid,"SIGTERM");await new Promise(resolve=>f.child.once("exit",resolve)); + f.request();writeFileSync(join(f.mailbox,"status.json"),JSON.stringify({...job,status:"running"})); + const next=spawn("bash",[script,"--run",f.dir],{env:{...process.env,UPDATE_TEST_ARGS:f.calls},detached:true,stdio:"ignore"}); + t.after(()=>{try{process.kill(-next.pid,"SIGTERM")}catch{}}); + await until(()=>f.status()?.status==="interrupted",next); + assert.equal(existsSync(f.calls),false); +}); +test("malformed jobs and symbolic links never reach the installer",async t=>{ + const f=fixture(t);await until(()=>existsSync(join(f.mailbox,"heartbeat")),f.child); + const outside=join(f.dir,"synthetic-private");writeFileSync(outside,"synthetic private bytes"); + symlinkSync(outside,join(f.mailbox,"request.json")); + await until(()=>!existsSync(join(f.mailbox,"request.json")),f.child); + assert.equal(readFileSync(outside,"utf8"),"synthetic private bytes"); + f.request({...job,version:"1.3.0; touch unwanted"}); + await until(()=>!existsSync(join(f.mailbox,"request.json")),f.child); + assert.equal(existsSync(f.calls),false); + assert.equal(existsSync(join(f.dir,"unwanted")),false); +}); + +test("the host worker independently refuses downgrades and repeated installations",async t=>{ + const f=fixture(t);await until(()=>existsSync(join(f.mailbox,"heartbeat")),f.child); + f.request({...job,version:"1.1.0"}); + await until(()=>f.status()?.status==="failed"&&!existsSync(join(f.mailbox,"request.json")),f.child); + assert.equal(existsSync(f.calls),false); + f.request({...job,id:"b".repeat(32),version:"1.2.0"}); + await until(()=>f.status()?.id==="b".repeat(32)&&f.status()?.status==="failed",f.child); + assert.equal(existsSync(f.calls),false); + assert.match(readFileSync(join(f.privateDir,"last.log"),"utf8"),/newer stable version/); +}); diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index eaa1276e..ed5e6bea 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -83,6 +83,7 @@ const navigation = [ { to: '/admin/settings/timezone', label: 'timeZoneTitle' }, { to: '/admin/settings/turnstile', label: 'turnstileTitle' }, { to: '/admin/settings/clients', label: 'clientVersionsTitle' }, + { to: '/admin/settings/updates', label: 'updatesTitle' }, { to: '/admin/settings/backup', label: 'backupTitle' }, ], }, diff --git a/web/src/lib/audit.ts b/web/src/lib/audit.ts index 31ed25ec..5fc2fe59 100644 --- a/web/src/lib/audit.ts +++ b/web/src/lib/audit.ts @@ -13,6 +13,7 @@ export const auditActions: Record = { 'backup.prepare': 'auditBackupPrepare', 'backup.verify': 'auditBackupVerify', 'settings.update': 'auditSettingsUpdate', + 'updates.request': 'auditUpdateRequest', 'settings.test': 'auditSettingsTest', 'key.reveal': 'auditKeyReveal', 'key.create': 'auditKeyCreate', diff --git a/web/src/lib/updates.ts b/web/src/lib/updates.ts new file mode 100644 index 00000000..13fe41a7 --- /dev/null +++ b/web/src/lib/updates.ts @@ -0,0 +1,81 @@ +import { queryOptions, type QueryClient } from '@tanstack/react-query' +import { z } from 'zod' +import { authKey, type AuthState } from './auth' +import { ApiError, request } from './request' + +const version = z + .string() + .regex(/^(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})$/) +const schema = z.object({ + current_version: z.string().max(64), + latest_version: z.union([z.literal(''), version]), + release_url: z + .string() + .regex( + /^(|https:\/\/github\.com\/murongg\/SubLane\/releases\/tag\/v[0-9.]+)$/, + ), + checked_at: z.number().int().nonnegative(), + check_failed: z.boolean(), + available: z.boolean(), + enabled: z.boolean(), + job: z + .object({ + id: z.string().regex(/^[a-f0-9]{32}$/), + version, + status: z.enum([ + 'queued', + 'running', + 'succeeded', + 'failed', + 'interrupted', + ]), + updated_at: z.number().int().positive(), + }) + .nullable(), +}) +export type UpdateState = z.infer +export function updatePending(value: UpdateState) { + // Worker completion alone does not prove that the target server version is serving. + return ( + value.job?.status === 'queued' || + value.job?.status === 'running' || + (value.job?.status === 'succeeded' && + value.job.version !== value.current_version) + ) +} +export function updatesOptions(client: QueryClient, userID: number) { + return queryOptions({ + queryKey: ['software-updates', userID], + queryFn: ({ signal }) => + request('/api/settings/updates', schema, { signal }), + enabled: () => { + const user = client.getQueryData(authKey)?.user + return userID === 1 && user?.id === userID && user.role === 'admin' + }, + refetchInterval: (query) => + query.state.data && updatePending(query.state.data) ? 2000 : 60_000, + }) +} +export function checkUpdate(signal: AbortSignal) { + return request('/api/settings/updates/check', schema, { + method: 'POST', + body: '{}', + signal, + }) +} +export function installUpdate(version: string, signal: AbortSignal) { + return request('/api/settings/updates/install', schema, { + method: 'POST', + body: JSON.stringify({ version, backup_confirmed: true }), + signal, + }) +} +export function updateError(error: Error) { + if (error instanceof ApiError) { + if (error.code === 'demo_read_only') return 'demoReadOnly' + if (error.code === 'update_cooldown') return 'updateCooldown' + if (error.code === 'update_check_failed') return 'updateCheckFailed' + if (error.code === 'update_unavailable') return 'updateDisabled' + } + return 'updateRequestFailed' +} diff --git a/web/src/locales/ar.json b/web/src/locales/ar.json index b5f6dee5..e28485ae 100644 --- a/web/src/locales/ar.json +++ b/web/src/locales/ar.json @@ -1526,5 +1526,32 @@ "keyGroupChangedNotice": "تم تحديث مجموعة الموارد لـ «{{name}}».", "keyGroupChangeFailed": "تعذر تحديث مجموعة الموارد. حاول مجددًا.", "keyGroupUnavailableHint": "لم تعد المجموعة أو حصة الاستخدام المحددة متاحة. حدّث الخيارات أو تواصل مع مسؤول.", - "allocationExistingKeysHint": "عند الحفظ، تُربط المفاتيح الحالية غير الملغاة للأعضاء المحددين في هذه المجموعة بحصة الاستخدام. تبقى المفاتيح وإعدادات العميل كما هي. ستفقد المفاتيح الحالية للأعضاء غير المشمولين بالحصة الوصول إلى هذه المجموعة." + "allocationExistingKeysHint": "عند الحفظ، تُربط المفاتيح الحالية غير الملغاة للأعضاء المحددين في هذه المجموعة بحصة الاستخدام. تبقى المفاتيح وإعدادات العميل كما هي. ستفقد المفاتيح الحالية للأعضاء غير المشمولين بالحصة الوصول إلى هذه المجموعة.", + "updatesTitle": "تحديثات البرنامج", + "updatesDescription": "تحقق من إصدارات SubLane الرسمية وحدّث هذه النسخة. يمكن لمالك المنصة فقط تثبيت التحديثات.", + "updateCurrent": "الإصدار الحالي", + "updateLatest": "أحدث إصدار مستقر", + "updateCheck": "التحقق من التحديثات", + "updateInstall": "التحديث إلى {{version}}", + "updateConfirmTitle": "تحديث SubLane", + "updateConfirmDescription": "هل تريد تثبيت {{version}} وإعادة تشغيل SubLane؟ قد تنقطع الطلبات النشطة. صدّر نسخة احتياطية وتحقق منها أولًا. إعادة تثبيت إصدار أقدم لا تتراجع عن ترحيلات قاعدة البيانات.", + "updateBackupConfirm": "صدّرت نسخة احتياطية حديثة وتحققت منها.", + "updateConfirm": "تثبيت التحديث", + "updateDisabled": "فعّل عملية التحديث على الخادم للتثبيت من هذه الصفحة.", + "updateSetup": "إعداد التحديث عبر الويب", + "updateQueued": "التحديث في قائمة الانتظار. بانتظار عملية الخادم.", + "updateRunning": "جارٍ تثبيت التحديث. ستتم إعادة تشغيل SubLane.", + "updateSucceeded": "اكتمل التحديث. أعد تحميل الصفحة لاستخدام الإصدار الجديد.", + "updateFailed": "فشل التحديث. تحقق من سجل الخادم قبل المحاولة مجددًا. قد تتطلب قاعدة البيانات المرحّلة استعادة نسخة احتياطية تم التحقق منها.", + "updateInterrupted": "انقطع التحديث. تحقق من الإصدار المثبت وسجل الخادم.", + "updateReconnecting": "جارٍ إعادة الاتصال بـ SubLane…", + "updateReload": "إعادة تحميل الصفحة", + "updateNoNew": "لم يتم العثور على إصدار مستقر أحدث.", + "updateCheckFailed": "تعذّر التحقق من الإصدارات المستقرة. تحقق من اتصال GitHub وحاول لاحقًا.", + "updateRequestFailed": "تعذّر تأكيد طلب التحديث. حدّث الصفحة للتحقق من حالته قبل إعادة المحاولة.", + "updateCooldown": "يرجى الانتظار دقيقة قبل التحقق مجددًا.", + "updateReleaseNotes": "ملاحظات الإصدار", + "updateBuildUnsupported": "يتطلب هذا البناء تحديثًا يدويًا. يدعم التثبيت عبر الويب الإصدارات المستقرة.", + "updateWorkerLost": "عملية التحديث لا تستجيب. تحقق من خدمة systemd على الخادم.", + "auditUpdateRequest": "طُلب تحديث البرنامج" } diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 6f8b1a89..8d1f31b8 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -1506,5 +1506,32 @@ "keyGroupChangedNotice": "Ressourcengruppe für „{{name}}“ aktualisiert.", "keyGroupChangeFailed": "Die Ressourcengruppe konnte nicht aktualisiert werden. Versuchen Sie es erneut.", "keyGroupUnavailableHint": "Die gewählte Gruppe oder das Kontingent ist nicht mehr verfügbar. Aktualisieren Sie die Auswahl oder kontaktieren Sie einen Administrator.", - "allocationExistingKeysHint": "Beim Speichern werden die vorhandenen, nicht widerrufenen Schlüssel der ausgewählten Mitglieder in dieser Gruppe mit dem Kontingent verknüpft. Schlüssel und Client-Einstellungen bleiben gleich. Vorhandene Schlüssel nicht eingeschlossener Mitglieder verlieren den Zugriff auf diese Gruppe." + "allocationExistingKeysHint": "Beim Speichern werden die vorhandenen, nicht widerrufenen Schlüssel der ausgewählten Mitglieder in dieser Gruppe mit dem Kontingent verknüpft. Schlüssel und Client-Einstellungen bleiben gleich. Vorhandene Schlüssel nicht eingeschlossener Mitglieder verlieren den Zugriff auf diese Gruppe.", + "updatesTitle": "Softwareupdates", + "updatesDescription": "Prüfen Sie offizielle SubLane-Versionen und aktualisieren Sie diese Instanz. Nur der Plattforminhaber darf Updates installieren.", + "updateCurrent": "Aktuelle Version", + "updateLatest": "Neueste stabile Version", + "updateCheck": "Nach Updates suchen", + "updateInstall": "Auf {{version}} aktualisieren", + "updateConfirmTitle": "SubLane aktualisieren", + "updateConfirmDescription": "{{version}} installieren und SubLane neu starten? Laufende Anfragen können unterbrochen werden. Exportieren und prüfen Sie zuerst eine Sicherung. Eine ältere Version macht Datenbankmigrationen nicht rückgängig.", + "updateBackupConfirm": "Ich habe eine aktuelle Sicherung exportiert und überprüft.", + "updateConfirm": "Update installieren", + "updateDisabled": "Aktivieren Sie den Updateprozess auf dem Host, um hier Updates zu installieren.", + "updateSetup": "Webupdates einrichten", + "updateQueued": "Update vorgemerkt. Warten auf den Hostprozess.", + "updateRunning": "Update wird installiert. SubLane wird neu gestartet.", + "updateSucceeded": "Update abgeschlossen. Laden Sie die Seite für die neue Version neu.", + "updateFailed": "Das Update ist fehlgeschlagen. Prüfen Sie vor dem nächsten Versuch das Hostprotokoll. Eine migrierte Datenbank muss gegebenenfalls aus einer geprüften Sicherung wiederhergestellt werden.", + "updateInterrupted": "Update unterbrochen. Prüfen Sie die installierte Version und das Hostprotokoll.", + "updateReconnecting": "Verbindung zu SubLane wird wiederhergestellt…", + "updateReload": "Seite neu laden", + "updateNoNew": "Keine neuere stabile Version gefunden.", + "updateCheckFailed": "Stabile Versionen konnten nicht geprüft werden. Prüfen Sie die GitHub-Verbindung und versuchen Sie es später erneut.", + "updateRequestFailed": "Die Updateanfrage konnte nicht bestätigt werden. Aktualisieren Sie die Seite vor einem erneuten Versuch.", + "updateCooldown": "Warten Sie vor der nächsten Prüfung eine Minute.", + "updateReleaseNotes": "Versionshinweise", + "updateBuildUnsupported": "Dieser Build muss manuell aktualisiert werden. Die Webinstallation unterstützt stabile Versionen.", + "updateWorkerLost": "Der Updateprozess antwortet nicht. Prüfen Sie seinen systemd-Dienst auf dem Server.", + "auditUpdateRequest": "Softwareupdate angefordert" } diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 339cd0cb..1a3cdfcd 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -1,4 +1,39 @@ export const en = { + updatesTitle: 'Software updates', + updatesDescription: + 'Check official SubLane releases and update this instance. Only the platform owner can install updates.', + updateCurrent: 'Current version', + updateLatest: 'Latest stable release', + updateCheck: 'Check for updates', + updateInstall: 'Update to {{version}}', + updateConfirmTitle: 'Update SubLane', + updateConfirmDescription: + 'Install {{version}} and restart SubLane? Active requests may be interrupted. Export and verify a backup first. Reinstalling an older version cannot undo database migrations.', + updateBackupConfirm: 'I have exported and verified a current backup.', + updateConfirm: 'Install update', + updateDisabled: 'Enable the host updater to install updates from this page.', + updateSetup: 'Set up web updates', + updateQueued: 'Update queued. Waiting for the host updater.', + updateRunning: 'Installing the update. SubLane will restart.', + updateSucceeded: 'Update completed. Reload to use the new version.', + updateFailed: + 'The update failed. Check the host updater log before retrying. A migrated database may require restoring a verified backup.', + updateInterrupted: + 'The update was interrupted. Check the installed version and host updater log before continuing.', + updateReconnecting: 'Reconnecting to SubLane…', + updateReload: 'Reload page', + updateNoNew: 'No newer stable release found.', + updateCheckFailed: + 'Could not check stable releases. Check network access to GitHub and try again later.', + updateRequestFailed: + 'Could not confirm the update request. Refresh to check its status before retrying.', + updateCooldown: 'Please wait one minute before checking again.', + updateReleaseNotes: 'Release notes', + updateBuildUnsupported: + 'This build requires a manual update. Web installation supports stable release builds.', + updateWorkerLost: + 'The host updater is not responding. Check its systemd service on the server.', + auditUpdateRequest: 'Requested a software update', allocationInitialEditHint: 'Before the first activation, edit the initial member plan here. Saving keeps its reviewed start time and accounting anchor.', allocationReplacePendingHint: diff --git a/web/src/locales/es.json b/web/src/locales/es.json index 2ab2bde2..3243f76f 100644 --- a/web/src/locales/es.json +++ b/web/src/locales/es.json @@ -1511,5 +1511,32 @@ "keyGroupChangedNotice": "Grupo de recursos actualizado para «{{name}}».", "keyGroupChangeFailed": "No se pudo actualizar el grupo de recursos. Inténtelo de nuevo.", "keyGroupUnavailableHint": "El grupo o la asignación seleccionados ya no están disponibles. Actualice las opciones o contacte con un administrador.", - "allocationExistingKeysHint": "Al guardar, las claves existentes no revocadas de los miembros seleccionados en este grupo se vinculan a la asignación. Las claves y la configuración del cliente no cambian. Las claves existentes de los miembros excluidos perderán el acceso a este grupo." + "allocationExistingKeysHint": "Al guardar, las claves existentes no revocadas de los miembros seleccionados en este grupo se vinculan a la asignación. Las claves y la configuración del cliente no cambian. Las claves existentes de los miembros excluidos perderán el acceso a este grupo.", + "updatesTitle": "Actualizaciones de software", + "updatesDescription": "Consulta las versiones oficiales de SubLane y actualiza esta instancia. Solo el propietario de la plataforma puede instalar actualizaciones.", + "updateCurrent": "Versión actual", + "updateLatest": "Última versión estable", + "updateCheck": "Buscar actualizaciones", + "updateInstall": "Actualizar a {{version}}", + "updateConfirmTitle": "Actualizar SubLane", + "updateConfirmDescription": "¿Instalar {{version}} y reiniciar SubLane? Las solicitudes activas podrían interrumpirse. Exporta y verifica una copia primero. Reinstalar una versión anterior no revierte las migraciones de la base de datos.", + "updateBackupConfirm": "He exportado y verificado una copia de seguridad actual.", + "updateConfirm": "Instalar actualización", + "updateDisabled": "Activa el proceso de actualización del servidor para instalar desde esta página.", + "updateSetup": "Configurar actualizaciones web", + "updateQueued": "Actualización en cola. Esperando al proceso del servidor.", + "updateRunning": "Instalando la actualización. SubLane se reiniciará.", + "updateSucceeded": "Actualización completada. Recarga la página para usar la nueva versión.", + "updateFailed": "La actualización falló. Revisa el registro del servidor antes de reintentar. Una base migrada puede requerir restaurar una copia verificada.", + "updateInterrupted": "Actualización interrumpida. Revisa la versión instalada y el registro del servidor.", + "updateReconnecting": "Reconectando con SubLane…", + "updateReload": "Recargar página", + "updateNoNew": "No se encontró una versión estable más reciente.", + "updateCheckFailed": "No se pudieron consultar las versiones estables. Revisa la conexión a GitHub e inténtalo más tarde.", + "updateRequestFailed": "No se pudo confirmar la solicitud. Actualiza para comprobar su estado antes de reintentar.", + "updateCooldown": "Espera un minuto antes de volver a buscar.", + "updateReleaseNotes": "Notas de la versión", + "updateBuildUnsupported": "Esta compilación requiere una actualización manual. La instalación web admite versiones estables.", + "updateWorkerLost": "El proceso de actualización no responde. Revisa su servicio systemd en el servidor.", + "auditUpdateRequest": "Solicitó una actualización de software" } diff --git a/web/src/locales/fr.json b/web/src/locales/fr.json index 220cc53a..cda3836e 100644 --- a/web/src/locales/fr.json +++ b/web/src/locales/fr.json @@ -1511,5 +1511,32 @@ "keyGroupChangedNotice": "Groupe de ressources mis à jour pour « {{name}} ».", "keyGroupChangeFailed": "Impossible de mettre à jour le groupe de ressources. Réessayez.", "keyGroupUnavailableHint": "Le groupe ou l’allocation sélectionné n’est plus disponible. Actualisez les choix ou contactez un administrateur.", - "allocationExistingKeysHint": "L’enregistrement associe à cette allocation les clés existantes non révoquées des membres sélectionnés dans ce groupe. Les clés et les paramètres du client restent inchangés. Les clés existantes des membres exclus de l’allocation perdront l’accès à ce groupe." + "allocationExistingKeysHint": "L’enregistrement associe à cette allocation les clés existantes non révoquées des membres sélectionnés dans ce groupe. Les clés et les paramètres du client restent inchangés. Les clés existantes des membres exclus de l’allocation perdront l’accès à ce groupe.", + "updatesTitle": "Mises à jour logicielles", + "updatesDescription": "Vérifiez les versions officielles de SubLane et mettez à jour cette instance. Seul le propriétaire de la plateforme peut installer des mises à jour.", + "updateCurrent": "Version actuelle", + "updateLatest": "Dernière version stable", + "updateCheck": "Rechercher des mises à jour", + "updateInstall": "Mettre à jour vers {{version}}", + "updateConfirmTitle": "Mettre à jour SubLane", + "updateConfirmDescription": "Installer {{version}} et redémarrer SubLane ? Les requêtes en cours peuvent être interrompues. Exportez et vérifiez d’abord une sauvegarde. Réinstaller une ancienne version ne peut pas annuler les migrations de base de données.", + "updateBackupConfirm": "J’ai exporté et vérifié une sauvegarde récente.", + "updateConfirm": "Installer la mise à jour", + "updateDisabled": "Activez le processus de mise à jour de l’hôte pour installer depuis cette page.", + "updateSetup": "Configurer les mises à jour web", + "updateQueued": "Mise à jour en attente du processus de l’hôte.", + "updateRunning": "Installation de la mise à jour. SubLane va redémarrer.", + "updateSucceeded": "Mise à jour terminée. Rechargez la page pour utiliser la nouvelle version.", + "updateFailed": "La mise à jour a échoué. Consultez le journal de l’hôte avant de réessayer. Une base migrée peut nécessiter la restauration d’une sauvegarde vérifiée.", + "updateInterrupted": "Mise à jour interrompue. Vérifiez la version installée et le journal de l’hôte.", + "updateReconnecting": "Reconnexion à SubLane…", + "updateReload": "Recharger la page", + "updateNoNew": "Aucune version stable plus récente trouvée.", + "updateCheckFailed": "Impossible de vérifier les versions stables. Vérifiez la connexion à GitHub et réessayez plus tard.", + "updateRequestFailed": "Impossible de confirmer la demande. Actualisez pour vérifier son état avant de réessayer.", + "updateCooldown": "Veuillez patienter une minute avant une nouvelle vérification.", + "updateReleaseNotes": "Notes de version", + "updateBuildUnsupported": "Cette compilation nécessite une mise à jour manuelle. L’installation web prend en charge les versions stables.", + "updateWorkerLost": "Le processus de mise à jour de l’hôte ne répond pas. Vérifiez son service systemd sur le serveur.", + "auditUpdateRequest": "Mise à jour logicielle demandée" } diff --git a/web/src/locales/ja.json b/web/src/locales/ja.json index 807c2dd0..cad2f66c 100644 --- a/web/src/locales/ja.json +++ b/web/src/locales/ja.json @@ -1506,5 +1506,32 @@ "keyGroupChangedNotice": "「{{name}}」のリソースグループを更新しました。", "keyGroupChangeFailed": "リソースグループを更新できませんでした。再試行してください。", "keyGroupUnavailableHint": "選択したグループまたは利用枠は利用できなくなりました。選択肢を更新するか、管理者に連絡してください。", - "allocationExistingKeysHint": "保存すると、選択したメンバーがこのグループで持つ未失効の既存キーを利用枠に関連付けます。キーとクライアント設定は変わりません。利用枠に含まれないメンバーの既存キーは、このグループを利用できなくなります。" + "allocationExistingKeysHint": "保存すると、選択したメンバーがこのグループで持つ未失効の既存キーを利用枠に関連付けます。キーとクライアント設定は変わりません。利用枠に含まれないメンバーの既存キーは、このグループを利用できなくなります。", + "updatesTitle": "ソフトウェア更新", + "updatesDescription": "SubLane の公式リリースを確認し、このインスタンスを更新します。インストールはプラットフォーム所有者のみ行えます。", + "updateCurrent": "現在のバージョン", + "updateLatest": "最新の安定版", + "updateCheck": "更新を確認", + "updateInstall": "{{version}} に更新", + "updateConfirmTitle": "SubLane を更新", + "updateConfirmDescription": "{{version}} をインストールして SubLane を再起動しますか?実行中のリクエストは中断される場合があります。先にバックアップをエクスポートして検証してください。旧バージョンの再インストールではデータベース移行を元に戻せません。", + "updateBackupConfirm": "最新のバックアップをエクスポートし、検証しました。", + "updateConfirm": "更新をインストール", + "updateDisabled": "このページから更新するには、ホストの更新プロセスを有効にしてください。", + "updateSetup": "ウェブ更新の設定", + "updateQueued": "更新を受け付けました。ホストの処理を待っています。", + "updateRunning": "更新をインストール中です。SubLane は再起動します。", + "updateSucceeded": "更新が完了しました。ページを再読み込みしてください。", + "updateFailed": "更新に失敗しました。再試行前にホストの更新ログを確認してください。移行済みのデータベースには検証済みバックアップからの復元が必要な場合があります。", + "updateInterrupted": "更新が中断されました。インストール済みのバージョンとホストのログを確認してください。", + "updateReconnecting": "SubLane に再接続中…", + "updateReload": "ページを再読み込み", + "updateNoNew": "新しい安定版は見つかりませんでした。", + "updateCheckFailed": "安定版を確認できません。GitHub への接続を確認し、後で再試行してください。", + "updateRequestFailed": "更新リクエストを確認できません。再試行の前にページを更新して状態を確認してください。", + "updateCooldown": "次の確認まで 1 分お待ちください。", + "updateReleaseNotes": "リリースノート", + "updateBuildUnsupported": "このビルドは手動更新が必要です。ウェブ更新は安定版ビルドに対応しています。", + "updateWorkerLost": "ホストの更新プロセスが応答しません。サーバーの systemd サービスを確認してください。", + "auditUpdateRequest": "ソフトウェア更新を要求" } diff --git a/web/src/locales/ko.json b/web/src/locales/ko.json index 19233870..b8e74350 100644 --- a/web/src/locales/ko.json +++ b/web/src/locales/ko.json @@ -1506,5 +1506,32 @@ "keyGroupChangedNotice": "“{{name}}”의 리소스 그룹이 업데이트되었습니다.", "keyGroupChangeFailed": "리소스 그룹을 업데이트하지 못했습니다. 다시 시도하세요.", "keyGroupUnavailableHint": "선택한 그룹 또는 사용 한도를 더 이상 사용할 수 없습니다. 선택 목록을 새로 고치거나 관리자에게 문의하세요.", - "allocationExistingKeysHint": "저장하면 선택한 구성원이 이 그룹에 보유한 기존의 취소되지 않은 키를 사용 한도에 연결합니다. 키와 클라이언트 설정은 유지됩니다. 사용 한도에 포함되지 않은 구성원의 기존 키는 이 그룹에 접근할 수 없게 됩니다." + "allocationExistingKeysHint": "저장하면 선택한 구성원이 이 그룹에 보유한 기존의 취소되지 않은 키를 사용 한도에 연결합니다. 키와 클라이언트 설정은 유지됩니다. 사용 한도에 포함되지 않은 구성원의 기존 키는 이 그룹에 접근할 수 없게 됩니다.", + "updatesTitle": "소프트웨어 업데이트", + "updatesDescription": "SubLane 공식 릴리스를 확인하고 이 인스턴스를 업데이트합니다. 플랫폼 소유자만 설치할 수 있습니다.", + "updateCurrent": "현재 버전", + "updateLatest": "최신 안정 버전", + "updateCheck": "업데이트 확인", + "updateInstall": "{{version}}(으)로 업데이트", + "updateConfirmTitle": "SubLane 업데이트", + "updateConfirmDescription": "{{version}} 버전을 설치하고 SubLane을 재시작할까요? 진행 중인 요청이 중단될 수 있습니다. 먼저 백업을 내보내고 검증하세요. 이전 버전 재설치로 데이터베이스 마이그레이션을 되돌릴 수 없습니다.", + "updateBackupConfirm": "최신 백업을 내보내고 검증했습니다.", + "updateConfirm": "업데이트 설치", + "updateDisabled": "이 페이지에서 업데이트하려면 호스트 업데이트 프로세스를 활성화하세요.", + "updateSetup": "웹 업데이트 설정", + "updateQueued": "업데이트가 대기 중입니다. 호스트 프로세스를 기다리는 중입니다.", + "updateRunning": "업데이트 설치 중입니다. SubLane이 재시작됩니다.", + "updateSucceeded": "업데이트가 완료되었습니다. 새 버전을 사용하려면 페이지를 새로 고치세요.", + "updateFailed": "업데이트에 실패했습니다. 재시도 전 호스트 로그를 확인하세요. 마이그레이션된 데이터베이스는 검증된 백업으로 복원해야 할 수 있습니다.", + "updateInterrupted": "업데이트가 중단되었습니다. 설치 버전과 호스트 로그를 확인하세요.", + "updateReconnecting": "SubLane에 다시 연결 중…", + "updateReload": "페이지 새로 고침", + "updateNoNew": "새로운 안정 버전이 없습니다.", + "updateCheckFailed": "안정 버전을 확인할 수 없습니다. GitHub 연결을 확인한 후 다시 시도하세요.", + "updateRequestFailed": "업데이트 요청 상태를 확인할 수 없습니다. 다시 시도하기 전에 새로 고쳐 상태를 확인하세요.", + "updateCooldown": "다시 확인하기 전에 1분 기다려 주세요.", + "updateReleaseNotes": "릴리스 노트", + "updateBuildUnsupported": "이 빌드는 수동 업데이트가 필요합니다. 웹 설치는 안정 버전 빌드만 지원합니다.", + "updateWorkerLost": "호스트 업데이트 프로세스가 응답하지 않습니다. 서버의 systemd 서비스를 확인하세요.", + "auditUpdateRequest": "소프트웨어 업데이트 요청" } diff --git a/web/src/locales/pt.json b/web/src/locales/pt.json index 6d9229d2..ba139232 100644 --- a/web/src/locales/pt.json +++ b/web/src/locales/pt.json @@ -1511,5 +1511,32 @@ "keyGroupChangedNotice": "Grupo de recursos de “{{name}}” atualizado.", "keyGroupChangeFailed": "Não foi possível atualizar o grupo de recursos. Tente novamente.", "keyGroupUnavailableHint": "O grupo ou a alocação selecionados não estão mais disponíveis. Atualize as opções ou contate um administrador.", - "allocationExistingKeysHint": "Ao salvar, as chaves existentes não revogadas dos membros selecionados neste grupo são vinculadas à alocação. As chaves e as configurações do cliente permanecem iguais. As chaves existentes dos membros não incluídos perderão o acesso a este grupo." + "allocationExistingKeysHint": "Ao salvar, as chaves existentes não revogadas dos membros selecionados neste grupo são vinculadas à alocação. As chaves e as configurações do cliente permanecem iguais. As chaves existentes dos membros não incluídos perderão o acesso a este grupo.", + "updatesTitle": "Atualizações de software", + "updatesDescription": "Verifique as versões oficiais do SubLane e atualize esta instância. Apenas o proprietário da plataforma pode instalar atualizações.", + "updateCurrent": "Versão atual", + "updateLatest": "Última versão estável", + "updateCheck": "Verificar atualizações", + "updateInstall": "Atualizar para {{version}}", + "updateConfirmTitle": "Atualizar SubLane", + "updateConfirmDescription": "Instalar {{version}} e reiniciar o SubLane? Solicitações ativas podem ser interrompidas. Exporte e verifique um backup primeiro. Reinstalar uma versão anterior não desfaz migrações do banco de dados.", + "updateBackupConfirm": "Exportei e verifiquei um backup atual.", + "updateConfirm": "Instalar atualização", + "updateDisabled": "Ative o processo de atualização do servidor para instalar nesta página.", + "updateSetup": "Configurar atualizações web", + "updateQueued": "Atualização na fila. Aguardando o processo do servidor.", + "updateRunning": "Instalando a atualização. O SubLane será reiniciado.", + "updateSucceeded": "Atualização concluída. Recarregue a página para usar a nova versão.", + "updateFailed": "A atualização falhou. Verifique o registro do servidor antes de tentar novamente. Um banco migrado pode exigir restauração de um backup verificado.", + "updateInterrupted": "Atualização interrompida. Verifique a versão instalada e o registro do servidor.", + "updateReconnecting": "Reconectando ao SubLane…", + "updateReload": "Recarregar página", + "updateNoNew": "Nenhuma versão estável mais recente encontrada.", + "updateCheckFailed": "Não foi possível verificar as versões estáveis. Verifique a conexão com o GitHub e tente mais tarde.", + "updateRequestFailed": "Não foi possível confirmar a solicitação. Atualize para verificar o estado antes de tentar novamente.", + "updateCooldown": "Aguarde um minuto antes de verificar novamente.", + "updateReleaseNotes": "Notas da versão", + "updateBuildUnsupported": "Esta compilação requer atualização manual. A instalação web suporta versões estáveis.", + "updateWorkerLost": "O processo de atualização não responde. Verifique o serviço systemd no servidor.", + "auditUpdateRequest": "Solicitou atualização de software" } diff --git a/web/src/locales/ru.json b/web/src/locales/ru.json index 65681384..8961b653 100644 --- a/web/src/locales/ru.json +++ b/web/src/locales/ru.json @@ -1516,5 +1516,32 @@ "keyGroupChangedNotice": "Группа ресурсов для «{{name}}» обновлена.", "keyGroupChangeFailed": "Не удалось обновить группу ресурсов. Повторите попытку.", "keyGroupUnavailableHint": "Выбранная группа или лимит больше недоступны. Обновите список или обратитесь к администратору.", - "allocationExistingKeysHint": "При сохранении существующие неотозванные ключи выбранных участников в этой группе привязываются к лимиту. Ключи и настройки клиента не меняются. Существующие ключи участников, не включённых в лимит, потеряют доступ к этой группе." + "allocationExistingKeysHint": "При сохранении существующие неотозванные ключи выбранных участников в этой группе привязываются к лимиту. Ключи и настройки клиента не меняются. Существующие ключи участников, не включённых в лимит, потеряют доступ к этой группе.", + "updatesTitle": "Обновления ПО", + "updatesDescription": "Проверяйте официальные выпуски SubLane и обновляйте этот экземпляр. Установка доступна только владельцу платформы.", + "updateCurrent": "Текущая версия", + "updateLatest": "Последняя стабильная версия", + "updateCheck": "Проверить обновления", + "updateInstall": "Обновить до {{version}}", + "updateConfirmTitle": "Обновить SubLane", + "updateConfirmDescription": "Установить {{version}} и перезапустить SubLane? Активные запросы могут прерваться. Сначала экспортируйте и проверьте резервную копию. Установка старой версии не отменяет миграции базы данных.", + "updateBackupConfirm": "Я экспортировал и проверил актуальную резервную копию.", + "updateConfirm": "Установить обновление", + "updateDisabled": "Включите процесс обновления на сервере для установки с этой страницы.", + "updateSetup": "Настроить обновления через браузер", + "updateQueued": "Обновление в очереди. Ожидание процесса на сервере.", + "updateRunning": "Установка обновления. SubLane будет перезапущен.", + "updateSucceeded": "Обновление завершено. Перезагрузите страницу для новой версии.", + "updateFailed": "Обновление не удалось. Перед повтором проверьте журнал на сервере. Для мигрированной базы может потребоваться восстановление из проверенной копии.", + "updateInterrupted": "Обновление прервано. Проверьте установленную версию и журнал на сервере.", + "updateReconnecting": "Повторное подключение к SubLane…", + "updateReload": "Перезагрузить страницу", + "updateNoNew": "Более новая стабильная версия не найдена.", + "updateCheckFailed": "Не удалось проверить стабильные версии. Проверьте доступ к GitHub и повторите позже.", + "updateRequestFailed": "Не удалось подтвердить запрос. Обновите страницу и проверьте состояние перед повтором.", + "updateCooldown": "Подождите минуту до следующей проверки.", + "updateReleaseNotes": "Примечания к выпуску", + "updateBuildUnsupported": "Эту сборку нужно обновлять вручную. Установка через браузер поддерживает стабильные сборки.", + "updateWorkerLost": "Процесс обновления не отвечает. Проверьте его службу systemd на сервере.", + "auditUpdateRequest": "Запрошено обновление ПО" } diff --git a/web/src/locales/zh-TW.json b/web/src/locales/zh-TW.json index c7cc8945..fb40871c 100644 --- a/web/src/locales/zh-TW.json +++ b/web/src/locales/zh-TW.json @@ -1506,5 +1506,32 @@ "keyGroupChangedNotice": "已更新「{{name}}」的資源群組。", "keyGroupChangeFailed": "無法更新資源群組,請重試。", "keyGroupUnavailableHint": "所選資源群組或用量分配已無法使用,請重新整理選項或聯絡管理員。", - "allocationExistingKeysHint": "儲存時,會將所選成員在此資源群組中現有且未撤銷的金鑰關聯至本方案,金鑰值和用戶端設定保持不變。未加入方案的成員,其現有金鑰將無法繼續使用此資源群組。" + "allocationExistingKeysHint": "儲存時,會將所選成員在此資源群組中現有且未撤銷的金鑰關聯至本方案,金鑰值和用戶端設定保持不變。未加入方案的成員,其現有金鑰將無法繼續使用此資源群組。", + "updatesTitle": "軟體更新", + "updatesDescription": "檢查 SubLane 官方版本並更新目前執行個體。僅平台擁有者可安裝更新。", + "updateCurrent": "目前版本", + "updateLatest": "最新正式版", + "updateCheck": "檢查更新", + "updateInstall": "更新至 {{version}}", + "updateConfirmTitle": "更新 SubLane", + "updateConfirmDescription": "安裝 {{version}} 並重新啟動 SubLane?進行中的要求可能中斷。請先匯出並驗證備份;重新安裝舊版無法撤銷資料庫遷移。", + "updateBackupConfirm": "我已匯出並驗證目前備份。", + "updateConfirm": "安裝更新", + "updateDisabled": "請先啟用主機更新程序,之後即可在此安裝更新。", + "updateSetup": "設定網頁更新", + "updateQueued": "更新已排入佇列,等待主機更新程序。", + "updateRunning": "正在安裝更新,SubLane 將重新啟動。", + "updateSucceeded": "更新完成,請重新整理頁面以使用新版本。", + "updateFailed": "更新失敗。請先檢查主機更新記錄再重試;已遷移的資料庫可能需要從驗證過的備份還原。", + "updateInterrupted": "更新已中斷。請先檢查已安裝版本及主機更新記錄。", + "updateReconnecting": "正在重新連線 SubLane…", + "updateReload": "重新整理頁面", + "updateNoNew": "未發現更新的正式版。", + "updateCheckFailed": "無法檢查正式版,請檢查 GitHub 網路連線後稍後重試。", + "updateRequestFailed": "無法確認更新要求,請先重新整理以查看狀態,再決定是否重試。", + "updateCooldown": "請等待一分鐘後再次檢查。", + "updateReleaseNotes": "版本說明", + "updateBuildUnsupported": "目前組建需要手動升級,網頁安裝僅支援正式版組建。", + "updateWorkerLost": "主機更新程序未回應,請在伺服器檢查對應的 systemd 服務。", + "auditUpdateRequest": "要求軟體更新" } diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index cdbd0cbe..edc40ce5 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -1,6 +1,37 @@ import type { en } from './en' export const zh: Record = { + updatesTitle: '软件更新', + updatesDescription: + '检查 SubLane 官方版本并更新当前实例。仅平台所有者可以安装更新。', + updateCurrent: '当前版本', + updateLatest: '最新正式版', + updateCheck: '检查更新', + updateInstall: '更新至 {{version}}', + updateConfirmTitle: '更新 SubLane', + updateConfirmDescription: + '安装 {{version}} 并重启 SubLane?正在进行的请求可能中断。请先导出并验证备份;重新安装旧版本无法撤销数据库迁移。', + updateBackupConfirm: '我已导出并验证当前备份。', + updateConfirm: '安装更新', + updateDisabled: '请先启用宿主机更新进程,之后即可在此安装更新。', + updateSetup: '配置网页更新', + updateQueued: '更新已排队,等待宿主机更新进程执行。', + updateRunning: '正在安装更新,SubLane 将重启。', + updateSucceeded: '更新完成,刷新页面以使用新版本。', + updateFailed: + '更新失败。请先检查宿主机更新日志,再决定是否重试;已迁移的数据库可能需要从验证过的备份恢复。', + updateInterrupted: '更新已中断。请先检查已安装版本和宿主机更新日志。', + updateReconnecting: '正在重新连接 SubLane…', + updateReload: '刷新页面', + updateNoNew: '未发现更新的正式版。', + updateCheckFailed: '无法检查正式版,请检查 GitHub 网络连接后稍后重试。', + updateRequestFailed: + '无法确认更新请求状态,请先刷新查看状态,再决定是否重试。', + updateCooldown: '请等待一分钟后再次检查。', + updateReleaseNotes: '版本说明', + updateBuildUnsupported: '当前构建需要手动升级,网页安装仅支持正式版构建。', + updateWorkerLost: '宿主机更新进程未响应,请在服务器检查对应的 systemd 服务。', + auditUpdateRequest: '请求软件更新', allocationInitialEditHint: '首次生效前可在此修正初始成员配置;保存保留已核对的生效时间和计量锚点。', allocationReplacePendingHint: diff --git a/web/src/pages/Updates.test.tsx b/web/src/pages/Updates.test.tsx new file mode 100644 index 00000000..5c402514 --- /dev/null +++ b/web/src/pages/Updates.test.tsx @@ -0,0 +1,164 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { act, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { expect, it, vi } from 'vitest' +import { createQueryClient } from '@/lib/query' +import { authKey, type AuthState } from '@/lib/auth' +import { authenticated, memberAuthenticated } from '@/test/fixtures' +import { Updates } from './Updates' + +const state = { + current_version: '1.2.0', + latest_version: '1.3.0', + release_url: 'https://github.com/murongg/SubLane/releases/tag/v1.3.0', + checked_at: 1900000000, + check_failed: false, + available: true, + enabled: true, + job: null, +} +const job = { + id: 'a'.repeat(32), + version: '1.3.0', + status: 'queued', + updated_at: 1900000000, +} +const response = (value: unknown, status = 200) => + new Response(JSON.stringify(value), { status }) +function mount(identity: AuthState = authenticated) { + const client = createQueryClient() + client.setQueryData(authKey, identity) + render( + + + , + ) + return client +} +it('requires a backup acknowledgement and submits the inspected release exactly once', async () => { + const fetch = vi.fn((url: string) => + Promise.resolve( + response(url.endsWith('/install') ? { ...state, job } : state), + ), + ) + vi.stubGlobal('fetch', fetch) + const user = userEvent.setup() + mount() + await user.click( + await screen.findByRole('button', { name: 'Update to 1.3.0' }), + ) + const dialog = screen.getByRole('dialog', { name: 'Update SubLane' }) + const confirm = within(dialog).getByRole('button', { name: 'Install update' }) + expect(confirm.hasAttribute('disabled')).toBe(true) + await user.click(within(dialog).getByRole('checkbox')) + await user.click(confirm) + expect( + await screen.findByText('Update queued. Waiting for the host updater.'), + ).toBeTruthy() + const calls = fetch.mock.calls.filter(([url]) => url.endsWith('/install')) + expect(calls).toHaveLength(1) + expect((calls[0] as unknown[])[1]).toMatchObject({ + method: 'POST', + body: JSON.stringify({ version: '1.3.0', backup_confirmed: true }), + }) + expect( + screen.queryByText('Update completed. Reload to use the new version.'), + ).toBeNull() +}) +it('keeps release checks available when host updates are not enabled', async () => { + const fetch = vi.fn(() => + Promise.resolve(response({ ...state, enabled: false })), + ) + vi.stubGlobal('fetch', fetch) + mount() + expect( + await screen.findByText( + 'Enable the host updater to install updates from this page.', + ), + ).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Update to 1.3.0' })).toBeNull() + await userEvent.click( + screen.getByRole('button', { name: 'Check for updates' }), + ) + expect(fetch.mock.calls.length).toBeGreaterThan(1) +}) +it.each(['running', 'succeeded'])( + 'retains progress during a restart with worker status %s until the target is serving', + async (status) => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve(response({ ...state, job: { ...job, status } })), + ), + ) + const client = mount() + expect( + await screen.findByText('Installing the update. SubLane will restart.'), + ).toBeTruthy() + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.reject(new TypeError('Synthetic restart'))), + ) + await act(async () => { + await client.refetchQueries({ queryKey: ['software-updates'] }) + }) + expect(await screen.findByText('Reconnecting to SubLane…')).toBeTruthy() + expect( + screen.queryByText('Update completed. Reload to use the new version.'), + ).toBeNull() + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve( + response({ + ...state, + current_version: '1.3.0', + available: false, + job: { ...job, status: 'succeeded' }, + }), + ), + ), + ) + await act(async () => { + await client.refetchQueries({ queryKey: ['software-updates'] }) + }) + expect( + await screen.findByText( + 'Update completed. Reload to use the new version.', + ), + ).toBeTruthy() + }, +) +it('shows a failed installation without claiming success', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve( + response({ ...state, job: { ...job, status: 'failed' } }), + ), + ), + ) + mount() + expect(await screen.findByRole('alert')).toHaveProperty( + 'textContent', + expect.stringContaining('The update failed.'), + ) + expect( + screen.queryByText('Update completed. Reload to use the new version.'), + ).toBeNull() +}) +it.each([ + memberAuthenticated, + { ...authenticated, user: { ...authenticated.user, id: 2 } }, +])( + 'does not query software updates for an unauthorized identity', + async (identity) => { + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + mount(identity) + await waitFor(() => expect(fetch).not.toHaveBeenCalled()) + expect( + screen.queryByRole('button', { name: 'Check for updates' }), + ).toBeNull() + }, +) diff --git a/web/src/pages/Updates.tsx b/web/src/pages/Updates.tsx new file mode 100644 index 00000000..c959e7b0 --- /dev/null +++ b/web/src/pages/Updates.tsx @@ -0,0 +1,287 @@ +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { RefreshCw } from 'lucide-react' +import { authOptions } from '@/lib/auth' +import { + checkUpdate, + installUpdate, + updateError, + updatePending, + updatesOptions, + type UpdateState, +} from '@/lib/updates' +import { useAdminMutation } from '@/hooks/use-admin-mutation' +import { Button } from '@/components/ui/Button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/Dialog' + +export function Updates() { + const { data } = useQuery(authOptions()) + const user = data?.user + return user?.role === 'admin' && user.id === 1 ? ( + + ) : null +} + +function UpdateSettings({ userID }: { userID: number }) { + const { t } = useTranslation() + const client = useQueryClient() + const options = updatesOptions(client, userID) + const query = useQuery(options) + const [target, setTarget] = useState(null) + const [backup, setBackup] = useState(false) + const apply = (data: UpdateState) => + client.setQueryData(options.queryKey, data) + const refresh = () => client.invalidateQueries({ queryKey: options.queryKey }) + const check = useAdminMutation({ + userID, + mutationFn: (_input: void, signal) => checkUpdate(signal), + onSuccess: apply, + onError: refresh, + }) + const install = useAdminMutation({ + userID, + mutationFn: installUpdate, + onSuccess: (data) => { + apply(data) + setTarget(null) + }, + onError: refresh, + }) + const value = query.data + const busy = value ? updatePending(value) : false + const error = check.error ?? install.error + const stableBuild = value && /^\d+\.\d+\.\d+$/.test(value.current_version) + const result = value?.job + const status = + result?.status === 'succeeded' && result.version !== value?.current_version + ? 'running' + : result?.status + const statusCopy = { + queued: 'updateQueued', + running: 'updateRunning', + succeeded: 'updateSucceeded', + failed: 'updateFailed', + interrupted: 'updateInterrupted', + } as const + return ( +
+
+

+ {t('updatesTitle')} +

+

+ {t('updatesDescription')} +

+
+ {query.isPending ? ( +

+ {t('loading')} +

+ ) : value ? ( + <> +
+
+
+ {t('updateCurrent')} +
+
+ {value.current_version || '—'} +
+
+
+
+ {t('updateLatest')} +
+
+ {value.latest_version || '—'} +
+
+
+ {!value.enabled && ( +
+

{t('updateDisabled')}

+ + {t('updateSetup')} + +
+ )} + {!stableBuild && ( +

+ {t('updateBuildUnsupported')} +

+ )} + {value.check_failed && ( +

+ {t('updateCheckFailed')} +

+ )} + {!value.available && + value.latest_version && + stableBuild && + !value.check_failed && ( +

+ {t('updateNoNew')} +

+ )} + {status && ( +
+

+ {t(statusCopy[status])} +

+ {result && ( +

+ {result.version} +

+ )} + {busy && !value.enabled && ( +

+ {t('updateWorkerLost')} +

+ )} + {status === 'succeeded' && ( + + )} +
+ )} + {query.isError && ( +

+ {t(busy ? 'updateReconnecting' : 'updateRequestFailed')} +

+ )} +
+ + {value.enabled && value.available && ( + + )} + {value.release_url && ( + + {t('updateReleaseNotes')} + + )} +
+ + ) : ( +
+

{t('updateRequestFailed')}

+ +
+ )} + {error && ( +

+ {t(updateError(error))} +

+ )} + { + if (!open && !install.isPending) setTarget(null) + }} + > + + + {t('updateConfirmTitle')} + + {t('updateConfirmDescription', { version: target })} + + + + {t('backupTitle')} + + + + + + + + +
+ ) +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 691031f8..837d566c 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -59,6 +59,11 @@ const routes = root.addChildren([ component: lazyRouteComponent(() => import('@/pages/Overview'), 'Overview'), }), settings.addChildren([ + createRoute({ + getParentRoute: () => settings, + path: 'updates', + component: lazyRouteComponent(() => import('@/pages/Updates'), 'Updates'), + }), createRoute({ getParentRoute: () => settings, path: 'turnstile', From cfa6a38bc46a16337191aaf7050f7ce58f50f4d4 Mon Sep 17 00:00:00 2001 From: MuRong Date: Sun, 1 Nov 2026 15:29:00 +0800 Subject: [PATCH 2/2] fix(updates): make host worker activation failure handling explicit --- scripts/install.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 9362c2b5..8a2af0bf 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -741,10 +741,12 @@ UNIT systemctl --user restart "$install_unit" || fail 'Could not restart SubLane with the update mailbox.' binary_ready || fail 'SubLane did not become ready with the update mailbox.' fi - systemctl --user link "$private/$unit" \ - && systemctl --user daemon-reload \ - && systemctl --user enable "$unit" \ - && systemctl --user restart "$unit" || fail 'Could not enable the host updater. Inspect its systemd user unit.' + if ! systemctl --user link "$private/$unit" \ + || ! systemctl --user daemon-reload \ + || ! systemctl --user enable "$unit" \ + || ! systemctl --user restart "$unit"; then + fail 'Could not enable the host updater. Inspect its systemd user unit.' + fi printf '\nWeb updates enabled. Host diagnostics: %s/last.log\n' "$private" }