Files
panel/controller/cmd/controller/pair.go
T
dbledeez a00bd620a1 Panel — public source drop (v0.9.0)
Self-hostable game-server control panel: controller + agent + 26 game
modules. One-line install (prebuilt release, no Go required):

  curl -fsSL https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.sh | sudo bash

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 21:17:39 -07:00

214 lines
6.3 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)
if err := p.db.CreatePairToken(r.Context(), db.PairTokenRow{
ID: id,
TokenHash: sha256Hex(raw),
Description: req.Description,
CreatedBy: user.ID,
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