agent security updates 4-30-26
This commit is contained in:
parent
2517a41ddf
commit
f1a245cc01
90
internal/auth/auth.go
Normal file
90
internal/auth/auth.go
Normal 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
|
||||
}
|
||||
@ -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),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"]
|
||||
|
||||
12
main.go
12
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,
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
|
||||
@ -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"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user