feat: dynamic, scoped API keys (+ restore cmd/server entrypoint)
Validate X-API-Key against a DB-backed, self-managed key store in addition
to the static Infisical keys, so new consumers (e.g. gsc_admin) no longer
require a rebuild. Keys carry scopes (e.g. {ldap:read}); the required scope
is derived per-request from path + method and enforced by ScopeEnforce.
Static Infisical keys keep an implicit wildcard scope (no regression).
- service/apikey.go: DB store (admin.api_keys, SHA-256 hashes only), 30s
validation cache, generate/list/revoke. EnsureSchema is existence-first
(to_regclass) so a least-privilege DB role starts cleanly when the table
is provisioned out-of-band; startup is non-fatal if the store is absent.
- handler/apikeys.go + routes: POST/GET/DELETE /api/v1/admin/api-keys.
- middleware/apikey.go: APIKeyWithValidator + Principal + ScopeEnforce.
- pkg/types/scopes.go: scope vocabulary + matching.
- migrations/002_api_keys.sql.
Also restore cmd/server/main.go, which the `.gitignore` `server` pattern
was silently excluding (it matched cmd/server/); anchored that pattern and
`gsc-ops-api` to the repo root so only the built binaries are ignored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
338
cmd/server/main.go
Normal file
338
cmd/server/main.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/client"
|
||||
"github.com/gosec/gsc-ops-api/internal/config"
|
||||
"github.com/gosec/gsc-ops-api/internal/database"
|
||||
"github.com/gosec/gsc-ops-api/internal/handler"
|
||||
"github.com/gosec/gsc-ops-api/internal/router"
|
||||
"github.com/gosec/gsc-ops-api/internal/schema"
|
||||
"github.com/gosec/gsc-ops-api/internal/service"
|
||||
)
|
||||
|
||||
const (
|
||||
appName = "gsc-ops-api"
|
||||
appVersion = "1.0.0"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Logger
|
||||
logger := zerolog.New(os.Stdout).With().
|
||||
Timestamp().
|
||||
Str("app", appName).
|
||||
Str("version", appVersion).
|
||||
Logger()
|
||||
|
||||
// Load configuration
|
||||
configPath := os.Getenv("GSC_OPS_API_CONFIG")
|
||||
if configPath == "" {
|
||||
configPath = "/etc/gsc-ops-api/config.yaml"
|
||||
}
|
||||
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Failed to load config")
|
||||
}
|
||||
|
||||
// Set log level
|
||||
level, err := zerolog.ParseLevel(cfg.Logging.Level)
|
||||
if err == nil {
|
||||
zerolog.SetGlobalLevel(level)
|
||||
}
|
||||
|
||||
// Load secrets from Infisical
|
||||
if err := cfg.LoadSecretsFromInfisical(); err != nil {
|
||||
logger.Warn().Err(err).Msg("Failed to load secrets from Infisical")
|
||||
}
|
||||
|
||||
// Connect to databases
|
||||
var coreDB, pbxDB, voiceAgentDB, personaDB *database.DB
|
||||
|
||||
if cfg.HasDatabase("core") {
|
||||
coreDB, err = database.NewNamed(cfg, "core")
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Str("db", "core").Msg("Failed to connect to database")
|
||||
}
|
||||
defer coreDB.Close()
|
||||
logger.Info().Str("db", "gsc_core").Msg("Connected to database")
|
||||
}
|
||||
|
||||
if cfg.HasDatabase("pbx") {
|
||||
pbxDB, err = database.NewNamed(cfg, "pbx")
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Str("db", "pbx").Msg("Failed to connect to database")
|
||||
}
|
||||
defer pbxDB.Close()
|
||||
logger.Info().Str("db", "gsc_pbx").Msg("Connected to database")
|
||||
}
|
||||
|
||||
if cfg.HasDatabase("voice_agent") {
|
||||
voiceAgentDB, err = database.NewNamed(cfg, "voice_agent")
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Str("db", "voice_agent").Msg("Failed to connect to database")
|
||||
}
|
||||
defer voiceAgentDB.Close()
|
||||
logger.Info().Str("db", "gsc_voice_agent").Msg("Connected to database")
|
||||
}
|
||||
|
||||
if cfg.HasDatabase("persona") {
|
||||
personaDB, err = database.NewNamed(cfg, "persona")
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Str("db", "persona").Msg("Failed to connect to database")
|
||||
}
|
||||
defer personaDB.Close()
|
||||
logger.Info().Str("db", "gsc_persona").Msg("Connected to database")
|
||||
}
|
||||
|
||||
// Fallback to legacy single database if named databases not configured
|
||||
var db *database.DB
|
||||
if coreDB == nil || pbxDB == nil || voiceAgentDB == nil {
|
||||
db, err = database.New(cfg)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Failed to connect to legacy database")
|
||||
}
|
||||
defer db.Close()
|
||||
logger.Info().Msg("Connected to legacy database (gsc_admin)")
|
||||
if coreDB == nil {
|
||||
coreDB = db
|
||||
}
|
||||
if pbxDB == nil {
|
||||
pbxDB = db
|
||||
}
|
||||
if voiceAgentDB == nil {
|
||||
voiceAgentDB = db
|
||||
}
|
||||
} else {
|
||||
// Use coreDB as the primary for health checks
|
||||
db = coreDB
|
||||
}
|
||||
|
||||
// Initialize LDAP client
|
||||
var ldapClient *client.LDAPClient
|
||||
if len(cfg.LDAP.Servers) > 0 {
|
||||
ldapClient, err = client.NewLDAPClient(cfg.LDAP, logger)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Failed to create LDAP client")
|
||||
}
|
||||
defer ldapClient.Close()
|
||||
logger.Info().Int("poolSize", cfg.LDAP.PoolSize).Msg("Connected to LDAP")
|
||||
}
|
||||
|
||||
// Initialize PowerDNS client
|
||||
var pdnsClient *client.PowerDNSClient
|
||||
if cfg.PowerDNS.BaseURL != "" {
|
||||
pdnsClient = client.NewPowerDNSClient(cfg.PowerDNS, logger)
|
||||
logger.Info().Str("url", cfg.PowerDNS.BaseURL).Msg("PowerDNS client initialized")
|
||||
}
|
||||
|
||||
// Initialize EJBCA client
|
||||
var ejbcaClient *client.EJBCAClient
|
||||
if cfg.EJBCA.BaseURL != "" {
|
||||
ejbcaClient, err = client.NewEJBCAClient(cfg.EJBCA, logger)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("Failed to create EJBCA client (certificates disabled)")
|
||||
} else {
|
||||
logger.Info().Str("url", cfg.EJBCA.BaseURL).Msg("EJBCA client initialized")
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Hockeypuck client
|
||||
var hkpClient *client.HockeypuckClient
|
||||
if len(cfg.Hockeypuck.Servers) > 0 {
|
||||
hkpClient = client.NewHockeypuckClient(cfg.Hockeypuck, logger)
|
||||
logger.Info().Int("servers", len(cfg.Hockeypuck.Servers)).Msg("Hockeypuck client initialized")
|
||||
}
|
||||
|
||||
// Initialize CardDAV client
|
||||
var carddavClient *client.CardDAVClient
|
||||
if cfg.CardDAV.Host != "" {
|
||||
carddavClient, err = client.NewCardDAVClient(cfg.CardDAV, cfg.CardDAVDSN(), logger)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("Failed to create CardDAV client (carddav disabled)")
|
||||
} else {
|
||||
defer carddavClient.Close()
|
||||
logger.Info().Str("host", cfg.CardDAV.Host).Str("database", cfg.CardDAV.Database).Msg("CardDAV client initialized")
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Asterisk AMI client
|
||||
var asteriskClient *client.AsteriskClient
|
||||
if len(cfg.Asterisk.Servers) > 0 {
|
||||
astServers := make([]client.AsteriskServer, len(cfg.Asterisk.Servers))
|
||||
for i, s := range cfg.Asterisk.Servers {
|
||||
astServers[i] = client.AsteriskServer{Host: s.Host, AMIPort: s.AMIPort}
|
||||
}
|
||||
asteriskClient = client.NewAsteriskClient(astServers, cfg.Asterisk.AMIUser, cfg.Asterisk.AMISecret, logger)
|
||||
logger.Info().Int("servers", len(cfg.Asterisk.Servers)).Msg("Asterisk AMI client initialized")
|
||||
}
|
||||
|
||||
// Initialize Kamailio client
|
||||
var kamailioClient *client.KamailioClient
|
||||
if len(cfg.Kamailio.Servers) > 0 {
|
||||
var sshKey []byte
|
||||
if cfg.Kamailio.SSHKey != "" {
|
||||
sshKey, _ = os.ReadFile(cfg.Kamailio.SSHKey)
|
||||
}
|
||||
kamailioClient = client.NewKamailioClient(cfg.Kamailio.Servers, cfg.Kamailio.SSHUser, sshKey, logger)
|
||||
logger.Info().Int("servers", len(cfg.Kamailio.Servers)).Msg("Kamailio SSH client initialized")
|
||||
}
|
||||
|
||||
// Create schema registry
|
||||
registry := schema.NewRegistry()
|
||||
logger.Info().Int("attrs", len(registry.AllUserAttrs())).Int("entities", len(registry.AllEntityTypes())).Msg("Schema registry initialized")
|
||||
|
||||
// Create services
|
||||
ldapSvc := service.NewLDAPService(ldapClient, cfg.LDAP.BaseDN, logger, registry)
|
||||
ldapEntitySvc := service.NewLDAPEntityService(ldapClient, cfg.LDAP.BaseDN, registry, logger)
|
||||
dnsSvc := service.NewDNSService(pdnsClient, logger)
|
||||
dbSvc := service.NewDatabaseService(coreDB.Pool(), logger)
|
||||
certSvc := service.NewCertificateService(ejbcaClient, logger)
|
||||
pgpSvc := service.NewPGPService(hkpClient, logger)
|
||||
|
||||
var carddavSvc *service.CardDAVService
|
||||
if carddavClient != nil {
|
||||
carddavSvc = service.NewCardDAVService(carddavClient.Pool(), logger)
|
||||
}
|
||||
|
||||
pbxSvc := service.NewPBXService(pbxDB.Pool(), asteriskClient, kamailioClient, logger)
|
||||
voiceAgentSvc := service.NewVoiceAgentService(voiceAgentDB.Pool(), logger)
|
||||
personalAgentSvc := service.NewPersonalAgentService(coreDB.Pool(), logger)
|
||||
|
||||
var personaSvc *service.PersonaService
|
||||
if personaDB != nil {
|
||||
personaSvc = service.NewPersonaService(personaDB.Pool(), logger)
|
||||
}
|
||||
|
||||
// Dynamic API-key store (self-managed keys, validated alongside the static
|
||||
// keys loaded from Infisical). Backed by the core DB. A failure here is
|
||||
// non-fatal: the service stays up on the static keys and picks up dynamic
|
||||
// keys once the table is reachable (the cache reloads periodically).
|
||||
apiKeySvc := service.NewAPIKeyService(coreDB.Pool(), logger)
|
||||
if err := apiKeySvc.EnsureSchema(context.Background()); err != nil {
|
||||
logger.Error().Err(err).Msg("API-key store unavailable — dynamic keys disabled until the table is provisioned")
|
||||
} else {
|
||||
logger.Info().Msg("Dynamic API-key store initialized")
|
||||
}
|
||||
|
||||
// Create handlers
|
||||
healthHandler := handler.NewHealthHandler(db, ldapClient, pdnsClient, carddavClient)
|
||||
ldapUserHandler := handler.NewLDAPUserHandler(ldapSvc)
|
||||
ldapGroupHandler := handler.NewLDAPGroupHandler(ldapSvc)
|
||||
ldapEntityHandler := handler.NewLDAPEntityHandler(ldapEntitySvc, registry)
|
||||
dnsZoneHandler := handler.NewDNSZoneHandler(dnsSvc)
|
||||
dnsRecordHandler := handler.NewDNSRecordHandler(dnsSvc)
|
||||
dbTenantHandler := handler.NewDBTenantHandler(dbSvc)
|
||||
dbUserHandler := handler.NewDBUserHandler(dbSvc)
|
||||
certHandler := handler.NewCertHandler(certSvc)
|
||||
pgpHandler := handler.NewPGPHandler(pgpSvc)
|
||||
|
||||
var carddavHandler *handler.CardDAVHandler
|
||||
if carddavSvc != nil {
|
||||
carddavHandler = handler.NewCardDAVHandler(carddavSvc)
|
||||
}
|
||||
|
||||
pbxHandler := handler.NewPBXHandler(pbxSvc)
|
||||
voiceAgentHandler := handler.NewVoiceAgentHandler(voiceAgentSvc)
|
||||
personalAgentHandler := handler.NewPersonalAgentHandler(personalAgentSvc)
|
||||
|
||||
var personaHandler *handler.PersonaHandler
|
||||
if personaSvc != nil {
|
||||
personaHandler = handler.NewPersonaHandler(personaSvc)
|
||||
}
|
||||
|
||||
apiKeyHandler := handler.NewAPIKeyHandler(apiKeySvc)
|
||||
|
||||
// Create Fiber app
|
||||
app := fiber.New(fiber.Config{
|
||||
ReadTimeout: cfg.Server.ReadTimeout,
|
||||
WriteTimeout: cfg.Server.WriteTimeout,
|
||||
AppName: appName,
|
||||
})
|
||||
|
||||
// Setup routes
|
||||
router.Setup(app, &router.Config{
|
||||
Logger: logger,
|
||||
APIKeys: cfg.Auth.APIKeys,
|
||||
APIKeyValidate: apiKeySvc.Validate,
|
||||
APIKeysAdmin: apiKeyHandler,
|
||||
Health: healthHandler,
|
||||
LDAPUsers: ldapUserHandler,
|
||||
LDAPGroups: ldapGroupHandler,
|
||||
LDAPEntities: ldapEntityHandler,
|
||||
DNSZones: dnsZoneHandler,
|
||||
DNSRecords: dnsRecordHandler,
|
||||
DBTenants: dbTenantHandler,
|
||||
DBUsers: dbUserHandler,
|
||||
Certs: certHandler,
|
||||
PGP: pgpHandler,
|
||||
CardDAV: carddavHandler,
|
||||
PBX: pbxHandler,
|
||||
VoiceAgent: voiceAgentHandler,
|
||||
PersonalAgent: personalAgentHandler,
|
||||
Persona: personaHandler,
|
||||
})
|
||||
|
||||
// Load TLS config
|
||||
tlsConfig, err := loadTLSConfig(cfg)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Failed to load TLS config")
|
||||
}
|
||||
|
||||
// Start server
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||
logger.Info().Str("addr", addr).Msg("Starting server")
|
||||
|
||||
// Graceful shutdown
|
||||
go func() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
sig := <-sigCh
|
||||
logger.Info().Str("signal", sig.String()).Msg("Shutting down")
|
||||
app.Shutdown()
|
||||
}()
|
||||
|
||||
// Start HTTPS with mTLS
|
||||
ln, err := tls.Listen("tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Failed to create TLS listener")
|
||||
}
|
||||
|
||||
if err := app.Listener(ln); err != nil {
|
||||
logger.Fatal().Err(err).Msg("Server error")
|
||||
}
|
||||
}
|
||||
|
||||
func loadTLSConfig(cfg *config.Config) (*tls.Config, error) {
|
||||
cert, err := tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load server certificate: %w", err)
|
||||
}
|
||||
|
||||
caCert, err := os.ReadFile(cfg.TLS.CAFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read CA certificate: %w", err)
|
||||
}
|
||||
|
||||
caCertPool := x509.NewCertPool()
|
||||
if !caCertPool.AppendCertsFromPEM(caCert) {
|
||||
return nil, fmt.Errorf("failed to parse CA certificate")
|
||||
}
|
||||
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
ClientCAs: caCertPool,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user