Initial import — snapshot from admin host /srv/gosec/gsc-ops-api

This repo had no version control prior to this commit. The import is a
straight snapshot of the working tree at 2026-05-03; the deployed
binary on fihelvop01 was being rebuilt from this source via `make
build` + scp into place, with no upstream review path.

The snapshot already includes one in-flight fix made on 2026-05-03 to
internal/service/persona.go:GetSelfModel — the handler queried
`source` and `strength` columns plus an `is_active = true` filter on
persona.persona_commitments, none of which exist on that table (its
shape is session-bound commitments with `status`, `commitment_meta`,
etc.). The query returned a 500 every time SynapseHub bootstrapped a
persona's self-model, dropping the IdentityConstraints / Commitments /
ConscienceStandards layer from the assembled prompt. The patched
query reads existing columns only (commitment_text, commitment_type),
filters on `status='active'`, and synthesises Source="learned" /
Strength=1.0 to keep the SelfModel response shape stable for callers.

Verified live: `GET /api/v1/personas/70f7cfd9-.../self-model` now
returns 200 with `{identityConstraints:[],commitments:[],
conscienceStandards:[]}` instead of 500.

Future changes go through PRs against this repo — no more bin-only
deploys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude (gsc-ops-api init)
2026-05-03 20:06:02 +02:00
commit 3847eb2036
68 changed files with 12982 additions and 0 deletions

191
internal/client/asterisk.go Normal file
View File

@@ -0,0 +1,191 @@
package client
import (
"bufio"
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/rs/zerolog"
)
// AsteriskServer defines an Asterisk AMI endpoint
type AsteriskServer struct {
Host string `yaml:"host"`
AMIPort int `yaml:"amiPort"`
}
// AsteriskClient manages AMI connections to Asterisk servers
type AsteriskClient struct {
servers []AsteriskServer
user string
secret string
logger zerolog.Logger
}
// NewAsteriskClient creates a new Asterisk AMI client
func NewAsteriskClient(servers []AsteriskServer, user, secret string, logger zerolog.Logger) *AsteriskClient {
return &AsteriskClient{
servers: servers,
user: user,
secret: secret,
logger: logger.With().Str("client", "asterisk").Logger(),
}
}
// ReloadPJSIP sends a PJSIP reload command to all Asterisk servers
func (c *AsteriskClient) ReloadPJSIP() []ServerResult {
return c.runOnAll("pjsip reload")
}
// ReloadDialplan sends a dialplan reload to all Asterisk servers
func (c *AsteriskClient) ReloadDialplan() []ServerResult {
return c.runOnAll("dialplan reload")
}
// ReloadAll reloads both PJSIP and dialplan on all servers
func (c *AsteriskClient) ReloadAll() []ServerResult {
results := c.runOnAll("core reload")
return results
}
// GetChannelCount returns active channel count from each server
func (c *AsteriskClient) GetChannelCount() []ServerResult {
return c.runOnAll("core show channels count")
}
// GetUptime returns uptime from each server
func (c *AsteriskClient) GetUptime() []ServerResult {
return c.runOnAll("core show uptime")
}
// ServerResult holds the result from an AMI command on a single server
type ServerResult struct {
Host string `json:"host"`
Success bool `json:"success"`
Output string `json:"output"`
Error string `json:"error,omitempty"`
}
// runOnAll executes an AMI command on all Asterisk servers in parallel
func (c *AsteriskClient) runOnAll(command string) []ServerResult {
var wg sync.WaitGroup
results := make([]ServerResult, len(c.servers))
for i, srv := range c.servers {
wg.Add(1)
go func(idx int, server AsteriskServer) {
defer wg.Done()
results[idx] = c.execAMI(server, command)
}(i, srv)
}
wg.Wait()
return results
}
// execAMI connects to a single Asterisk AMI server and executes a command
func (c *AsteriskClient) execAMI(server AsteriskServer, command string) ServerResult {
addr := fmt.Sprintf("%s:%d", server.Host, server.AMIPort)
result := ServerResult{Host: server.Host}
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil {
result.Error = fmt.Sprintf("connection failed: %v", err)
c.logger.Warn().Str("host", server.Host).Err(err).Msg("AMI connection failed")
return result
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(10 * time.Second))
reader := bufio.NewReader(conn)
// Read AMI banner
banner, err := reader.ReadString('\n')
if err != nil {
result.Error = fmt.Sprintf("failed to read banner: %v", err)
return result
}
if !strings.HasPrefix(banner, "Asterisk Call Manager") {
result.Error = fmt.Sprintf("unexpected banner: %s", strings.TrimSpace(banner))
return result
}
// Login
loginMsg := fmt.Sprintf("Action: Login\r\nUsername: %s\r\nSecret: %s\r\n\r\n",
c.user, c.secret)
if _, err := conn.Write([]byte(loginMsg)); err != nil {
result.Error = fmt.Sprintf("login write failed: %v", err)
return result
}
// Read login response
loginResp, err := readAMIResponse(reader)
if err != nil {
result.Error = fmt.Sprintf("login response failed: %v", err)
return result
}
if !strings.Contains(loginResp, "Success") {
result.Error = fmt.Sprintf("login failed: %s", loginResp)
return result
}
// Execute command
cmdMsg := fmt.Sprintf("Action: Command\r\nCommand: %s\r\n\r\n", command)
if _, err := conn.Write([]byte(cmdMsg)); err != nil {
result.Error = fmt.Sprintf("command write failed: %v", err)
return result
}
// Read command response
cmdResp, err := readAMIResponse(reader)
if err != nil {
result.Error = fmt.Sprintf("command response failed: %v", err)
return result
}
// Logoff
conn.Write([]byte("Action: Logoff\r\n\r\n"))
result.Success = true
result.Output = cmdResp
c.logger.Debug().Str("host", server.Host).Str("command", command).Msg("AMI command executed")
return result
}
// readAMIResponse reads a complete AMI response (terminated by blank line)
func readAMIResponse(reader *bufio.Reader) (string, error) {
var lines []string
for {
line, err := reader.ReadString('\n')
if err != nil {
return strings.Join(lines, "\n"), err
}
trimmed := strings.TrimSpace(line)
if trimmed == "" {
break
}
lines = append(lines, trimmed)
}
return strings.Join(lines, "\n"), nil
}
// Health checks connectivity to all Asterisk servers
func (c *AsteriskClient) Health() error {
for _, srv := range c.servers {
addr := fmt.Sprintf("%s:%d", srv.Host, srv.AMIPort)
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
return fmt.Errorf("asterisk %s unreachable: %w", srv.Host, err)
}
conn.Close()
}
return nil
}
// Servers returns the configured server list
func (c *AsteriskClient) Servers() []AsteriskServer {
return c.servers
}

