Files
panel/controller/cmd/controller/pair.go
T
dbledeez 4ccccc6fe2 panel v0.9.2 — public release
Self-hostable game server control panel: Go controller + agent, 26 game
modules, embedded web UI. One-line install via install.sh / install.ps1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 00:43:35 -07:00

227 lines
6.8 KiB
Go

package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/dbledeez/panel/controller/internal/ca"
"github.com/dbledeez/panel/controller/internal/db"
)
// pairService hosts the HTTP endpoints agents use to bootstrap mTLS.
// Two public (no session auth) + one session-auth'd admin endpoint:
//
// GET /api/pair/ca public — returns the CA cert PEM so the
// agent can pin it before completing pair
// POST /api/pair/issue public — exchanges {token, agent_id, csr}
// for a signed client cert
// POST /api/admin/pair-tokens auth'd — operator creates a new token
type pairService struct {
ca *ca.CA
db *db.DB
}
func (p *pairService) getCA(w http.ResponseWriter, _ *http.Request) {
if p.ca == nil {
writeError(w, http.StatusServiceUnavailable, "no_ca", "controller has no CA configured (run with --ca-dir)")
return
}
w.Header().Set("Content-Type", "application/x-pem-file")
_, _ = w.Write(p.ca.RootPEM)
}
type issueReq struct {
Token string `json:"token"`
AgentID string `json:"agent_id"`
CSRPEM string `json:"csr_pem"`
}
type issueResp struct {
CertPEM string `json:"cert_pem"`
CAPEM string `json:"ca_pem"`
Subject string `json:"subject"`
}
func (p *pairService) issue(w http.ResponseWriter, r *http.Request) {
if p.ca == nil {
writeError(w, http.StatusServiceUnavailable, "no_ca", "controller has no CA configured")
return
}
var req issueReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if req.Token == "" || req.AgentID == "" || req.CSRPEM == "" {
writeError(w, http.StatusBadRequest, "missing", "token, agent_id, and csr_pem are required")
return
}
hash := sha256Hex(req.Token)
// Pre-validate the CSR before consuming the token so a bad CSR
// doesn't burn the one-time-use token.
cert, err := p.ca.SignAgentCSR([]byte(req.CSRPEM), req.AgentID)
if err != nil {
writeError(w, http.StatusBadRequest, "csr", err.Error())
return
}
fingerprint := certFingerprint(cert)
if _, err := p.db.ConsumePairToken(r.Context(), hash, req.AgentID, fingerprint); err != nil {
if errors.Is(err, db.ErrPairTokenNotFound) {
writeError(w, http.StatusUnauthorized, "invalid_token", "token invalid, expired, or already used")
return
}
writeError(w, http.StatusInternalServerError, "db", err.Error())
return
}
writeJSON(w, http.StatusOK, issueResp{
CertPEM: string(cert),
CAPEM: string(p.ca.RootPEM),
Subject: req.AgentID,
})
}
type createTokenReq struct {
Description string `json:"description,omitempty"`
ExpiresMinutes int `json:"expires_minutes,omitempty"` // default 15
}
type createTokenResp struct {
ID string `json:"id"`
Token string `json:"token"` // shown once
ExpiresAt string `json:"expires_at"`
Description string `json:"description"`
}
// createToken: operator-invoked (auth required), generates a raw token
// + stores hash. Returns the raw token exactly once — operator must copy
// it immediately. Token string is never shown or recovered again.
func (p *pairService) createToken(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthenticated", "login required")
return
}
if user.Role != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
var req createTokenReq
_ = json.NewDecoder(r.Body).Decode(&req)
ttl := time.Duration(req.ExpiresMinutes) * time.Minute
if ttl <= 0 {
ttl = 15 * time.Minute
}
raw, err := randomToken(24)
if err != nil {
writeError(w, http.StatusInternalServerError, "rand", err.Error())
return
}
id := "pt_" + randHex(8)
// created_by is a NOT NULL FK -> users(id). The synthetic service-admin
// identity (loopback / admin-token header callers, see serviceAdminUser)
// has ID 0, which no real user row has, so inserting it fails the FK with
// a 500. Resolve such callers to the first real admin so the audit row
// still points at a genuine account. If there is somehow no admin, fall
// back to the raw ID and let the DB surface the error.
createdBy := user.ID
if createdBy == 0 {
if admin, err := p.db.GetFirstAdmin(r.Context()); err == nil {
createdBy = admin.ID
}
}
if err := p.db.CreatePairToken(r.Context(), db.PairTokenRow{
ID: id,
TokenHash: sha256Hex(raw),
Description: req.Description,
CreatedBy: createdBy,
ExpiresAt: time.Now().Add(ttl),
}); err != nil {
writeError(w, http.StatusInternalServerError, "db", err.Error())
return
}
writeJSON(w, http.StatusCreated, createTokenResp{
ID: id,
Token: raw,
ExpiresAt: time.Now().Add(ttl).Format(time.RFC3339),
Description: req.Description,
})
}
// ---- helpers ----
func sha256Hex(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])
}
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
// URL-safe, no padding, easy to copy/paste.
return hex.EncodeToString(b), nil
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// certFingerprint returns a sha256 hex digest of the DER-encoded cert,
// suitable for identifying which cert was issued against which token.
func certFingerprint(certPEM []byte) string {
// Cheap approximation — hash the PEM bytes. Operators just need
// a stable value to link a token to a cert; not a security property.
return sha256Hex(string(certPEM))
}
// listPairTokens is operator-visible audit of generated tokens.
func (p *pairService) listTokens(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow)
if !ok || user.Role != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
rows, err := p.db.ListPairTokens(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "db", err.Error())
return
}
type dto struct {
ID string `json:"id"`
Description string `json:"description"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
UsedAt string `json:"used_at,omitempty"`
UsedByAgent string `json:"used_by_agent,omitempty"`
}
out := make([]dto, 0, len(rows))
for _, r := range rows {
d := dto{
ID: r.ID, Description: r.Description,
CreatedAt: r.CreatedAt.Format(time.RFC3339),
ExpiresAt: r.ExpiresAt.Format(time.RFC3339),
UsedByAgent: r.UsedByAgent,
}
if r.UsedAt != nil {
d.UsedAt = r.UsedAt.Format(time.RFC3339)
}
out = append(out, d)
}
writeJSON(w, http.StatusOK, map[string]any{"pair_tokens": out})
}
// Ensure we reference fmt to keep imports stable for future error fmtting.
var _ = fmt.Sprintf