diff --git a/internal/auth/auth.go b/internal/auth/auth.go index ffe265f..aabf755 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -27,16 +27,16 @@ func Public(methods ...string) map[string]struct{} { } func Wrap(next http.Handler, policy Policy) http.Handler { - if strings.TrimSpace(os.Getenv(envToken)) == "" { - log.Printf("[auth] warning: %s not set; agent auth enforcement disabled", envToken) - return next - } - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if policy.IsPublic(r.Method, r.URL.Path) { next.ServeHTTP(w, r) return } + if strings.TrimSpace(os.Getenv(envToken)) == "" { + log.Printf("[auth] warning: %s not set; rejecting protected request path=%s", envToken, r.URL.Path) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } if !Authorized(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return diff --git a/internal/http/agent_test.go b/internal/http/agent_test.go index 641e721..b47d8ab 100644 --- a/internal/http/agent_test.go +++ b/internal/http/agent_test.go @@ -1,11 +1,129 @@ package agenthttp import ( + "net/http" + "net/http/httptest" + "strings" "testing" + "github.com/gorilla/websocket" + "zlh-agent/internal/state" ) +func TestProtectedRoutesRequireAgentToken(t *testing.T) { + t.Setenv("ZLH_AGENT_TOKEN", "test-token") + + protected := []struct { + method string + path string + body string + }{ + {http.MethodGet, "/status", ""}, + {http.MethodGet, "/ready", ""}, + {http.MethodPost, "/config", "{}"}, + {http.MethodPost, "/start", ""}, + {http.MethodPost, "/stop", ""}, + {http.MethodPost, "/restart", ""}, + {http.MethodGet, "/game/files/list", ""}, + {http.MethodGet, "/game/files/read", ""}, + {http.MethodGet, "/game/files/download", ""}, + {http.MethodPost, "/game/files/upload", ""}, + {http.MethodPost, "/game/files/revert", ""}, + {http.MethodGet, "/game/files/stat", ""}, + {http.MethodGet, "/game/backups", ""}, + {http.MethodPost, "/game/backups", ""}, + {http.MethodPost, "/game/backups/restore", `{"id":"backup-1"}`}, + {http.MethodDelete, "/game/backups/backup-1", ""}, + {http.MethodGet, "/game/mods", ""}, + {http.MethodPost, "/game/mods/install", "{}"}, + {http.MethodPost, "/game/mods/mod-1", "{}"}, + {http.MethodPost, "/agent/update", "{}"}, + {http.MethodGet, "/agent/update/status", ""}, + {http.MethodGet, "/metrics/process", ""}, + {http.MethodPost, "/dev/codeserver/start", ""}, + {http.MethodPost, "/dev/codeserver/stop", ""}, + {http.MethodPost, "/dev/codeserver/restart", ""}, + } + + mux := NewMux() + for _, tc := range protected { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + rec := httptest.NewRecorder() + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("without token status = %d, want %d", rec.Code, http.StatusUnauthorized) + } + + req = httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + req.Header.Set("Authorization", "Bearer test-token") + rec = httptest.NewRecorder() + + mux.ServeHTTP(rec, req) + + if rec.Code == http.StatusUnauthorized { + t.Fatalf("with token status = %d, want authorized request to reach handler", rec.Code) + } + }) + } +} + +func TestProtectedRoutesFailClosedWhenAgentTokenMissing(t *testing.T) { + t.Setenv("ZLH_AGENT_TOKEN", "") + + mux := NewMux() + req := httptest.NewRequest(http.MethodGet, "/status", nil) + rec := httptest.NewRecorder() + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } +} + +func TestPublicRoutesDoNotRequireAgentToken(t *testing.T) { + t.Setenv("ZLH_AGENT_TOKEN", "") + + mux := NewMux() + for _, path := range []string{"/health", "/version"} { + t.Run(path, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + + mux.ServeHTTP(rec, req) + + if rec.Code == http.StatusUnauthorized { + t.Fatalf("status = %d, want public route", rec.Code) + } + }) + } +} + +func TestConsoleWebSocketRequiresAgentToken(t *testing.T) { + t.Setenv("ZLH_AGENT_TOKEN", "test-token") + + server := httptest.NewServer(NewMux()) + defer server.Close() + + conn, resp, err := websocket.DefaultDialer.Dial(strings.Replace(server.URL, "http://", "ws://", 1)+"/console/stream", nil) + if conn != nil { + _ = conn.Close() + } + if err == nil { + t.Fatalf("websocket dial without token succeeded") + } + if resp == nil { + t.Fatalf("websocket dial response is nil: %v", err) + } + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusUnauthorized) + } +} + func TestEnsureDevCodeServerInstallsAndStartsWhenRequested(t *testing.T) { restoreCodeServerTestHooks(t) diff --git a/logs/backup_restore.log b/logs/backup_restore.log index 40e5808..abdafbe 100644 --- a/logs/backup_restore.log +++ b/logs/backup_restore.log @@ -305,3 +305,10 @@ [2026-04-30T20:08:10Z] action=archive_write step=add_path status=complete backup_id=safe path=world files=1 bytes=6 [2026-04-30T20:08:10Z] action=archive_write status=complete backup_id=safe files=1 bytes=6 [2026-04-30T20:08:10Z] action=archive_restore status=begin archive_path=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting3515028932/003/safe.tar.gz server_root=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting3515028932/001 paths=["../world"] +[2026-04-30T21:43:50Z] action=archive_write status=begin backup_id=safe archive_path=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting2034890975/003/safe.tar.gz server_root=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting2034890975/002 paths=["world"] +[2026-04-30T21:43:50Z] action=archive_write step=add_path status=begin backup_id=safe path=world +[2026-04-30T21:43:50Z] action=archive_write step=entry backup_id=safe type=dir path=world +[2026-04-30T21:43:50Z] action=archive_write step=entry backup_id=safe type=file path=world/level.dat bytes=6 total_files=1 total_bytes=6 +[2026-04-30T21:43:50Z] action=archive_write step=add_path status=complete backup_id=safe path=world files=1 bytes=6 +[2026-04-30T21:43:50Z] action=archive_write status=complete backup_id=safe files=1 bytes=6 +[2026-04-30T21:43:50Z] action=archive_restore status=begin archive_path=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting2034890975/003/safe.tar.gz server_root=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting2034890975/001 paths=["../world"] diff --git a/state/update.json b/state/update.json index 3adafad..f76512a 100644 --- a/state/update.json +++ b/state/update.json @@ -1,6 +1,6 @@ { - "status": "available", - "current": "0.0.0-dev", + "status": "noop", + "current": "1.0.70", "target": "1.0.70", - "checked_at_utc": "2026-04-30T20:48:03Z" + "checked_at_utc": "2026-05-01T14:05:18Z" } \ No newline at end of file