View File

@@ -0,0 +1,67 @@
package client
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/internal/config"
)
// CardDAVClient wraps a pgx connection pool for the sabredav database
type CardDAVClient struct {
pool *pgxpool.Pool
logger zerolog.Logger
}
// NewCardDAVClient creates a new CardDAV database client
func NewCardDAVClient(cfg config.CardDAVConfig, dsn string, logger zerolog.Logger) (*CardDAVClient, error) {
poolConfig, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("failed to parse carddav database config: %w", err)
}
poolConfig.MaxConns = 10
poolConfig.MinConns = 2
poolConfig.MaxConnLifetime = 1 * time.Hour
poolConfig.MaxConnIdleTime = 30 * time.Minute
poolConfig.HealthCheckPeriod = 1 * time.Minute
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
if err != nil {
return nil, fmt.Errorf("failed to create carddav connection pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("failed to ping carddav database: %w", err)
}
return &CardDAVClient{
pool: pool,
logger: logger.With().Str("client", "carddav").Logger(),
}, nil
}
// Pool returns the underlying connection pool
func (c *CardDAVClient) Pool() *pgxpool.Pool {
return c.pool
}
// Health checks the database connection
func (c *CardDAVClient) Health(ctx context.Context) error {
return c.pool.Ping(ctx)
}
// Close closes the connection pool
func (c *CardDAVClient) Close() {
if c.pool != nil {
c.pool.Close()
}
}

188
internal/client/ejbca.go Normal file
View File

