Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions manifests/k3d/beta9.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,11 @@ spec:
postgresql:
auth:
username: root
password: password
# Was the literal string "password" (security baseline P0 #4,
# plaintext infra creds). Substituted at apply time by
# beta9_deploy.sh's envsubst pass, same pattern as SENTRY_DSN below
# — export POSTGRES_PASSWORD before running the deploy script.
password: ${POSTGRES_PASSWORD}
database: main
image:
registry: registry.agentosaurus.com
Expand Down Expand Up @@ -351,7 +355,10 @@ data:
host: postgresql
port: 5432
username: root
password: password
# Must match the postgresql HelmChart's auth.password above —
# same POSTGRES_PASSWORD substituted at apply time (security
# baseline P0 #4).
password: ${POSTGRES_PASSWORD}
name: main
timezone: UTC
redis:
Expand Down
106 changes: 98 additions & 8 deletions pkg/agent/control.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,62 @@ package agent
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"

"github.com/rs/zerolog/log"
)

const DefaultControlPort = 9999

// DefaultControlBindAddr binds the control server to loopback only by
// default (P0-2: it used to hardcode 0.0.0.0, reachable by any Tailnet
// peer with zero authentication). Set BETA9_AGENT_CONTROL_BIND_ADDR to
// override for setups that genuinely need the gateway to reach this over
// the Tailnet — the shared-secret check below still gates every request
// either way.
const DefaultControlBindAddr = "127.0.0.1"

// isValidOllamaModelName allows only simple local model references
// (name, name:tag, namespace/name:tag) and rejects anything shaped like a
// fully-qualified third-party registry reference. P0-2b: `req.Model` used
// to be forwarded verbatim to Ollama's /api/pull, so a caller could smuggle
// "registry.attacker.com/model:tag" — a supply-chain pull plus a disk-fill
// DoS via the 30-minute pull timeout. Charset is an explicit allowlist
// (not a regex, per this repo's ban on hand-rolled regex for validation);
// the "does the first path segment contain a dot" check is the same
// heuristic Docker/Ollama use to tell a registry hostname apart from a
// plain namespace.
func isValidOllamaModelName(name string) bool {
if name == "" || len(name) > 255 {
return false
}
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
case r == '_' || r == '.' || r == '-' || r == ':' || r == '/':
default:
return false
}
}
if strings.Contains(name, "..") || strings.Contains(name, "//") {
return false
}
parts := strings.Split(name, "/")
if len(parts) > 2 {
return false // deeper than namespace/name is a fully-qualified external registry path
}
if len(parts) == 2 && strings.Contains(parts[0], ".") {
return false // first segment looks like a registry hostname (e.g. registry.attacker.com)
}
return true
}

// ControlServer handles external commands to the agent
type ControlServer struct {
agent *Agent
Expand All @@ -31,27 +77,63 @@ func NewControlServer(agent *Agent, port int) *ControlServer {
}
}

// requireControlToken wraps a handler so it 401s unless the caller presents
// the shared secret from BETA9_AGENT_CONTROL_TOKEN as a Bearer token or
// X-Control-Token header. P0-2: this server previously had zero
// authentication at all — any Tailnet peer reaching :9999 could start/stop
// inference or trigger a model pull. Fails closed (503) if the operator
// hasn't set a token, matching the same policy used for the gateway's
// cluster-admin auth rather than silently allowing everything.
func (c *ControlServer) requireControlToken(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := os.Getenv("BETA9_AGENT_CONTROL_TOKEN")
if token == "" {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
"status": "error",
"error": "BETA9_AGENT_CONTROL_TOKEN is not configured; refusing control requests",
})
return
}
supplied := r.Header.Get("X-Control-Token")
if supplied == "" {
supplied = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
if subtle.ConstantTimeCompare([]byte(supplied), []byte(token)) != 1 {
writeJSON(w, http.StatusUnauthorized, map[string]any{
"status": "error",
"error": "missing or invalid control token",
})
return
}
next(w, r)
}
}

