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>
262 lines
8.2 KiB
Go
262 lines
8.2 KiB
Go
// Package ca implements the Controller's internal Certificate Authority:
|
|
// the root-of-trust that signs both the Controller's own server cert and
|
|
// every Agent's client cert during the pairing flow.
|
|
//
|
|
// Files on disk under caDir:
|
|
//
|
|
// ca.crt — self-signed root CA certificate (PEM)
|
|
// ca.key — CA private key (PEM, mode 0600)
|
|
// controller.crt — Controller's server cert, signed by ca.key (PEM)
|
|
// controller.key — Controller's server key (PEM, mode 0600)
|
|
//
|
|
// Bootstrapping is idempotent: on first start we generate both pairs; on
|
|
// every subsequent start we load them. Rotate by deleting the specific
|
|
// file(s) you want regenerated and restarting.
|
|
package ca
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
rsaBits = 2048
|
|
caValidity = 10 * 365 * 24 * time.Hour
|
|
serverValidity = 365 * 24 * time.Hour
|
|
agentValidity = 365 * 24 * time.Hour
|
|
caCommonName = "panel Internal CA"
|
|
serverCommonName = "panel controller"
|
|
)
|
|
|
|
// CA wraps the loaded root cert/key plus the Controller's server cert/key.
|
|
type CA struct {
|
|
Root *x509.Certificate
|
|
RootKey *rsa.PrivateKey
|
|
RootPEM []byte // cached ca.crt bytes
|
|
Server *x509.Certificate
|
|
ServerKey *rsa.PrivateKey
|
|
}
|
|
|
|
// Ensure loads (or initializes) the CA + server cert under dir. If
|
|
// hostnames is non-empty, it's added as SANs on the server cert (e.g.
|
|
// "mypanel.example.com"). localhost + 127.0.0.1 + the machine's
|
|
// hostname are always included.
|
|
func Ensure(dir string, extraHostnames []string) (*CA, error) {
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return nil, fmt.Errorf("mkdir %s: %w", dir, err)
|
|
}
|
|
rootCert, rootKey, rootPEM, err := ensureRoot(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
serverCert, serverKey, err := ensureServer(dir, rootCert, rootKey, extraHostnames)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &CA{
|
|
Root: rootCert, RootKey: rootKey, RootPEM: rootPEM,
|
|
Server: serverCert, ServerKey: serverKey,
|
|
}, nil
|
|
}
|
|
|
|
func ensureRoot(dir string) (*x509.Certificate, *rsa.PrivateKey, []byte, error) {
|
|
certPath := filepath.Join(dir, "ca.crt")
|
|
keyPath := filepath.Join(dir, "ca.key")
|
|
if _, err := os.Stat(certPath); err == nil {
|
|
cert, pemBytes, err := loadCert(certPath)
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("load ca.crt: %w", err)
|
|
}
|
|
key, err := loadKey(keyPath)
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("load ca.key: %w", err)
|
|
}
|
|
return cert, key, pemBytes, nil
|
|
}
|
|
|
|
priv, err := rsa.GenerateKey(rand.Reader, rsaBits)
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("rsa key: %w", err)
|
|
}
|
|
tpl := &x509.Certificate{
|
|
SerialNumber: randomSerial(),
|
|
Subject: pkix.Name{CommonName: caCommonName, Organization: []string{"panel"}},
|
|
NotBefore: time.Now().Add(-1 * time.Minute),
|
|
NotAfter: time.Now().Add(caValidity),
|
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
|
BasicConstraintsValid: true,
|
|
IsCA: true,
|
|
MaxPathLenZero: false,
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("create ca cert: %w", err)
|
|
}
|
|
cert, err := x509.ParseCertificate(der)
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("parse ca cert: %w", err)
|
|
}
|
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
|
|
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
return cert, priv, certPEM, nil
|
|
}
|
|
|
|
func ensureServer(dir string, root *x509.Certificate, rootKey *rsa.PrivateKey, extraHostnames []string) (*x509.Certificate, *rsa.PrivateKey, error) {
|
|
certPath := filepath.Join(dir, "controller.crt")
|
|
keyPath := filepath.Join(dir, "controller.key")
|
|
if _, err := os.Stat(certPath); err == nil {
|
|
cert, _, err := loadCert(certPath)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("load controller.crt: %w", err)
|
|
}
|
|
key, err := loadKey(keyPath)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("load controller.key: %w", err)
|
|
}
|
|
return cert, key, nil
|
|
}
|
|
priv, err := rsa.GenerateKey(rand.Reader, rsaBits)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
host, _ := os.Hostname()
|
|
ips := []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}
|
|
dns := []string{"localhost", host}
|
|
// Split extraHostnames into DNS vs IP — TLS validates IP dials against
|
|
// IPAddresses, not DNSNames, so any LAN IP an agent will dial must
|
|
// land in the IP set.
|
|
for _, h := range extraHostnames {
|
|
if ip := net.ParseIP(h); ip != nil {
|
|
ips = append(ips, ip)
|
|
} else {
|
|
dns = append(dns, h)
|
|
}
|
|
}
|
|
dns = uniq(dns)
|
|
|
|
tpl := &x509.Certificate{
|
|
SerialNumber: randomSerial(),
|
|
Subject: pkix.Name{CommonName: serverCommonName, Organization: []string{"panel"}},
|
|
NotBefore: time.Now().Add(-1 * time.Minute),
|
|
NotAfter: time.Now().Add(serverValidity),
|
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
DNSNames: dns,
|
|
IPAddresses: ips,
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, tpl, root, &priv.PublicKey, rootKey)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
cert, err := x509.ParseCertificate(der)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}), 0o600); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return cert, priv, nil
|
|
}
|
|
|
|
// SignAgentCSR validates a CSR from a pairing agent and returns the
|
|
// signed cert as PEM. The agent's CN becomes its identity. The cert
|
|
// is marked ExtKeyUsageClientAuth (mTLS client only).
|
|
func (c *CA) SignAgentCSR(csrPEM []byte, agentID string) (certPEM []byte, err error) {
|
|
block, _ := pem.Decode(csrPEM)
|
|
if block == nil || block.Type != "CERTIFICATE REQUEST" {
|
|
return nil, errors.New("bad CSR PEM")
|
|
}
|
|
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse CSR: %w", err)
|
|
}
|
|
if err := csr.CheckSignature(); err != nil {
|
|
return nil, fmt.Errorf("CSR signature: %w", err)
|
|
}
|
|
// Force our agent_id into CN + DNS SANs regardless of what the CSR asked for.
|
|
// (This is the panel's canonical identity for the cert; the agent can't
|
|
// claim to be someone else.)
|
|
tpl := &x509.Certificate{
|
|
SerialNumber: randomSerial(),
|
|
Subject: pkix.Name{CommonName: agentID, Organization: []string{"panel-agent"}},
|
|
NotBefore: time.Now().Add(-1 * time.Minute),
|
|
NotAfter: time.Now().Add(agentValidity),
|
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
|
DNSNames: []string{agentID},
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, tpl, c.Root, csr.PublicKey, c.RootKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sign: %w", err)
|
|
}
|
|
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), nil
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
func randomSerial() *big.Int {
|
|
max := new(big.Int).Lsh(big.NewInt(1), 128)
|
|
n, _ := rand.Int(rand.Reader, max)
|
|
return n
|
|
}
|
|
|
|
func loadCert(path string) (*x509.Certificate, []byte, error) {
|
|
body, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
block, _ := pem.Decode(body)
|
|
if block == nil {
|
|
return nil, nil, errors.New("no PEM block")
|
|
}
|
|
cert, err := x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return cert, body, nil
|
|
}
|
|
|
|
func loadKey(path string) (*rsa.PrivateKey, error) {
|
|
body, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
block, _ := pem.Decode(body)
|
|
if block == nil {
|
|
return nil, errors.New("no PEM block")
|
|
}
|
|
return x509.ParsePKCS1PrivateKey(block.Bytes)
|
|
}
|
|
|
|
func uniq(in []string) []string {
|
|
seen := map[string]bool{}
|
|
out := make([]string, 0, len(in))
|
|
for _, s := range in {
|
|
if s == "" || seen[s] {
|
|
continue
|
|
}
|
|
seen[s] = true
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|