@@ -0,0 +1,188 @@
package client
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/internal/config"
)
// EJBCAClient is an mTLS HTTP client for the EJBCA REST API
type EJBCAClient struct {
baseURL string
client *http.Client
logger zerolog.Logger
}
// NewEJBCAClient creates a new EJBCA client with mTLS
func NewEJBCAClient(cfg config.EJBCAConfig, logger zerolog.Logger) (*EJBCAClient, error) {
cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile)
if err != nil {
return nil, fmt.Errorf("failed to load EJBCA client cert: %w", err)
}
tlsCfg := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
}
if cfg.CAFile != "" {
caCert, err := os.ReadFile(cfg.CAFile)
if err != nil {
return nil, fmt.Errorf("failed to read EJBCA CA file: %w", err)
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caCert)
tlsCfg.RootCAs = pool
}
return &EJBCAClient{
baseURL: cfg.BaseURL,
client: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{TLSClientConfig: tlsCfg},
},
logger: logger.With().Str("component", "ejbca").Logger(),
}, nil
}
// EJBCACert represents a certificate from EJBCA
type EJBCACert struct {
SerialNumber string `json:"serial_number"`
SubjectDN string `json:"subject_dn"`
IssuerDN string `json:"issuer_dn"`
Status string `json:"status"`
NotBefore string `json:"not_before"`
NotAfter string `json:"not_after"`
CertificateData string `json:"certificate"`
CAName string `json:"ca_name,omitempty"`
}
// CertSearchRequest is the request body for searching certificates
type CertSearchRequest struct {
MaxResults int `json:"max_number_of_results"`
Criteria []CertSearchCriterion `json:"criteria"`
}
// CertSearchCriterion is a single search criterion
type CertSearchCriterion struct {
Property string `json:"property"`
Value string `json:"value"`
Operation string `json:"operation"`
}
// CertEnrollRequest is the request body for enrolling a certificate
type CertEnrollRequest struct {
CertificateRequest string `json:"certificate_request,omitempty"`
CertificateProfileName string `json:"certificate_profile_name"`
EndEntityProfileName string `json:"end_entity_profile_name"`
CAName string `json:"certificate_authority_name"`
Username string `json:"username"`
Password string `json:"password"`
IncludeChain bool `json:"include_chain"`
SubjectAltName string `json:"subject_alternative_name,omitempty"`
}
// CertRevokeRequest is the request body for revoking a certificate
type CertRevokeRequest struct {
IssuerDN string `json:"issuer_dn"`
SerialNumber string `json:"serial_number"`
Reason string `json:"reason"`
}
func (c *EJBCAClient) do(method, path string, body interface{}, result interface{}) error {
url := c.baseURL + path
var reqBody io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
reqBody = bytes.NewReader(data)
}
req, err := http.NewRequest(method, url, reqBody)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode >= 400 {
return fmt.Errorf("EJBCA error %d: %s", resp.StatusCode, string(respBody))
}
if result != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
}
return nil
}
// SearchCertificates searches for certificates matching criteria
func (c *EJBCAClient) SearchCertificates(req *CertSearchRequest) ([]EJBCACert, error) {
var result struct {
Certificates []EJBCACert `json:"certificates"`
}
err := c.do("POST", "/ejbca/ejbca-rest-api/v1/certificate/search", req, &result)
if err != nil {
return nil, err
}
return result.Certificates, nil
}
// GetCertificate gets a certificate by serial number and issuer DN
func (c *EJBCAClient) GetCertificate(issuerDN, serialNumber string) (*EJBCACert, error) {
path := fmt.Sprintf("/ejbca/ejbca-rest-api/v1/certificate/%s/%s", issuerDN, serialNumber)
var cert EJBCACert
err := c.do("GET", path, nil, &cert)
if err != nil {
return nil, err
}
return &cert, nil
}
// EnrollCertificate requests a new certificate
func (c *EJBCAClient) EnrollCertificate(req *CertEnrollRequest) (*EJBCACert, error) {
var cert EJBCACert
err := c.do("POST", "/ejbca/ejbca-rest-api/v1/certificate/enrollkeystore", req, &cert)
if err != nil {
return nil, err
}
return &cert, nil
}
// RevokeCertificate revokes a certificate
func (c *EJBCAClient) RevokeCertificate(issuerDN, serialNumber, reason string) error {
path := fmt.Sprintf("/ejbca/ejbca-rest-api/v1/certificate/%s/%s/revoke?reason=%s",
issuerDN, serialNumber, reason)
return c.do("PUT", path, nil, nil)
}
// Health checks EJBCA connectivity
func (c *EJBCAClient) Health() error {
return c.do("GET", "/ejbca/ejbca-rest-api/v1/certificate/status", nil, nil)
}