// Start starts the control server
func (c *ControlServer) Start(ctx context.Context) error {
mux := http.NewServeMux()

// Inference control
mux.HandleFunc("/inference/start", c.handleInferenceStart)
mux.HandleFunc("/inference/stop", c.handleInferenceStop)
mux.HandleFunc("/inference/status", c.handleInferenceStatus)
mux.HandleFunc("/inference/pull", c.handleInferencePull)
mux.HandleFunc("/inference/start", c.requireControlToken(c.handleInferenceStart))
mux.HandleFunc("/inference/stop", c.requireControlToken(c.handleInferenceStop))
mux.HandleFunc("/inference/status", c.requireControlToken(c.handleInferenceStatus))
mux.HandleFunc("/inference/pull", c.requireControlToken(c.handleInferencePull))

// Agent status
mux.HandleFunc("/status", c.handleStatus)
mux.HandleFunc("/health", c.handleHealth)
mux.HandleFunc("/status", c.requireControlToken(c.handleStatus))
mux.HandleFunc("/health", c.handleHealth) // unauthenticated liveness probe only

bindAddr := os.Getenv("BETA9_AGENT_CONTROL_BIND_ADDR")
if bindAddr == "" {
bindAddr = DefaultControlBindAddr
}
c.server = &http.Server{
Addr: fmt.Sprintf("0.0.0.0:%d", c.port),
Addr: fmt.Sprintf("%s:%d", bindAddr, c.port),
Handler: mux,
}

go func() {
log.Info().Int("port", c.port).Msg("Control server starting")
log.Info().Str("bind_addr", bindAddr).Int("port", c.port).Msg("Control server starting")
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error().Err(err).Msg("Control server error")
}
Expand Down Expand Up @@ -144,6 +226,14 @@ func (c *ControlServer) handleInferencePull(w http.ResponseWriter, r *http.Reque
return
}

if !isValidOllamaModelName(req.Model) {
writeJSON(w, http.StatusBadRequest, map[string]any{
"status": "error",
"error": "Model name must be a simple local reference (name[:tag] or namespace/name[:tag]), not a fully-qualified registry path",
})
return
}

log.Info().Str("model", req.Model).Msg("Control: pull model command received")
c.agent.state.AddLog(fmt.Sprintf("Pulling model: %s", req.Model))

Expand Down
98 changes: 63 additions & 35 deletions pkg/api/v1/machine.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package apiv1

import (
"fmt"
"net"
"net/http"
"strconv"

"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"

"github.com/beam-cloud/beta9/pkg/auth"
"github.com/beam-cloud/beta9/pkg/network"
Expand Down Expand Up @@ -49,37 +51,56 @@ type MachineGroup struct {
routerGroup *echo.Group
config types.AppConfig
workerRepo repository.WorkerRepository
inferenceRegistry interface {
RegisterNode(info interface{})
UpdateHeartbeat(nodeID string)
UpdateNodeModels(nodeID string, models interface{})
}
inferenceRegistry InferenceRegistry
}

// Interface for model registry to avoid circular imports if possible,
// or we need to import it. Since gateway imports apiv1, apiv1 cannot import gateway.
// We'll define the interface here or pass it as 'any' and cast it, or better, move model registry to a shared package.
// For now, let's use a narrow interface matching methods we need.
// InferenceRegistry mirrors the subset of gateway.ModelRegistry's methods
// this package needs. apiv1 can't import gateway (gateway already imports
// apiv1), so this narrow interface breaks the cycle — but it must use the
// SAME concrete parameter types gateway.ModelRegistry actually implements
// (pkg/types.NodeInferenceInfo / pkg/types.ModelInfo, both already shared
// and importable here). The previous version declared RegisterNode(any) /
// UpdateNodeModels(nodeID string, any): Go requires exact signature matches
// for interface satisfaction, so *gateway.ModelRegistry never satisfied it
// and `inferenceRegistry.(InferenceRegistry)` below panicked on every
// gateway startup (P0, beta9 security baseline 2026-04-20 onward).
type InferenceRegistry interface {
RegisterNode(info any)
RegisterNode(info *types.NodeInferenceInfo)
UpdateHeartbeat(nodeID string)
UpdateNodeModels(nodeID string, models any)
UpdateNodeModels(nodeID string, models map[string]*types.ModelInfo)
}

func NewMachineGroup(g *echo.Group, providerRepo repository.ProviderRepository, tailscale *network.Tailscale, config types.AppConfig, workerRepo repository.WorkerRepository, inferenceRegistry any) *MachineGroup {
// Non-panicking assertion as defense in depth: if a caller ever passes
// something that doesn't satisfy InferenceRegistry, disable inference
// keepalive/registration for this group instead of crashing the gateway.
registry, ok := inferenceRegistry.(InferenceRegistry)
if !ok && inferenceRegistry != nil {
log.Warn().
Str("type", fmt.Sprintf("%T", inferenceRegistry)).
Msg("inferenceRegistry does not implement apiv1.InferenceRegistry; inference keepalive/registration disabled for this machine group")
}
group := &MachineGroup{routerGroup: g,
providerRepo: providerRepo,
tailscale: tailscale,
config: config,
workerRepo: workerRepo,
inferenceRegistry: inferenceRegistry.(InferenceRegistry),
inferenceRegistry: registry,
}

g.GET("/:workspaceId/gpus", auth.WithWorkspaceAuth(group.GPUCounts))
g.POST("/register", group.RegisterMachine)
g.POST("/keepalive", group.MachineKeepalive)
g.GET("/config", group.GetConfig)
g.GET("/list", group.ListPoolMachines)
// RegisterMachine/MachineKeepalive/GetConfig/ListPoolMachines all do
// `cc, _ := ctx.(*auth.HttpAuthContext)` and immediately dereference
// `cc.AuthInfo...` with no ok-check. Unwrapped, a caller with no token
// hits fail-open AuthMiddleware (ctx stays plain echo.Context), so cc
// is nil and every one of these routes nil-derefs on the first line
// (security baseline P1: "nil-deref panic on unauth /v1/machine/*").
// auth.WithAuth guarantees ctx is *HttpAuthContext before the handler
// runs, which both fixes the panic and requires real authentication.
g.POST("/register", auth.WithAuth(group.RegisterMachine))
g.POST("/keepalive", auth.WithAuth(group.MachineKeepalive))
g.GET("/config", auth.WithAuth(group.GetConfig))
g.GET("/list", auth.WithAuth(group.ListPoolMachines))
return group
}

Expand Down Expand Up @@ -202,22 +223,22 @@ func (g *MachineGroup) RegisterMachine(ctx echo.Context) error {
})
}

