mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
`Server/go.mod` declared `github.com/owncord/server` while the public repository is `github.com/J3vb/OwnCord`. Nothing resolves that path — there is no `owncord` GitHub org and no vanity-import host serving go-import metadata for it — so every import line in the tree named a location that does not exist. It compiles because a main module's own path is never fetched, which is exactly why it went unnoticed. The obvious fix — an AST-aware import rewriter (`gomvpkg`, `go mod edit`) — is wrong here, and provably so. Six of the 722 occurrences are not imports at all: `api/main_test.go:20` (a goleak `IgnoreTopFunction` pattern), `telemetry/metrics.go:17-19` (three OTel instrumentation-scope names), `invariants/syncutil_locks.go:73` (a diagnostic message), and `invariants/syncutil_locks_test.go:56` (an import line inside a raw-string Go fixture). An import rewriter touches none of them, and the compiler cannot see any of them either. Done as one scripted substitution over `git ls-files`, anchored on the full `github.com/owncord/server` string. The anchor matters: `owncord-server` is a different identifier — the OTel `service.name` (`config/config.go`, `telemetry/telemetry_otel.go`) and the GHCR image name (`.github/workflows/release.yml`, `docker-compose.yml`) — and a looser pattern would have moved it. It is untouched: 10 occurrences across 9 files, before and after. 350 files, 728 insertions, 728 deletions. 722 occurrences in 344 Go files, plus `go.mod:1`, the `sed` at `Makefile:67`, `Server/CLAUDE.md:3`, `docs/architecture/server.md:5`, and the ledger pair (`findings-ledger.json:3758` plus a `render-ledger.mjs` re-render of `FINDINGS.md`). Zero in any workflow, zero in the Dockerfile, zero in `Server/.golangci.yml` (no `local-prefixes`, `gci`, `importas` or `depguard` rule keys on the module path, so import grouping is not configured anywhere). The plan's blast-radius estimate missed one thing, and it is the one that would have gone red: **gofmt**. `J` (0x4A) sorts before every lowercase letter, so in the 36 files where a module-local import shares a contiguous group with a third-party one, the module's imports must move above `github.com/go-chi/...`. `gofmt -l` was clean before the substitution and listed exactly 36 files after it; `gofmt -w` on those 36 restores it to clean. `gofmt` is an enforced gate — the `formatters` block in `Server/.golangci.yml`, which is S-05 — so a substitution-only commit fails Lint. Verified: both directions, and the line accounting is exact. Every added line in this diff contains the new module path (728) and every removed line contains the old one (728); the count of changed lines containing neither is **zero**, so the gofmt re-sort moved module-path lines only and touched no third-party import. The residual check (`git ls-files -z | xargs -0 grep -n 'github\.com/owncord/server'`) returns exactly two hits, both deliberately out of scope: the RL-13 row in `docs/audit-2026-08-23-repository-layout.md` and the measurement row in this phase's own plan. The compiler-invisible half was proven by reverting *only* `api/main_test.go:20` to the old path on the otherwise-renamed tree: `go build ./...` and `go vet ./api/` both still pass — they see nothing wrong — while `go test ./api/` FAILS, because the runtime function name now carries the new path and goleak stops ignoring `ws.(*Hub).Run.func1`. Restoring the line makes it pass. `go.sum` is byte-identical (no `go mod tidy` was run and none was needed). All four build-tag variants compile; `go vet ./...`, `go vet -tags otel,wazero ./...` and `go vet -tags deadlock ./...` pass; `go test -race ./...` is 16/16 packages green; `go test -tags deadlock ./...` passes; the tag-gated `./plugin/...` (wazero) and `./telemetry/...` (otel) runs pass. `golangci-lint` v2.11.3 — the pinned CI version, rebuilt locally against Go 1.26 because the packaged binary cannot load a 1.26 config — reports **0 issues**. `go run ./cmd/genprotocol` leaves `git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts` clean, so the rename does not reach the generated protocol constants. `npx prettier --check .` and `node .superpowers/render-ledger.mjs --check` pass. Not included: `docs/audit-2026-08-23-repository-layout.md` and `docs/plans/b1-repository-foundation-2026-08-25.md` keep the old path — they are the audit row and the measurement that motivated this change, and rewriting them would erase the record of what was measured. They are why the residual check needs a two-path allowance rather than being empty; that allowance is stated above rather than hidden in a pathspec. `telemetry/metrics.go:19` declares `scopeVoice` for a `Server/voice` package that does not exist; the substitution carried the dead path forward verbatim as `github.com/J3vb/OwnCord/Server/voice` rather than fixing it, because correcting a real observability bug inside a mechanical rename would hide it in a 350-file diff. It needs its own item. No `go.work`, no second module, and no vanity-import host was set up — the new path resolves against the real repository, but nothing imports this module as a library, so `go get` reachability was not exercised either way. Refs RL-13, L-12
218 lines
8.3 KiB
Go
218 lines
8.3 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/J3vb/OwnCord/Server/updater"
|
|
"golang.org/x/mod/semver"
|
|
)
|
|
|
|
// handleCheckUpdate returns the current update status. can_apply tells the
|
|
// admin SPA whether POST /updates/apply is usable in this deployment (false
|
|
// in containers, where upgrades are image pulls).
|
|
func handleCheckUpdate(u *updater.Updater) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if u == nil {
|
|
writeErr(w, http.StatusServiceUnavailable, "UPDATE_UNAVAILABLE", "update checking is not configured")
|
|
return
|
|
}
|
|
info, err := u.CheckForUpdate(r.Context())
|
|
if err != nil {
|
|
slog.Error("update check failed", "err", err)
|
|
writeErr(w, http.StatusBadGateway, "UPDATE_CHECK_FAILED", "failed to check for updates — see server logs")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, struct {
|
|
updater.UpdateInfo
|
|
CanApply bool `json:"can_apply"`
|
|
}{info, !updater.RunningInContainer()})
|
|
}
|
|
}
|
|
|
|
// applyRestartDelay is how long the "restarting in 5s" countdown broadcast to
|
|
// clients actually gets before the swap + restart request. A var so tests can
|
|
// shrink it; the broadcast countdown below stays 5 to match this value.
|
|
var applyRestartDelay = 5 * time.Second
|
|
|
|
// handleApplyUpdate downloads and applies a server update.
|
|
func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// In a container the running binary is image content: the staged
|
|
// replacement dies with the container and the restart comes back as
|
|
// the old image. Refuse before any nil/availability logic so the
|
|
// answer does not depend on updater configuration.
|
|
if updater.RunningInContainer() {
|
|
writeErr(w, http.StatusServiceUnavailable, "CONTAINER_DEPLOYMENT",
|
|
"in-place self-update is disabled in container deployments — upgrade by pulling the new image")
|
|
return
|
|
}
|
|
if u == nil {
|
|
writeErr(w, http.StatusServiceUnavailable, "UPDATE_UNAVAILABLE", "update checking is not configured")
|
|
return
|
|
}
|
|
|
|
// Serialize against concurrent applies, restores, and an
|
|
// already-requested restart — claimed before any updater work so a
|
|
// pending restart answers 409 without an outbound GitHub call, and
|
|
// so two concurrent applies can never both stage into the same .new
|
|
// path (each download removes the other's staged file). The deferred
|
|
// release covers every early return; ownership transfers to the
|
|
// applyAndRestart goroutine at the bottom.
|
|
if !beginRestartSensitiveOp() {
|
|
writeRestartConflict(w)
|
|
return
|
|
}
|
|
claimed := true
|
|
defer func() {
|
|
if claimed {
|
|
abortRestartSensitiveOp()
|
|
}
|
|
}()
|
|
|
|
// Check for available update.
|
|
info, err := u.CheckForUpdate(r.Context())
|
|
if err != nil {
|
|
slog.Error("update check failed during apply", "err", err)
|
|
writeErr(w, http.StatusBadGateway, "UPDATE_CHECK_FAILED", "failed to check for updates — see server logs")
|
|
return
|
|
}
|
|
if !info.UpdateAvailable {
|
|
if semver.Compare(info.Current, info.Latest) < 0 && !info.RequiredAssetsPresent {
|
|
writeErr(w, http.StatusBadGateway, "MISSING_ASSETS", "release is missing required assets")
|
|
return
|
|
}
|
|
writeErr(w, http.StatusConflict, "NO_UPDATE", "server is already up to date")
|
|
return
|
|
}
|
|
if !info.RequiredAssetsPresent {
|
|
writeErr(w, http.StatusBadGateway, "MISSING_ASSETS", "release is missing required assets")
|
|
return
|
|
}
|
|
|
|
// Get current executable path.
|
|
exePath, err := os.Executable()
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "cannot determine executable path")
|
|
return
|
|
}
|
|
exePath, err = filepath.EvalSymlinks(exePath)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "cannot resolve executable path")
|
|
return
|
|
}
|
|
|
|
newPath := exePath + ".new"
|
|
oldPath := exePath + ".old"
|
|
|
|
// Download and verify.
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
// DownloadAndVerify stages the binary and returns its trusted hash
|
|
// (bound to the signed release manifest). The apply goroutine below
|
|
// re-verifies the staged file against this hash through an open
|
|
// handle — never by path — before the rename.
|
|
stagedHash, err := u.DownloadAndVerify(ctx, info.Latest, info.DownloadURL, info.ChecksumURL, info.SignatureURL, info.ManifestURL, info.ManifestSignatureURL, newPath)
|
|
if err != nil {
|
|
slog.Error("update download/verify failed", "err", err)
|
|
writeErr(w, http.StatusBadGateway, "DOWNLOAD_FAILED", "download or verification failed — see server logs")
|
|
return
|
|
}
|
|
|
|
// Respond to the client before shutting down.
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"status": "applying",
|
|
"version": info.Latest,
|
|
})
|
|
|
|
// Broadcast the restart countdown and finish in the background — the
|
|
// goroutine takes over the busy state claimed above, so the deferred
|
|
// release must stand down.
|
|
claimed = false
|
|
go applyAndRestart(hub, exePath, oldPath, newPath, stagedHash)
|
|
})
|
|
}
|
|
|
|
// applyAndRestart is POST /updates/apply's background tail: give clients the
|
|
// promised countdown, swap the binary on disk, and hand the process over to
|
|
// the main package's restart coordinator. On a failed swap it releases the
|
|
// exclusive slot claimed by the handler so a corrected release can be applied
|
|
// without a manual restart (the corrective update_aborted broadcast is sent
|
|
// by applyStagedUpdate's deferred guard).
|
|
func applyAndRestart(hub HubBroadcaster, exePath, oldPath, newPath, stagedHash string) {
|
|
if hub != nil {
|
|
hub.BroadcastServerRestart("update", 5)
|
|
}
|
|
time.Sleep(applyRestartDelay)
|
|
if applyStagedUpdate(hub, exePath, oldPath, newPath, stagedHash) {
|
|
commitRestartPending()
|
|
requestRestart("update")
|
|
return
|
|
}
|
|
abortRestartSensitiveOp()
|
|
}
|
|
|
|
// applyStagedUpdate performs the on-disk swap: verified staged binary ->
|
|
// exePath, previous binary -> .old. It reports whether the swap committed —
|
|
// on true the caller must request a restart, because the file at exePath is
|
|
// no longer the binary this process is running.
|
|
//
|
|
// The caller has already broadcast "restarting in 5s" to every connected
|
|
// client before invoking this, so every failure path must correct that
|
|
// promise — otherwise the client's restart banner counts down to a permanent
|
|
// "Reconnecting..." over a connection that never actually dropped (OC-0226).
|
|
// The deferred broadcast below covers all such paths (verification failure,
|
|
// rename failure, commit failure) with one guard; it is cancelled by setting
|
|
// committed=true once the verified binary is in place.
|
|
//
|
|
// It does NOT spawn, signal, or exit: the restart itself is the main
|
|
// package's job, after run() has fully drained (Server/restart.go). Keeping
|
|
// the swap free of process side effects is also what makes the success path
|
|
// unit-testable.
|
|
func applyStagedUpdate(hub HubBroadcaster, exePath, oldPath, newPath, stagedHash string) bool {
|
|
committed := false
|
|
defer func() {
|
|
if !committed && hub != nil {
|
|
hub.BroadcastServerRestart("update_aborted", 0)
|
|
}
|
|
}()
|
|
|
|
// TOCTOU guard: open the staged binary once, verify its hash
|
|
// through that handle, and commit (rename) that exact file.
|
|
// Commit fails if the path was swapped after verification, so
|
|
// the bytes verified are the bytes the restart will execute.
|
|
staged, err := updater.OpenVerifiedBinary(newPath, stagedHash)
|
|
if err != nil {
|
|
slog.Error("update: staged binary re-verification failed, aborting update", "error", err)
|
|
return false
|
|
}
|
|
defer staged.Close() //nolint:errcheck
|
|
|
|
// Rename: current -> .old, verified staged binary -> current
|
|
_ = os.Remove(oldPath) // remove any stale .old
|
|
if err := os.Rename(exePath, oldPath); err != nil {
|
|
slog.Error("update: rename current to old failed", "error", err)
|
|
return false
|
|
}
|
|
if err := staged.Commit(exePath); err != nil {
|
|
slog.Error("update: committing staged binary failed, restoring original binary", "error", err)
|
|
// Whatever is at exePath now (if anything) is not the verified
|
|
// binary; restoring .old replaces it.
|
|
if restoreErr := os.Rename(oldPath, exePath); restoreErr != nil {
|
|
slog.Error("update: CRITICAL — recovery rename also failed, server binary may be missing",
|
|
"restore_error", restoreErr, "original_error", err,
|
|
"old_path", oldPath, "exe_path", exePath)
|
|
}
|
|
return false
|
|
}
|
|
|
|
committed = true
|
|
slog.Info("update: staged binary committed — requesting restart", "path", exePath)
|
|
return true
|
|
}
|