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>
339 lines
11 KiB
Go
339 lines
11 KiB
Go
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
|
|
}
|