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:
191
internal/client/asterisk.go
Normal file
191
internal/client/asterisk.go
Normal 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
|
||||
}
|
||||
67
internal/client/carddav.go
Normal file
67
internal/client/carddav.go
Normal 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
188
internal/client/ejbca.go
Normal 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)
|
||||
}
|
||||
162
internal/client/hockeypuck.go
Normal file
162
internal/client/hockeypuck.go
Normal 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
125
internal/client/kamailio.go
Normal 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
226
internal/client/ldap.go
Normal 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
180
internal/client/powerdns.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user