View File

@@ -0,0 +1,162 @@
package client
import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/internal/config"
)
// HockeypuckClient implements the HKP (HTTP Keyserver Protocol) client
type HockeypuckClient struct {
servers []string
client *http.Client
logger zerolog.Logger
}
// NewHockeypuckClient creates a new Hockeypuck HKP client
func NewHockeypuckClient(cfg config.HockeypuckConfig, logger zerolog.Logger) *HockeypuckClient {
return &HockeypuckClient{
servers: cfg.Servers,
client: &http.Client{Timeout: 30 * time.Second},
logger: logger.With().Str("component", "hockeypuck").Logger(),
}
}
// SearchKeys searches for PGP keys by query string (email, name, or key ID)
func (c *HockeypuckClient) SearchKeys(query string) (string, error) {
params := url.Values{
"search": {query},
"op": {"index"},
"options": {"mr"},
}
for _, server := range c.servers {
u := fmt.Sprintf("%s/pks/lookup?%s", server, params.Encode())
resp, err := c.client.Get(u)
if err != nil {
c.logger.Warn().Err(err).Str("server", server).Msg("HKP search failed")
continue
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return "", nil
}
if resp.StatusCode != http.StatusOK {
continue
}
body, err := io.ReadAll(resp.Body)
if err != nil {
continue
}
return string(body), nil
}
return "", fmt.Errorf("all HKP servers failed")
}
// GetKey retrieves a PGP key by key ID
func (c *HockeypuckClient) GetKey(keyID string) (string, error) {
// Ensure keyID has 0x prefix
if !strings.HasPrefix(keyID, "0x") {
keyID = "0x" + keyID
}
params := url.Values{
"search": {keyID},
"op": {"get"},
"options": {"mr"},
}
for _, server := range c.servers {
u := fmt.Sprintf("%s/pks/lookup?%s", server, params.Encode())
resp, err := c.client.Get(u)
if err != nil {
continue
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return "", nil
}
if resp.StatusCode != http.StatusOK {
continue
}
body, err := io.ReadAll(resp.Body)
if err != nil {
continue
}
return string(body), nil
}
return "", fmt.Errorf("all HKP servers failed")
}
// UploadKey uploads a PGP public key
func (c *HockeypuckClient) UploadKey(armoredKey string) error {
form := url.Values{
"keytext": {armoredKey},
}
for _, server := range c.servers {
u := fmt.Sprintf("%s/pks/add", server)
resp, err := c.client.PostForm(u, form)
if err != nil {
c.logger.Warn().Err(err).Str("server", server).Msg("HKP upload failed")
continue
}
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
}
return fmt.Errorf("failed to upload key to any HKP server")
}
// DeleteKey deletes a PGP key (Hockeypuck-specific API, not standard HKP)
func (c *HockeypuckClient) DeleteKey(keyID string) error {
if !strings.HasPrefix(keyID, "0x") {
keyID = "0x" + keyID
}
for _, server := range c.servers {
u := fmt.Sprintf("%s/pks/delete?search=%s", server, url.QueryEscape(keyID))
req, err := http.NewRequest("DELETE", u, nil)
if err != nil {
continue
}
resp, err := c.client.Do(req)
if err != nil {
continue
}
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
}
return fmt.Errorf("failed to delete key from any HKP server")
}
// Health checks HKP server connectivity
func (c *HockeypuckClient) Health() error {
for _, server := range c.servers {
resp, err := c.client.Get(server + "/pks/lookup?op=stats")
if err != nil {
continue
}
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
}
return fmt.Errorf("no HKP servers reachable")
}

125
internal/client/kamailio.go Normal file
View File

