a00bd620a1
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>
214 lines
6.3 KiB
Go
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
|