- PATCH /agents/{id} switches the mode and reconnects the agent
- POST /revisions/{n}/deploy creates pending push deployments, sends
ApplyRevisionCommand with the deployment ID, re-sends open pushes on
connect and announces them in GetDesiredState; reports with the ID
complete the deployment
- POST /revisions/{n}/rollback re-renders the stored generator input
with the next revision number
- GET /revisions/{n}/diff and GET /config/pending with unified diffs
- GET /events streams agent, deployment, revision and pending events
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
5.2 KiB
Go
172 lines
5.2 KiB
Go
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
|
|
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/netip"
|
|
"slices"
|
|
"time"
|
|
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/agenthub"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/auth"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/events"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/license"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/pki"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/secrets"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/store"
|
|
)
|
|
|
|
// Actor is the authenticated user performing an operation.
|
|
type Actor struct {
|
|
UserID string
|
|
Username string
|
|
Role auth.Role
|
|
IP *netip.Addr
|
|
RequestID string
|
|
// SessionHash identifies the current session (kept on password change).
|
|
SessionHash []byte
|
|
}
|
|
|
|
// SystemActor is used for operations without a user (CLI, background jobs).
|
|
var SystemActor = &Actor{Username: "system", Role: auth.RoleAdmin}
|
|
|
|
// Options configures a Service.
|
|
type Options struct {
|
|
Store *store.Store
|
|
Keyring *secrets.Keyring
|
|
License *license.Manager
|
|
ProductName string
|
|
// SessionLifetime and SessionIdle bound web sessions (ADR 0020).
|
|
SessionLifetime time.Duration
|
|
SessionIdle time.Duration
|
|
Now func() time.Time
|
|
Logger *slog.Logger
|
|
|
|
// Agent API (ADR 0021). Without PKI, enrollment tokens cannot be
|
|
// created and agents cannot enroll.
|
|
PKI *pki.Authority
|
|
// Hub tracks the agents' open streams (optional).
|
|
Hub *agenthub.Hub
|
|
// AgentAPIAddress is the host:port shown in the enrollment command.
|
|
AgentAPIAddress string
|
|
// AgentServerNames are the SANs of the agent-port certificate.
|
|
AgentServerNames []string
|
|
// Events receives change notifications for the live UI (optional).
|
|
Events *events.Bus
|
|
}
|
|
|
|
// Service implements the use cases.
|
|
type Service struct {
|
|
opts Options
|
|
st *store.Store
|
|
now func() time.Time
|
|
log *slog.Logger
|
|
|
|
ipLimiter *auth.Limiter
|
|
userLimiter *auth.Limiter
|
|
// enrollLimiter bounds enrollment attempts per source IP.
|
|
enrollLimiter *auth.Limiter
|
|
}
|
|
|
|
// New creates a Service.
|
|
func New(opts Options) *Service {
|
|
if opts.Now == nil {
|
|
opts.Now = time.Now
|
|
}
|
|
if opts.Logger == nil {
|
|
opts.Logger = slog.New(slog.DiscardHandler)
|
|
}
|
|
if opts.SessionLifetime <= 0 {
|
|
opts.SessionLifetime = 12 * time.Hour
|
|
}
|
|
if opts.SessionIdle <= 0 {
|
|
opts.SessionIdle = time.Hour
|
|
}
|
|
return &Service{
|
|
opts: opts, st: opts.Store, now: opts.Now, log: opts.Logger,
|
|
// ADR 0020: 5 attempts, then one per minute; per IP a little more
|
|
// generous because several users may share an address.
|
|
ipLimiter: auth.NewLimiter(20, 15*time.Second),
|
|
userLimiter: auth.NewLimiter(5, time.Minute),
|
|
// Enrollment: 10 attempts, then one every 30 s per source IP.
|
|
enrollLimiter: auth.NewLimiter(10, 30*time.Second),
|
|
}
|
|
}
|
|
|
|
// License returns the license manager.
|
|
func (s *Service) License() *license.Manager { return s.opts.License }
|
|
|
|
// Ping checks the database (readiness).
|
|
func (s *Service) Ping(ctx context.Context) error { return s.st.Ping(ctx) }
|
|
|
|
// requireWritable fails if the license does not allow changes. The HTTP layer
|
|
// checks this for every writing route as well; this is defense in depth.
|
|
func (s *Service) requireWritable() error {
|
|
if s.opts.License != nil && !s.opts.License.Writable() {
|
|
return ErrReadOnly
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// audit writes an entry within tx. before/after are marshaled to JSON.
|
|
func (s *Service) audit(ctx context.Context, tx *store.Tx, a *Actor, action, objType, objID, objName string, before, after any) error {
|
|
e := &store.AuditEntry{ActorName: a.Username, Action: action, ObjectType: objType, ObjectName: objName,
|
|
RequestID: a.RequestID, IP: a.IP}
|
|
if a.UserID != "" {
|
|
id := a.UserID
|
|
e.ActorID = &id
|
|
}
|
|
if objID != "" {
|
|
e.ObjectID = &objID
|
|
}
|
|
var err error
|
|
if before != nil {
|
|
if e.Before, err = json.Marshal(before); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if after != nil {
|
|
if e.After, err = json.Marshal(after); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := tx.InsertAudit(ctx, e); err != nil {
|
|
return err
|
|
}
|
|
if affectsConfig(action, objType) {
|
|
tx.AfterCommit(events.TypePending, func() { s.publish(events.TypePending, events.Pending{}) })
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// configObjectTypes and configActions identify audit entries of data the
|
|
// generator uses (pending changes, ADR 0022).
|
|
var (
|
|
configObjectTypes = []string{"network", "address"}
|
|
configActions = []string{"settings.generator"}
|
|
)
|
|
|
|
func affectsConfig(action, objType string) bool {
|
|
return slices.Contains(configObjectTypes, objType) || slices.Contains(configActions, action)
|
|
}
|
|
|
|
// Events returns the event bus (nil if not configured).
|
|
func (s *Service) Events() *events.Bus { return s.opts.Events }
|
|
|
|
// publish sends an event to the live UI.
|
|
func (s *Service) publish(typ string, data any) { s.opts.Events.Publish(typ, data) }
|
|
|
|
func (s *Service) publishAgent(id string) { s.publish(events.TypeAgent, events.Agent{ID: id}) }
|
|
|
|
func (s *Service) publishDeployment(d *store.Deployment) {
|
|
s.publish(events.TypeDeployment, events.Deployment{ID: d.ID, AgentID: d.AgentID, Revision: d.Revision})
|
|
}
|
|
|
|
// inTx runs fn in a transaction and translates errors.
|
|
func (s *Service) inTx(ctx context.Context, fn func(tx *store.Tx) error) error {
|
|
return translate(s.st.InTx(ctx, fn))
|
|
}
|