295eb22826
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
422 lines
14 KiB
Go
422 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"math/rand/v2"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
|
|
"github.com/dbledeez/panel/agent/internal/dispatch"
|
|
agentruntime "github.com/dbledeez/panel/agent/internal/runtime"
|
|
modulepkg "github.com/dbledeez/panel/pkg/module"
|
|
"github.com/dbledeez/panel/pkg/version"
|
|
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
|
|
)
|
|
|
|
// loadClientTLS returns gRPC credentials configured for mTLS against
|
|
// the controller's CA, or nil if cert files aren't present (caller then
|
|
// decides whether to refuse or fall back to plaintext). The returned CN
|
|
// is the agent identity the controller issued during pairing — callers
|
|
// should use it as the agent_id so identity stays cryptographically bound
|
|
// to the cert instead of drifting to whatever hostname the process sees.
|
|
func loadClientTLS(certDir, controllerAddr string) (credentials.TransportCredentials, string, error) {
|
|
caPath := filepath.Join(certDir, "ca.crt")
|
|
certPath := filepath.Join(certDir, "agent.crt")
|
|
keyPath := filepath.Join(certDir, "agent.key")
|
|
|
|
for _, p := range []string{caPath, certPath, keyPath} {
|
|
if _, err := os.Stat(p); err != nil {
|
|
return nil, "", fmt.Errorf("missing %s: %w", p, err)
|
|
}
|
|
}
|
|
caPEM, err := os.ReadFile(caPath)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
pool := x509.NewCertPool()
|
|
if !pool.AppendCertsFromPEM(caPEM) {
|
|
return nil, "", fmt.Errorf("bad CA PEM")
|
|
}
|
|
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("load cert+key: %w", err)
|
|
}
|
|
var cn string
|
|
if len(cert.Certificate) > 0 {
|
|
if leaf, perr := x509.ParseCertificate(cert.Certificate[0]); perr == nil {
|
|
cn = leaf.Subject.CommonName
|
|
}
|
|
}
|
|
// ServerName defaults to the host part of controllerAddr. If it's
|
|
// an IP we still set ServerName so Go's TLS stack uses SNI; the
|
|
// controller cert includes 127.0.0.1 as an IP SAN so this works.
|
|
serverName := controllerAddr
|
|
if i := strings.LastIndex(serverName, ":"); i > 0 {
|
|
serverName = serverName[:i]
|
|
}
|
|
tlsCfg := &tls.Config{
|
|
Certificates: []tls.Certificate{cert},
|
|
RootCAs: pool,
|
|
ServerName: serverName,
|
|
MinVersion: tls.VersionTLS12,
|
|
}
|
|
return credentials.NewTLS(tlsCfg), cn, nil
|
|
}
|
|
|
|
// agentVersion mirrors the shared build version (ldflags-injected via
|
|
// github.com/dbledeez/panel/pkg/version.Version; "dev" in ad-hoc builds).
|
|
var agentVersion = version.Version
|
|
|
|
func hostname() string {
|
|
h, err := os.Hostname()
|
|
if err != nil || h == "" {
|
|
return "unknown"
|
|
}
|
|
return h
|
|
}
|
|
|
|
func main() {
|
|
controllerAddr := flag.String("controller", "localhost:8443", "controller gRPC address host:port")
|
|
agentID := flag.String("agent-id", "", "agent identifier (default: mTLS cert CN, or hostname when --insecure)")
|
|
modulesDir := flag.String("modules-dir", "./modules", "path to modules directory")
|
|
dataRoot := flag.String("data-root", "./data/instances", "root directory for instance data")
|
|
metaDir := flag.String("meta-dir", "./data/instance-meta", "directory for per-instance sidecar metadata (survives agent restart)")
|
|
backupDir := flag.String("backup-dir", "./data/backups", "directory where instance backup archives are written")
|
|
certDir := flag.String("cert-dir", "./data/certs", "directory holding agent.crt + agent.key + ca.crt after pairing")
|
|
heartbeatInterval := flag.Duration("heartbeat", 10*time.Second, "heartbeat interval")
|
|
insecureDial := flag.Bool("insecure", false, "force plaintext (no TLS). Use --insecure=true only for dev/local setups.")
|
|
pairToken := flag.String("pair-token", "", "if set, agent runs in pair mode: fetches CA + signed cert from --pair-url and exits")
|
|
pairURL := flag.String("pair-url", "http://localhost:8080", "controller HTTP URL for pair/CA/issue endpoints (used with --pair-token)")
|
|
logLevel := flag.String("log-level", "info", "log level: debug, info, warn, error")
|
|
flag.Parse()
|
|
|
|
var lvl slog.Level
|
|
if err := lvl.UnmarshalText([]byte(*logLevel)); err != nil {
|
|
fmt.Fprintf(os.Stderr, "invalid log level %q: %v\n", *logLevel, err)
|
|
os.Exit(2)
|
|
}
|
|
// Pair mode: fetch CA + sign a client cert, then exit. Pair runs before
|
|
// we touch the local cert so the agent-id has to come from either --agent-id
|
|
// or hostname — there's no cert CN to read yet.
|
|
if *pairToken != "" {
|
|
if *agentID == "" {
|
|
*agentID = hostname()
|
|
}
|
|
pairLogger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})).
|
|
With("agent_id", *agentID)
|
|
if err := runPair(pairConfig{
|
|
AgentID: *agentID,
|
|
PairURL: *pairURL,
|
|
Token: *pairToken,
|
|
CertDir: *certDir,
|
|
Logger: pairLogger,
|
|
}); err != nil {
|
|
pairLogger.Error("pair failed", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
// TLS creds: try loading cert files; fall back if --insecure.
|
|
// The cert's CN is the canonical agent identity. If --agent-id wasn't
|
|
// set explicitly, adopt the CN; if it was set to something different,
|
|
// refuse — a mismatched identity just silently orphans instances in the
|
|
// DB (the exact bug that motivated this change).
|
|
var tlsCreds credentials.TransportCredentials
|
|
var certCN string
|
|
creds, cn, tlsErr := loadClientTLS(*certDir, *controllerAddr)
|
|
switch {
|
|
case tlsErr == nil:
|
|
tlsCreds = creds
|
|
certCN = cn
|
|
case *insecureDial:
|
|
// Plaintext path — cert CN unavailable, fall through to hostname.
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "no TLS certs found and --insecure not set. Run pairing first:\n"+
|
|
" agent --pair-token <token> --pair-url http://controller:8080 --cert-dir %s\n"+
|
|
"detail: %v\n", *certDir, tlsErr)
|
|
os.Exit(2)
|
|
}
|
|
switch {
|
|
case *agentID == "" && certCN != "":
|
|
*agentID = certCN
|
|
case *agentID == "":
|
|
*agentID = hostname()
|
|
case certCN != "" && *agentID != certCN:
|
|
fmt.Fprintf(os.Stderr, "--agent-id=%q does not match mTLS cert CN=%q; refusing to start.\n"+
|
|
"Re-pair the agent with the intended id or drop --agent-id to adopt the cert CN.\n",
|
|
*agentID, certCN)
|
|
os.Exit(2)
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})).
|
|
With("agent_id", *agentID)
|
|
if tlsCreds != nil {
|
|
logger.Info("mTLS enabled", "cert_dir", *certDir, "cert_cn", certCN)
|
|
} else {
|
|
logger.Warn("running WITHOUT TLS (--insecure) — agent traffic is plaintext")
|
|
}
|
|
|
|
absDataRoot, err := filepath.Abs(*dataRoot)
|
|
if err != nil {
|
|
logger.Error("resolve data-root", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := os.MkdirAll(absDataRoot, 0o755); err != nil {
|
|
logger.Error("mkdir data-root", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
absMetaDir, err := filepath.Abs(*metaDir)
|
|
if err != nil {
|
|
logger.Error("resolve meta-dir", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := os.MkdirAll(absMetaDir, 0o755); err != nil {
|
|
logger.Error("mkdir meta-dir", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
absBackupDir, err := filepath.Abs(*backupDir)
|
|
if err != nil {
|
|
logger.Error("resolve backup-dir", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := os.MkdirAll(absBackupDir, 0o755); err != nil {
|
|
logger.Error("mkdir backup-dir", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
registry := modulepkg.NewRegistry()
|
|
if err := registry.LoadDir(*modulesDir); err != nil {
|
|
logger.Warn("module registry load failed", "dir", *modulesDir, "err", err)
|
|
}
|
|
logger.Info("modules loaded", "count", registry.Len(), "dir", *modulesDir)
|
|
for _, m := range registry.List() {
|
|
logger.Info("module registered", "id", m.ID, "name", m.Name, "version", m.Version)
|
|
}
|
|
|
|
dockerRT, err := agentruntime.NewDocker()
|
|
if err != nil {
|
|
logger.Error("docker runtime init", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
defer dockerRT.Close()
|
|
|
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer cancel()
|
|
|
|
if err := dockerRT.Ping(ctx); err != nil {
|
|
logger.Warn("docker daemon not reachable; instance commands will fail", "err", err)
|
|
} else {
|
|
logger.Info("docker runtime ready")
|
|
}
|
|
|
|
disp := dispatch.New(logger, registry, dockerRT, absDataRoot, absMetaDir, absBackupDir)
|
|
|
|
if err := disp.Rehydrate(ctx); err != nil {
|
|
logger.Warn("rehydrate failed (agent will start fresh)", "err", err)
|
|
}
|
|
|
|
// Reconnect loop: keep dialing the controller until ctx is cancelled.
|
|
// A session ends either because ctx was cancelled (operator signal)
|
|
// or because the stream died (controller blip, network, etc.).
|
|
backoff := 2 * time.Second
|
|
const maxBackoff = 30 * time.Second
|
|
for {
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
err := runSession(ctx, logger, *controllerAddr, *agentID, *heartbeatInterval, disp, tlsCreds)
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
logger.Warn("session ended, will reconnect", "err", err, "in", backoff)
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-time.After(backoff + jitter(backoff)):
|
|
}
|
|
if backoff < maxBackoff {
|
|
backoff *= 2
|
|
if backoff > maxBackoff {
|
|
backoff = maxBackoff
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.Info("operator signal — gracefully stopping running instances")
|
|
disp.Shutdown(context.Background())
|
|
}
|
|
|
|
// runSession opens one gRPC connection, handshakes, (re)announces current
|
|
// instance state, then runs the recv/send loops until the stream ends.
|
|
// Returns nil if the operator signalled shutdown, or an error if the
|
|
// connection broke.
|
|
func runSession(ctx context.Context, logger *slog.Logger, addr, agentID string, hb time.Duration, disp *dispatch.Dispatcher, tlsCreds credentials.TransportCredentials) error {
|
|
var dialOpt grpc.DialOption
|
|
if tlsCreds != nil {
|
|
dialOpt = grpc.WithTransportCredentials(tlsCreds)
|
|
} else {
|
|
dialOpt = grpc.WithTransportCredentials(insecure.NewCredentials())
|
|
}
|
|
// Lift the default 4 MiB per-message cap so big file uploads
|
|
// (FsWrite carrying mod zips, scenario archives, save backups)
|
|
// flow through end-to-end. Matches the controller-side cap.
|
|
const grpcMaxMsgSize = 5 * 1024 * 1024 * 1024
|
|
conn, err := grpc.NewClient(addr, dialOpt,
|
|
grpc.WithDefaultCallOptions(
|
|
grpc.MaxCallRecvMsgSize(grpcMaxMsgSize),
|
|
grpc.MaxCallSendMsgSize(grpcMaxMsgSize),
|
|
),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("grpc client: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
client := panelv1.NewAgentClient(conn)
|
|
|
|
logger.Info("connecting to controller", "addr", addr)
|
|
stream, err := client.Connect(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("open stream: %w", err)
|
|
}
|
|
|
|
hello := &panelv1.AgentEnvelope{
|
|
CorrelationId: "hello",
|
|
SentAt: timestamppb.Now(),
|
|
Payload: &panelv1.AgentEnvelope_Hello{
|
|
Hello: &panelv1.AgentHello{
|
|
AgentId: agentID,
|
|
AgentVersion: agentVersion,
|
|
HostOs: runtime.GOOS,
|
|
HostArch: runtime.GOARCH,
|
|
Hostname: hostname(),
|
|
SupportedRuntimes: []string{"docker"},
|
|
Modules: summarizeModules(disp),
|
|
},
|
|
},
|
|
}
|
|
if err := stream.Send(hello); err != nil {
|
|
return fmt.Errorf("send hello: %w", err)
|
|
}
|
|
|
|
resp, err := stream.Recv()
|
|
if err != nil {
|
|
return fmt.Errorf("recv hello: %w", err)
|
|
}
|
|
ch := resp.GetHello()
|
|
if ch == nil {
|
|
return fmt.Errorf("unexpected first message: %T", resp.Payload)
|
|
}
|
|
logger.Info("handshake complete", "controller_version", ch.ControllerVersion)
|
|
|
|
// Install the current stream as the upward sender and re-announce.
|
|
disp.SetSender(stream)
|
|
disp.Announce(ctx)
|
|
|
|
// Drop the sender before we return so no one sends into a dead stream.
|
|
defer disp.SetSender(nil)
|
|
|
|
recvDone := make(chan struct{})
|
|
recvErr := make(chan error, 1)
|
|
go func() {
|
|
defer close(recvDone)
|
|
for {
|
|
env, err := stream.Recv()
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
logger.Info("controller closed stream")
|
|
recvErr <- nil
|
|
} else if ctx.Err() == nil {
|
|
recvErr <- err
|
|
} else {
|
|
recvErr <- nil
|
|
}
|
|
return
|
|
}
|
|
// Dispatch each envelope in its OWN goroutine so a slow/blocked
|
|
// handler can't freeze the recv loop (and thus the whole agent).
|
|
// This was the Valheim+stdio hang: an RCON write to a wedged stdin
|
|
// pipe blocked here forever, so the subsequent stop never got read.
|
|
// Safe to parallelize: handlers guard shared state with d.mu, and the
|
|
// controller serializes dependent ops (create→start) via its own
|
|
// correlation-id awaits, so it never sends a dependent op before the
|
|
// prior reply arrives.
|
|
go disp.Handle(ctx, env)
|
|
}
|
|
}()
|
|
|
|
ticker := time.NewTicker(hb)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
_ = stream.CloseSend()
|
|
<-recvDone
|
|
return nil
|
|
case <-recvDone:
|
|
return <-recvErr
|
|
case t := <-ticker.C:
|
|
// Heartbeats go through the dispatcher's send queue (droppable)
|
|
// so sendLoop stays the sole owner of stream.Send — sending here
|
|
// directly raced the drainer goroutine on the same gRPC stream.
|
|
// A dead stream is detected by the recv goroutine (stream.Recv
|
|
// errors → recvDone), not by a failed heartbeat send.
|
|
disp.SendHeartbeat(t)
|
|
}
|
|
}
|
|
}
|
|
|
|
// jitter returns up to ±25% of d as a random offset, so N agents don't
|
|
// synchronize their reconnects against a restarting controller.
|
|
func jitter(d time.Duration) time.Duration {
|
|
if d <= 0 {
|
|
return 0
|
|
}
|
|
half := int64(d / 4)
|
|
return time.Duration(rand.Int64N(2*half+1) - half)
|
|
}
|
|
|
|
// summarizeModules reduces the agent's loaded manifests to the small
|
|
// ModuleSummary record the controller caches for UI picking.
|
|
func summarizeModules(disp *dispatch.Dispatcher) []*panelv1.ModuleSummary {
|
|
mods := disp.Modules().List()
|
|
out := make([]*panelv1.ModuleSummary, 0, len(mods))
|
|
for _, m := range mods {
|
|
providers := make([]*panelv1.ModuleUpdateProviderSummary, 0, len(m.UpdateProviders))
|
|
for _, p := range m.UpdateProviders {
|
|
providers = append(providers, &panelv1.ModuleUpdateProviderSummary{
|
|
Id: p.ID,
|
|
Kind: p.Kind,
|
|
RequiresSteamLogin: p.RequiresSteamLogin,
|
|
})
|
|
}
|
|
out = append(out, &panelv1.ModuleSummary{
|
|
Id: m.ID,
|
|
Name: m.Name,
|
|
Version: m.Version,
|
|
SupportedModes: m.SupportedModes,
|
|
UpdateProviders: providers,
|
|
HasRcon: m.RCON != nil,
|
|
Authors: m.Authors,
|
|
})
|
|
}
|
|
return out
|
|
}
|