// Helper structs for reflection/dynamic typing without importing gateway
type NodeInferenceInfo struct {
NodeID string `json:"node_id"`
TailscaleIP string `json:"tailscale_ip"`
Port int `json:"port"`
GPUType string `json:"gpu_type"`
TotalVRAM int64 `json:"total_vram_mb"`
AvailableVRAM int64 `json:"available_vram_mb"`
Models map[string]*ModelInfo `json:"models"`
}
// tailscaleCGNAT / isTailscaleAddr duplicate pkg/gateway's inference_handlers.go
// helper of the same purpose — apiv1 can't import gateway (gateway already
// imports apiv1), so this is a small, intentional duplication rather than a
// new shared package for one four-line check. If a third caller ever needs
// it, move both copies to pkg/network.
var tailscaleCGNAT = func() *net.IPNet {
_, n, err := net.ParseCIDR("100.64.0.0/10")
if err != nil {
panic(err)
}
return n
}()

type ModelInfo struct {
Name string `json:"name"`
LoadState string `json:"load_state"`
LastUsed any `json:"last_used"`
LoadedAt any `json:"loaded_at"`
func isTailscaleAddr(ip string) bool {
parsed := net.ParseIP(ip)
return parsed != nil && tailscaleCGNAT.Contains(parsed)
}

func (g *MachineGroup) MachineKeepalive(ctx echo.Context) error {
Expand Down Expand Up @@ -254,17 +275,24 @@ func (g *MachineGroup) MachineKeepalive(ctx echo.Context) error {

// Update inference status if available
if request.Inference != nil && g.inferenceRegistry != nil {
if request.Inference.Status == "running" && request.Inference.IP != "" && !isTailscaleAddr(request.Inference.IP) {
// P1 (beta9 security baseline): a compromised-but-authenticated
// worker token could otherwise redirect all chat/embed traffic
// for its machine_id to an arbitrary IP via keepalive. Same
// CGNAT check as /inference/nodes/register in the gateway package.
return HTTPBadRequest("inference.ip must be within the Tailscale CGNAT range (100.64.0.0/10)")
}
if request.Inference.Status == "running" {
// Register if needed (idempotent usually) or just update heartbeat
// Since we don't have full VRAM info here (it's in metrics but flat),
// we construct a best-effort update.

// Transform models list to map
modelsMap := make(map[string]*ModelInfo)
modelsMap := make(map[string]*types.ModelInfo)
for _, m := range request.Inference.Models {
modelsMap[m] = &ModelInfo{
modelsMap[m] = &types.ModelInfo{
Name: m,
LoadState: "ready", // Assume ready if reported
LoadState: types.LoadStateReady, // Assume ready if reported
}
}

Expand All @@ -282,7 +310,7 @@ func (g *MachineGroup) MachineKeepalive(ctx echo.Context) error {
// It probably should rely on keepalive.

// So let's do a RegisterNode call here with available info
info := &NodeInferenceInfo{
info := &types.NodeInferenceInfo{
NodeID: request.MachineID,
TailscaleIP: request.Inference.IP,
Port: request.Inference.Port,
Expand Down
8 changes: 7 additions & 1 deletion pkg/api/v1/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ func NewWorkspaceGroup(g *echo.Group, backendRepo repository.BackendRepository,
defaultStorageClient: defaultStorageClient,
}

g.POST("", group.CreateWorkspace)
// CreateWorkspace does `cc, _ := ctx.(*auth.HttpAuthContext)` and
// immediately dereferences cc.AuthInfo — unwrapped, an unauthenticated
// caller hits fail-open AuthMiddleware, cc is nil, and the handler
// nil-derefs (same P1 class as the /v1/machine/* routes).
// WithClusterAdminAuth both fixes that and enforces the check the
// handler body was already trying (and failing) to do itself.
g.POST("", auth.WithClusterAdminAuth(group.CreateWorkspace))
g.GET("/current", auth.WithAuth(group.CurrentWorkspace))
g.GET("/:workspaceId/export", auth.WithStrictWorkspaceAuth(group.ExportWorkspaceConfig))
g.POST("/:workspaceId/set-external-storage", auth.WithStrictWorkspaceAuth(group.SetExternalWorkspaceStorage))
Expand Down
Loading
Loading