agent security updates 4-30-26

This commit is contained in:
jester 2026-04-30 21:01:41 +00:00
parent 2517a41ddf
commit f1a245cc01
8 changed files with 267 additions and 35 deletions

90
internal/auth/auth.go Normal file
View File

@ -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
}

View File

@ -3,7 +3,6 @@ package agenthttp
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"os" "os"
@ -12,6 +11,7 @@ import (
"time" "time"
"zlh-agent/internal/alloy" "zlh-agent/internal/alloy"
"zlh-agent/internal/auth"
agentfiles "zlh-agent/internal/files" agentfiles "zlh-agent/internal/files"
agenthandlers "zlh-agent/internal/handlers" agenthandlers "zlh-agent/internal/handlers"
mcstatus "zlh-agent/internal/minecraft" mcstatus "zlh-agent/internal/minecraft"
@ -31,7 +31,10 @@ import (
"zlh-agent/internal/version" "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 return
} }
body, _ := io.ReadAll(r.Body)
var cfg state.Config 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() endOp()
http.Error(w, "bad json", http.StatusBadRequest) http.Error(w, "bad json", http.StatusBadRequest)
return 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) 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 { 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 := http.NewServeMux()
m.HandleFunc("/config", handleConfig) m.HandleFunc("/config", handleConfig)
@ -937,5 +945,10 @@ func NewMux() *http.ServeMux {
}) })
log.Println("[agent] routes registered") 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),
},
})
} }

View File

@ -25,6 +25,7 @@ type consoleSession struct {
key string key string
cfg *state.Config cfg *state.Config
ptyFile *os.File ptyFile *os.File
ownsPTY bool
createdAt time.Time createdAt time.Time
lastActive time.Time lastActive time.Time
@ -76,6 +77,7 @@ func getConsoleSession(cfg *state.Config) (*consoleSession, bool, error) {
key: key, key: key,
cfg: cfg, cfg: cfg,
ptyFile: ptyFile, ptyFile: ptyFile,
ownsPTY: cfg.ContainerType == "dev",
createdAt: time.Now(), createdAt: time.Now(),
lastActive: time.Now(), lastActive: time.Now(),
conns: make(map[*websocket.Conn]*consoleConn), conns: make(map[*websocket.Conn]*consoleConn),
@ -215,7 +217,7 @@ func (s *consoleSession) destroy() {
s.ptyFile = nil s.ptyFile = nil
s.mu.Unlock() s.mu.Unlock()
if pty != nil { if pty != nil && s.ownsPTY {
_ = pty.Close() _ = pty.Close()
} }

View File

@ -31,7 +31,6 @@ func TestResolveProxyVersion(t *testing.T) {
origRoot := fabricProxyArtifactRoot origRoot := fabricProxyArtifactRoot
origMap := fabricProxyMap origMap := fabricProxyMap
origErr := fabricProxyMapErr origErr := fabricProxyMapErr
origOnce := fabricProxyMapOnce
fabricProxyArtifactRoot = "zpacks/minecraft/fabric/fabric-proxy-lite" fabricProxyArtifactRoot = "zpacks/minecraft/fabric/fabric-proxy-lite"
fabricProxyMap = nil fabricProxyMap = nil
fabricProxyMapErr = nil fabricProxyMapErr = nil
@ -40,7 +39,7 @@ func TestResolveProxyVersion(t *testing.T) {
fabricProxyArtifactRoot = origRoot fabricProxyArtifactRoot = origRoot
fabricProxyMap = origMap fabricProxyMap = origMap
fabricProxyMapErr = origErr fabricProxyMapErr = origErr
fabricProxyMapOnce = origOnce fabricProxyMapOnce = sync.Once{}
}) })
tests := []struct { tests := []struct {

View File

@ -6,6 +6,7 @@ import (
"log" "log"
"os" "os"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "time"
) )
@ -342,7 +343,113 @@ func SaveConfig(cfg *Config) error {
return err 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) { func LoadConfig() (*Config, error) {
@ -356,23 +463,7 @@ func LoadConfig() (*Config, error) {
return nil, err return nil, err
} }
if cfg.ContainerType == "" { normalizeContainerType(&cfg)
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"
}
}
return &cfg, nil return &cfg, nil
} }

View File

@ -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 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_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-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"]

12
main.go
View File

@ -57,11 +57,13 @@ func main() {
update.StartPeriodic(version.AgentVersion) update.StartPeriodic(version.AgentVersion)
server := &http.Server{ server := &http.Server{
Addr: addr, Addr: addr,
Handler: mux, Handler: mux,
ReadTimeout: 30 * time.Second, ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 30 * time.Second, ReadTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second, WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
} }
// ------------------------------------------------------------ // ------------------------------------------------------------

View File

@ -1,6 +1,6 @@
{ {
"status": "available", "status": "available",
"current": "0.0.0-dev", "current": "0.0.0-dev",
"target": "1.0.68", "target": "1.0.70",
"checked_at_utc": "2026-04-19T21:48:03Z" "checked_at_utc": "2026-04-30T20:48:03Z"
} }