@@ -0,0 +1,125 @@
package client
import (
"fmt"
"sync"
"github.com/rs/zerolog"
"golang.org/x/crypto/ssh"
)
// KamailioClient manages SSH connections to Kamailio servers for kamcmd
type KamailioClient struct {
servers []string
sshUser string
sshKey []byte
logger zerolog.Logger
}
// NewKamailioClient creates a new Kamailio management client
func NewKamailioClient(servers []string, sshUser string, sshKey []byte, logger zerolog.Logger) *KamailioClient {
return &KamailioClient{
servers: servers,
sshUser: sshUser,
sshKey: sshKey,
logger: logger.With().Str("client", "kamailio").Logger(),
}
}
// ReloadDispatcher reloads the dispatcher module on all Kamailio servers
func (c *KamailioClient) ReloadDispatcher() []ServerResult {
return c.runOnAll("kamcmd dispatcher.reload")
}
// ReloadPermissions reloads address permissions on all Kamailio servers
func (c *KamailioClient) ReloadPermissions() []ServerResult {
return c.runOnAll("kamcmd permissions.addressReload")
}
// ReloadAll reloads dispatcher and permissions on all servers
func (c *KamailioClient) ReloadAll() []ServerResult {
results := make([]ServerResult, 0, len(c.servers)*2)
r1 := c.ReloadDispatcher()
r2 := c.ReloadPermissions()
results = append(results, r1...)
results = append(results, r2...)
return results
}
// runOnAll executes a command on all Kamailio servers in parallel
func (c *KamailioClient) runOnAll(command string) []ServerResult {
var wg sync.WaitGroup
results := make([]ServerResult, len(c.servers))
for i, srv := range c.servers {
wg.Add(1)
go func(idx int, host string) {
defer wg.Done()
results[idx] = c.execSSH(host, command)
}(i, srv)
}
wg.Wait()
return results
}
// execSSH connects via SSH and executes a command
func (c *KamailioClient) execSSH(host, command string) ServerResult {
result := ServerResult{Host: host}
signer, err := ssh.ParsePrivateKey(c.sshKey)
if err != nil {
result.Error = fmt.Sprintf("failed to parse SSH key: %v", err)
return result
}
config := &ssh.ClientConfig{
User: c.sshUser,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
client, err := ssh.Dial("tcp", host+":22", config)
if err != nil {
result.Error = fmt.Sprintf("SSH connection failed: %v", err)
c.logger.Warn().Str("host", host).Err(err).Msg("Kamailio SSH connection failed")
return result
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
result.Error = fmt.Sprintf("SSH session failed: %v", err)
return result
}
defer session.Close()
output, err := session.CombinedOutput(command)
if err != nil {
result.Error = fmt.Sprintf("command failed: %v, output: %s", err, string(output))
return result
}
result.Success = true
result.Output = string(output)
c.logger.Debug().Str("host", host).Str("command", command).Msg("Kamailio command executed")
return result
}
// Health checks SSH connectivity to all Kamailio servers
func (c *KamailioClient) Health() error {
for _, srv := range c.servers {
r := c.execSSH(srv, "kamcmd core.uptime")
if !r.Success {
return fmt.Errorf("kamailio %s: %s", srv, r.Error)
}
}
return nil
}
// Servers returns the configured server list
func (c *KamailioClient) Servers() []string {
return c.servers
}

226
internal/client/ldap.go Normal file
View File

@@ -0,0 +1,226 @@
package client
import (
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"sync"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/internal/config"
)
// LDAPClient manages a pool of LDAP connections
type LDAPClient struct {
cfg config.LDAPConfig
pool chan *ldap.Conn
mu sync.Mutex
logger zerolog.Logger
}
// NewLDAPClient creates a new LDAP client with a connection pool
func NewLDAPClient(cfg config.LDAPConfig, logger zerolog.Logger) (*LDAPClient, error) {
if len(cfg.Servers) == 0 {
return nil, fmt.Errorf("no LDAP servers configured")
}
c := &LDAPClient{
cfg: cfg,
pool: make(chan *ldap.Conn, cfg.PoolSize),
logger: logger.With().Str("component", "ldap").Logger(),
}
// Pre-fill pool with connections
for i := 0; i < cfg.PoolSize; i++ {
conn, err := c.connect()
if err != nil {
c.logger.Warn().Err(err).Int("index", i).Msg("failed to create initial LDAP connection")
continue
}
c.pool <- conn
}
return c, nil
}
func (c *LDAPClient) connect() (*ldap.Conn, error) {
var lastErr error
for _, server := range c.cfg.Servers {
var conn *ldap.Conn
var err error
if c.cfg.UseTLS {
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
if c.cfg.CAFile != "" {
caCert, err := os.ReadFile(c.cfg.CAFile)
if err != nil {
lastErr = fmt.Errorf("failed to read CA file: %w", err)
continue
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caCert)
tlsCfg.RootCAs = pool
}
conn, err = ldap.DialURL(server, ldap.DialWithTLSConfig(tlsCfg))
} else {
conn, err = ldap.DialURL(server)
}
if err != nil {
lastErr = fmt.Errorf("failed to connect to %s: %w", server, err)
continue
}
conn.SetTimeout(10 * time.Second)
if err := conn.Bind(c.cfg.BindDN, c.cfg.BindPass); err != nil {
conn.Close()
lastErr = fmt.Errorf("failed to bind to %s: %w", server, err)
continue
}
return conn, nil
}
return nil, fmt.Errorf("all LDAP servers failed: %w", lastErr)
}
// Acquire gets a connection from the pool, creating one if needed
func (c *LDAPClient) Acquire() (*ldap.Conn, error) {
select {
case conn := <-c.pool:
// Test the connection with a no-op search
_, err := conn.Search(&ldap.SearchRequest{
BaseDN: "",
Scope: ldap.ScopeBaseObject,
Filter: "(objectClass=*)",
SizeLimit: 1,
})
if err != nil {
conn.Close()
return c.connect()
}
return conn, nil
default:
return c.connect()
}
}
// Release returns a connection to the pool
func (c *LDAPClient) Release(conn *ldap.Conn) {
if conn == nil {
return
}
select {
case c.pool <- conn:
default:
conn.Close()
}
}
// Close closes all pooled connections
func (c *LDAPClient) Close() {
close(c.pool)
for conn := range c.pool {
conn.Close()
}
}
// Health checks LDAP connectivity
func (c *LDAPClient) Health() error {
conn, err := c.Acquire()
if err != nil {
return err
}
defer c.Release(conn)
return nil
}
// Search executes an LDAP search
func (c *LDAPClient) Search(baseDN, filter string, attrs []string, sizeLimit int) ([]*ldap.Entry, error) {
conn, err := c.Acquire()
if err != nil {
return nil, fmt.Errorf("failed to acquire LDAP connection: %w", err)
}
defer c.Release(conn)
sr, err := conn.Search(&ldap.SearchRequest{
BaseDN: baseDN,
Scope: ldap.ScopeWholeSubtree,
Filter: filter,
Attributes: attrs,
SizeLimit: sizeLimit,
})
if err != nil {
// FreeIPA returns SizeLimitExceeded with partial results
if ldap.IsErrorWithCode(err, ldap.LDAPResultSizeLimitExceeded) && sr != nil {
return sr.Entries, nil
}
return nil, fmt.Errorf("LDAP search failed: %w", err)
}
return sr.Entries, nil
}
// SearchOne executes an LDAP search expecting exactly one result
func (c *LDAPClient) SearchOne(baseDN, filter string, attrs []string) (*ldap.Entry, error) {
entries, err := c.Search(baseDN, filter, attrs, 1)
if err != nil {
return nil, err
}
if len(entries) == 0 {
return nil, nil
}
return entries[0], nil
}
// Add adds an LDAP entry
func (c *LDAPClient) Add(req *ldap.AddRequest) error {
conn, err := c.Acquire()
if err != nil {
return fmt.Errorf("failed to acquire LDAP connection: %w", err)
}
defer c.Release(conn)
return conn.Add(req)
}
// Modify modifies an LDAP entry
func (c *LDAPClient) Modify(req *ldap.ModifyRequest) error {
conn, err := c.Acquire()
if err != nil {
return fmt.Errorf("failed to acquire LDAP connection: %w", err)
}
defer c.Release(conn)
return conn.Modify(req)
}
// Delete deletes an LDAP entry
func (c *LDAPClient) Delete(dn string) error {
conn, err := c.Acquire()
if err != nil {
return fmt.Errorf("failed to acquire LDAP connection: %w", err)
}
defer c.Release(conn)
return conn.Del(&ldap.DelRequest{DN: dn})
}
// PasswordModify changes a user's password
func (c *LDAPClient) PasswordModify(userDN, newPassword string) error {
conn, err := c.Acquire()
if err != nil {
return fmt.Errorf("failed to acquire LDAP connection: %w", err)
}
defer c.Release(conn)
_, err = conn.PasswordModify(&ldap.PasswordModifyRequest{
UserIdentity: userDN,
NewPassword: newPassword,
})
return err
}

180
internal/client/powerdns.go Normal file
View File

@@ -0,0 +1,180 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/internal/config"
)
// PowerDNSClient is an HTTP client for the PowerDNS API
type PowerDNSClient struct {
baseURL string
apiKey string
serverID string
client *http.Client
logger zerolog.Logger
}
// NewPowerDNSClient creates a new PowerDNS client
func NewPowerDNSClient(cfg config.PowerDNSConfig, logger zerolog.Logger) *PowerDNSClient {
return &PowerDNSClient{
baseURL: cfg.BaseURL,
apiKey: cfg.APIKey,
serverID: cfg.ServerID,
client: &http.Client{Timeout: 30 * time.Second},
logger: logger.With().Str("component", "powerdns").Logger(),
}
}
// RRSet represents a PowerDNS resource record set
type RRSet struct {
Name string `json:"name"`
Type string `json:"type"`
TTL int `json:"ttl"`
ChangeType string `json:"changetype,omitempty"`
Records []Record `json:"records"`
Comments []Comment `json:"comments,omitempty"`
}
// Record represents a single DNS record
type Record struct {
Content string `json:"content"`
Disabled bool `json:"disabled"`
}
// Comment represents a comment on an RRSet
type Comment struct {
Content string `json:"content"`
Account string `json:"account"`
ModifiedAt int64 `json:"modified_at"`
}
// Zone represents a PowerDNS zone
type Zone struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
DNSSec bool `json:"dnssec"`
Serial int64 `json:"serial"`
NotifiedSerial int64 `json:"notified_serial"`
SOAEdit string `json:"soa_edit,omitempty"`
SOAEditAPI string `json:"soa_edit_api,omitempty"`
RRSets []RRSet `json:"rrsets,omitempty"`
Nameservers []string `json:"nameservers,omitempty"`
Masters []string `json:"masters,omitempty"`
}
// ZoneCreate is the request body for creating a zone
type ZoneCreate struct {
Name string `json:"name"`
Kind string `json:"kind"`
Nameservers []string `json:"nameservers"`
Masters []string `json:"masters,omitempty"`
}
func (c *PowerDNSClient) do(method, path string, body interface{}, result interface{}) error {
url := fmt.Sprintf("%s/api/v1/servers/%s%s", c.baseURL, c.serverID, path)
var reqBody io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
reqBody = bytes.NewReader(data)
}
req, err := http.NewRequest(method, url, reqBody)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("X-API-Key", c.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode >= 400 {
return fmt.Errorf("PowerDNS error %d: %s", resp.StatusCode, string(respBody))
}
if result != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
}
return nil
}
// ListZones lists all zones
func (c *PowerDNSClient) ListZones() ([]Zone, error) {
var zones []Zone
err := c.do("GET", "/zones", nil, &zones)
return zones, err
}
// GetZone gets a zone by ID with RRSets
func (c *PowerDNSClient) GetZone(zoneID string) (*Zone, error) {
var zone Zone
err := c.do("GET", "/zones/"+zoneID, nil, &zone)
if err != nil {
return nil, err
}
return &zone, nil
}
// CreateZone creates a new zone
func (c *PowerDNSClient) CreateZone(zone *ZoneCreate) (*Zone, error) {
var result Zone
err := c.do("POST", "/zones", zone, &result)
if err != nil {
return nil, err
}
return &result, nil
}
// UpdateZone updates zone metadata (PATCH to /zones/:id is for metadata only)
func (c *PowerDNSClient) UpdateZone(zoneID string, data map[string]interface{}) error {
return c.do("PUT", "/zones/"+zoneID, data, nil)
}
// DeleteZone deletes a zone
func (c *PowerDNSClient) DeleteZone(zoneID string) error {
return c.do("DELETE", "/zones/"+zoneID, nil, nil)
}
// NotifyZone sends NOTIFY to slaves
func (c *PowerDNSClient) NotifyZone(zoneID string) error {
return c.do("PUT", "/zones/"+zoneID+"/notify", nil, nil)
}
// PatchRRSets patches record sets in a zone (create, update, or delete)
func (c *PowerDNSClient) PatchRRSets(zoneID string, rrsets []RRSet) error {
body := map[string]interface{}{
"rrsets": rrsets,
}
return c.do("PATCH", "/zones/"+zoneID, body, nil)
}
// Health checks PowerDNS connectivity
func (c *PowerDNSClient) Health() error {
_, err := c.ListZones()
return err
}