diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..ffe265f --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,90 @@ +package auth + +import ( + "crypto/subtle" + "log" + "net/http" + "os" + "strings" +) + +const ( + envToken = "ZLH_AGENT_TOKEN" + HeaderToken = "X-ZLH-Agent-Token" + QueryTokenParam = "agent_token" +) + +type Policy struct { + Public map[string]map[string]struct{} +} + +func Public(methods ...string) map[string]struct{} { + out := make(map[string]struct{}, len(methods)) + for _, method := range methods { + out[strings.ToUpper(strings.TrimSpace(method))] = struct{}{} + } + return out +} + +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 !Authorized(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func (p Policy) IsPublic(method, path string) bool { + methods, ok := p.Public[path] + if !ok { + return false + } + _, ok = methods[strings.ToUpper(method)] + return ok +} + +func Authorized(r *http.Request) bool { + expected := strings.TrimSpace(os.Getenv(envToken)) + if expected == "" { + return true + } + + return constantTimeEqual(bearerToken(r.Header.Get("Authorization")), expected) || + constantTimeEqual(r.Header.Get(HeaderToken), expected) || + constantTimeEqual(r.URL.Query().Get(QueryTokenParam), expected) +} + +func bearerToken(header string) string { + header = strings.TrimSpace(header) + if header == "" { + return "" + } + before, after, ok := strings.Cut(header, " ") + if !ok || !strings.EqualFold(before, "Bearer") { + return "" + } + return strings.TrimSpace(after) +} + +func constantTimeEqual(got, expected string) bool { + got = strings.TrimSpace(got) + expected = strings.TrimSpace(expected) + if got == "" || expected == "" { + return false + } + if len(got) != len(expected) { + return false + } + return subtle.ConstantTimeCompare([]byte(got), []byte(expected)) == 1 +} diff --git a/internal/http/agent.go b/internal/http/agent.go index 9f469ad..3b45513 100755 --- a/internal/http/agent.go +++ b/internal/http/agent.go @@ -3,7 +3,6 @@ package agenthttp import ( "encoding/json" "fmt" - "io" "log" "net/http" "os" @@ -12,6 +11,7 @@ import ( "time" "zlh-agent/internal/alloy" + "zlh-agent/internal/auth" agentfiles "zlh-agent/internal/files" agenthandlers "zlh-agent/internal/handlers" mcstatus "zlh-agent/internal/minecraft" @@ -31,7 +31,10 @@ import ( "zlh-agent/internal/version" ) -const ReadinessTimeout = 60 * time.Second +const ( + ReadinessTimeout = 60 * time.Second + MaxConfigBytes = 1 << 20 +) /* -------------------------------------------------------------------------- @@ -331,14 +334,19 @@ func handleConfig(w http.ResponseWriter, r *http.Request) { return } - body, _ := io.ReadAll(r.Body) - var cfg state.Config - if err := json.Unmarshal(body, &cfg); err != nil { + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, MaxConfigBytes)) + dec.DisallowUnknownFields() + if err := dec.Decode(&cfg); err != nil { endOp() http.Error(w, "bad json", http.StatusBadRequest) return } + if err := state.ValidateConfig(&cfg); err != nil { + endOp() + http.Error(w, "invalid config: "+err.Error(), http.StatusBadRequest) + return + } log.Printf("[http] vmid=%d action=config status=received type=%s runtime=%s game=%s variant=%s version=%s", cfg.VMID, cfg.ContainerType, cfg.Runtime, cfg.Game, cfg.Variant, cfg.Version) if err := state.SaveConfig(&cfg); err != nil { @@ -898,7 +906,7 @@ func handleGamePlayers(w http.ResponseWriter, r *http.Request) { ---------------------------------------------------------------------------- */ -func NewMux() *http.ServeMux { +func NewMux() http.Handler { m := http.NewServeMux() m.HandleFunc("/config", handleConfig) @@ -937,5 +945,10 @@ func NewMux() *http.ServeMux { }) log.Println("[agent] routes registered") - return m + return auth.Wrap(m, auth.Policy{ + Public: map[string]map[string]struct{}{ + "/health": auth.Public(http.MethodGet), + "/version": auth.Public(http.MethodGet), + }, + }) } diff --git a/internal/http/console_sessions.go b/internal/http/console_sessions.go index 374abc4..e445ea3 100644 --- a/internal/http/console_sessions.go +++ b/internal/http/console_sessions.go @@ -25,6 +25,7 @@ type consoleSession struct { key string cfg *state.Config ptyFile *os.File + ownsPTY bool createdAt time.Time lastActive time.Time @@ -76,6 +77,7 @@ func getConsoleSession(cfg *state.Config) (*consoleSession, bool, error) { key: key, cfg: cfg, ptyFile: ptyFile, + ownsPTY: cfg.ContainerType == "dev", createdAt: time.Now(), lastActive: time.Now(), conns: make(map[*websocket.Conn]*consoleConn), @@ -215,7 +217,7 @@ func (s *consoleSession) destroy() { s.ptyFile = nil s.mu.Unlock() - if pty != nil { + if pty != nil && s.ownsPTY { _ = pty.Close() } diff --git a/internal/provision/minecraft/fabric_proxy_test.go b/internal/provision/minecraft/fabric_proxy_test.go index eddf212..3e949db 100644 --- a/internal/provision/minecraft/fabric_proxy_test.go +++ b/internal/provision/minecraft/fabric_proxy_test.go @@ -31,7 +31,6 @@ func TestResolveProxyVersion(t *testing.T) { origRoot := fabricProxyArtifactRoot origMap := fabricProxyMap origErr := fabricProxyMapErr - origOnce := fabricProxyMapOnce fabricProxyArtifactRoot = "zpacks/minecraft/fabric/fabric-proxy-lite" fabricProxyMap = nil fabricProxyMapErr = nil @@ -40,7 +39,7 @@ func TestResolveProxyVersion(t *testing.T) { fabricProxyArtifactRoot = origRoot fabricProxyMap = origMap fabricProxyMapErr = origErr - fabricProxyMapOnce = origOnce + fabricProxyMapOnce = sync.Once{} }) tests := []struct { diff --git a/internal/state/state.go b/internal/state/state.go index fc9faf8..e8f86c2 100755 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -6,6 +6,7 @@ import ( "log" "os" "strconv" + "strings" "sync" "time" ) @@ -342,7 +343,113 @@ func SaveConfig(cfg *Config) error { return err } - return os.WriteFile(configPath, b, 0o644) + return os.WriteFile(configPath, b, 0o600) +} + +func ValidateConfig(cfg *Config) error { + if cfg == nil { + return fmt.Errorf("config required") + } + if cfg.VMID <= 0 { + return fmt.Errorf("vmid must be positive") + } + + normalizeContainerType(cfg) + switch strings.ToLower(strings.TrimSpace(cfg.ContainerType)) { + case "dev": + return validateDevConfig(cfg) + case "game": + return validateGameConfig(cfg) + default: + return fmt.Errorf("unsupported container_type: %s", cfg.ContainerType) + } +} + +func normalizeContainerType(cfg *Config) { + if strings.TrimSpace(cfg.ContainerType) != "" { + cfg.ContainerType = strings.ToLower(strings.TrimSpace(cfg.ContainerType)) + return + } + vmidStr := strconv.Itoa(cfg.VMID) + if len(vmidStr) > 0 { + switch vmidStr[0] { + case '6': + cfg.ContainerType = "dev" + case '5': + cfg.ContainerType = "game" + } + } + if cfg.ContainerType == "" && strings.TrimSpace(cfg.Runtime) != "" { + cfg.ContainerType = "dev" + } + if cfg.ContainerType == "" { + cfg.ContainerType = "game" + } +} + +func validateDevConfig(cfg *Config) error { + if strings.TrimSpace(cfg.Game) != "" || strings.TrimSpace(cfg.Variant) != "" { + return fmt.Errorf("game and variant are not valid for dev containers") + } + runtime := strings.ToLower(strings.TrimSpace(cfg.Runtime)) + switch runtime { + case "node", "python", "go", "java", "dotnet": + cfg.Runtime = runtime + default: + return fmt.Errorf("unsupported dev runtime: %s", cfg.Runtime) + } + if strings.TrimSpace(cfg.Version) == "" { + return fmt.Errorf("version required for dev containers") + } + return validateMemory(cfg.MemoryMB) +} + +func validateGameConfig(cfg *Config) error { + if cfg.EnableCodeServer { + return fmt.Errorf("code-server is only valid for dev containers") + } + game := strings.ToLower(strings.TrimSpace(cfg.Game)) + if game == "" { + return fmt.Errorf("game required for game containers") + } + cfg.Game = game + + switch game { + case "minecraft": + variant := strings.ToLower(strings.TrimSpace(cfg.Variant)) + switch variant { + case "vanilla", "fabric", "paper", "purpur", "quilt", "forge", "neoforge": + cfg.Variant = variant + default: + return fmt.Errorf("unsupported minecraft variant: %s", cfg.Variant) + } + if strings.TrimSpace(cfg.Version) == "" { + return fmt.Errorf("version required for minecraft containers") + } + case "valheim", "rust", "terraria", "projectzomboid": + default: + return fmt.Errorf("unsupported game: %s", cfg.Game) + } + + if err := validateMemory(cfg.MemoryMB); err != nil { + return err + } + for _, port := range cfg.Ports { + if port < 1 || port > 65535 { + return fmt.Errorf("invalid port: %d", port) + } + } + return nil +} + +func validateMemory(memoryMB int) error { + if memoryMB == 0 { + return nil + } + if memoryMB < 256 || memoryMB > 262144 { + return fmt.Errorf("memory_mb out of range: %d", memoryMB) + } + return nil } func LoadConfig() (*Config, error) { @@ -356,23 +463,7 @@ func LoadConfig() (*Config, error) { return nil, err } - if cfg.ContainerType == "" { - vmidStr := strconv.Itoa(cfg.VMID) - if len(vmidStr) > 0 { - switch vmidStr[0] { - case '6': - cfg.ContainerType = "dev" - case '5': - cfg.ContainerType = "game" - } - } - if cfg.ContainerType == "" && cfg.Runtime != "" { - cfg.ContainerType = "dev" - } - if cfg.ContainerType == "" { - cfg.ContainerType = "game" - } - } + normalizeContainerType(&cfg) return &cfg, nil } diff --git a/logs/backup_restore.log b/logs/backup_restore.log index 7543296..40e5808 100644 --- a/logs/backup_restore.log +++ b/logs/backup_restore.log @@ -270,3 +270,38 @@ [2026-04-18T16:40:02Z] action=archive_write step=add_path status=complete backup_id=safe path=world files=1 bytes=6 [2026-04-18T16:40:02Z] action=archive_write status=complete backup_id=safe files=1 bytes=6 [2026-04-18T16:40:02Z] action=archive_restore status=begin archive_path=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting1871575453/003/safe.tar.gz server_root=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting1871575453/001 paths=["../world"] +[2026-04-24T16:43:38Z] action=archive_write status=begin backup_id=safe archive_path=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting2012860903/003/safe.tar.gz server_root=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting2012860903/002 paths=["world"] +[2026-04-24T16:43:38Z] action=archive_write step=add_path status=begin backup_id=safe path=world +[2026-04-24T16:43:38Z] action=archive_write step=entry backup_id=safe type=dir path=world +[2026-04-24T16:43:38Z] action=archive_write step=entry backup_id=safe type=file path=world/level.dat bytes=6 total_files=1 total_bytes=6 +[2026-04-24T16:43:38Z] action=archive_write step=add_path status=complete backup_id=safe path=world files=1 bytes=6 +[2026-04-24T16:43:38Z] action=archive_write status=complete backup_id=safe files=1 bytes=6 +[2026-04-24T16:43:38Z] action=archive_restore status=begin archive_path=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting2012860903/003/safe.tar.gz server_root=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting2012860903/001 paths=["../world"] +[2026-04-29T12:50:49Z] action=archive_write status=begin backup_id=safe archive_path=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting3857932451/003/safe.tar.gz server_root=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting3857932451/002 paths=["world"] +[2026-04-29T12:50:49Z] action=archive_write step=add_path status=begin backup_id=safe path=world +[2026-04-29T12:50:49Z] action=archive_write step=entry backup_id=safe type=dir path=world +[2026-04-29T12:50:49Z] action=archive_write step=entry backup_id=safe type=file path=world/level.dat bytes=6 total_files=1 total_bytes=6 +[2026-04-29T12:50:49Z] action=archive_write step=add_path status=complete backup_id=safe path=world files=1 bytes=6 +[2026-04-29T12:50:49Z] action=archive_write status=complete backup_id=safe files=1 bytes=6 +[2026-04-29T12:50:49Z] action=archive_restore status=begin archive_path=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting3857932451/003/safe.tar.gz server_root=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting3857932451/001 paths=["../world"] +[2026-04-30T19:29:21Z] action=archive_write status=begin backup_id=safe archive_path=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting2552956638/003/safe.tar.gz server_root=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting2552956638/002 paths=["world"] +[2026-04-30T19:29:21Z] action=archive_write step=add_path status=begin backup_id=safe path=world +[2026-04-30T19:29:21Z] action=archive_write step=entry backup_id=safe type=dir path=world +[2026-04-30T19:29:21Z] action=archive_write step=entry backup_id=safe type=file path=world/level.dat bytes=6 total_files=1 total_bytes=6 +[2026-04-30T19:29:21Z] action=archive_write step=add_path status=complete backup_id=safe path=world files=1 bytes=6 +[2026-04-30T19:29:21Z] action=archive_write status=complete backup_id=safe files=1 bytes=6 +[2026-04-30T19:29:21Z] action=archive_restore status=begin archive_path=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting2552956638/003/safe.tar.gz server_root=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting2552956638/001 paths=["../world"] +[2026-04-30T20:07:33Z] action=archive_write status=begin backup_id=safe archive_path=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting2513795887/003/safe.tar.gz server_root=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting2513795887/002 paths=["world"] +[2026-04-30T20:07:33Z] action=archive_write step=add_path status=begin backup_id=safe path=world +[2026-04-30T20:07:33Z] action=archive_write step=entry backup_id=safe type=dir path=world +[2026-04-30T20:07:33Z] action=archive_write step=entry backup_id=safe type=file path=world/level.dat bytes=6 total_files=1 total_bytes=6 +[2026-04-30T20:07:33Z] action=archive_write step=add_path status=complete backup_id=safe path=world files=1 bytes=6 +[2026-04-30T20:07:33Z] action=archive_write status=complete backup_id=safe files=1 bytes=6 +[2026-04-30T20:07:33Z] action=archive_restore status=begin archive_path=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting2513795887/003/safe.tar.gz server_root=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting2513795887/001 paths=["../world"] +[2026-04-30T20:08:10Z] action=archive_write status=begin backup_id=safe archive_path=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting3515028932/003/safe.tar.gz server_root=/tmp/TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting3515028932/002 paths=["world"] +[2026-04-30T20:08:10Z] action=archive_write step=add_path status=begin backup_id=safe path=world +[2026-04-30T20:08:10Z] action=archive_write step=entry backup_id=safe type=dir path=world +[2026-04-30T20:08:10Z] action=archive_write step=entry backup_id=safe type=file path=world/level.dat bytes=6 total_files=1 total_bytes=6 +[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"] diff --git a/main.go b/main.go index c2d4851..3b25b97 100755 --- a/main.go +++ b/main.go @@ -57,11 +57,13 @@ func main() { update.StartPeriodic(version.AgentVersion) server := &http.Server{ - Addr: addr, - Handler: mux, - ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, - IdleTimeout: 60 * time.Second, + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 1 << 20, } // ------------------------------------------------------------ diff --git a/state/update.json b/state/update.json index a745afa..3adafad 100644 --- a/state/update.json +++ b/state/update.json @@ -1,6 +1,6 @@ { "status": "available", "current": "0.0.0-dev", - "target": "1.0.68", - "checked_at_utc": "2026-04-19T21:48:03Z" + "target": "1.0.70", + "checked_at_utc": "2026-04-30T20:48:03Z" } \ No newline at end of file