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>
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user