Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,15 @@ Use the compose file and deployment guide in the umbrella repository:
TOTP; all other session-backed APIs return the stable `mfa_required` error
until enrollment is complete. Bearer PAT automation is not an interactive
session and is not gated by this policy.
- Approvals are manual by default. `LATTICE_APPROVAL_AUTO_RULES` (or
`-approval-auto-rules`) accepts a JSON array of opt-in auto-approve rules for
trusted writers, e.g.
`[{"name":"linemeta-fleet","writer":"lattice-server","plugin":"singbox-linemeta","action_prefix":"apply-metadata","queue":true,"daily_cap":100}]`.
The first matching rule wins; `queue` selects approve-and-queue over
approve-only, `daily_cap` bounds auto-approvals per UTC day (0 = uncapped),
and automated decisions are audited as `approval.auto_approve` /
`approval.auto_skip`. Malformed JSON is logged and ignored — the server
starts fully manual.
- **Passkeys (WebAuthn).** Operators can register passkeys (platform
authenticators such as Apple Passwords / iCloud Keychain, or roaming security
keys) and sign in with them. Verification uses
Expand Down
3 changes: 3 additions & 0 deletions cmd/lattice-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ func main() {
var auditHeadWebhookToken string
var auditHeadInterval time.Duration
var taskExecDisabled bool
var approvalAutoRules string
var printVersion bool
flag.StringVar(&listen, "listen", env("LATTICE_LISTEN", "127.0.0.1:8088"), "listen address")
flag.StringVar(&dataPath, "data", env("LATTICE_DATA", defaultDataPath()), "state file path")
Expand All @@ -85,6 +86,7 @@ func main() {
flag.StringVar(&auditHeadWebhookToken, "audit-head-webhook-token", env("LATTICE_AUDIT_HEAD_WEBHOOK_TOKEN", ""), "bearer token for -audit-head-webhook-url")
flag.DurationVar(&auditHeadInterval, "audit-head-interval", envDuration("LATTICE_AUDIT_HEAD_INTERVAL", 15*time.Minute), "audit head webhook shipping interval")
flag.BoolVar(&taskExecDisabled, "task-exec-disabled", env("LATTICE_TASK_EXEC_DISABLED", "") == "1", "server-side fleet kill switch: block new task queueing and agent task leases")
flag.StringVar(&approvalAutoRules, "approval-auto-rules", env("LATTICE_APPROVAL_AUTO_RULES", ""), "JSON array of approval auto-approve rules (empty keeps approvals fully manual)")
flag.BoolVar(&printVersion, "version", false, "print lattice-server version and exit")
flag.Parse()
if printVersion {
Expand Down Expand Up @@ -189,6 +191,7 @@ func main() {
Interval: auditHeadInterval,
},
TaskExecutionDisabled: taskExecDisabled,
ApprovalAutoRules: approvalAutoRules,
Logger: log.Default(),
})
if err != nil {
Expand Down
132 changes: 88 additions & 44 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ type Options struct {
// tasks are not queued and agents receive no task leases. Already leased
// task results are still accepted so in-flight work can report terminal state.
TaskExecutionDisabled bool
// ApprovalAutoRules is the operator's JSON-encoded auto-approve policy list
// (LATTICE_APPROVAL_AUTO_RULES). Empty keeps approvals fully manual — the
// default. Malformed input is logged and ignored, never fatal.
ApprovalAutoRules string
// RenewalReminderInterval controls the machine-renewal reminder scheduler.
// Zero uses the production default. DisableRenewalScheduler is intended for
// tests that need full control over reminder evaluation.
Expand Down Expand Up @@ -206,6 +210,9 @@ type Server struct {
// config, not persisted policy; restart with the flag/env cleared to re-enable
// queueing and leasing.
taskExecutionDisabled bool
// approvalAutoRules is the parsed auto-approve policy list. Nil (the
// default) keeps every approval manual.
approvalAutoRules []approvalAutoRule
// terminalBroker owns short-lived interactive terminal sessions. Sessions are
// intentionally in-memory only; a server restart forces operators to reopen.
terminalBroker *terminalBroker
Expand Down Expand Up @@ -336,6 +343,13 @@ func New(opts Options) (*Server, error) {
}
build := normalizeBuildInfo(opts.Build)
build.TaskExecutionDisabled = opts.TaskExecutionDisabled
approvalAutoRules, err := parseApprovalAutoRules(opts.ApprovalAutoRules)
if err != nil {
// Auto-approve is an operator opt-in; malformed policy must never block
// startup, so fall back to the zero-rule (fully manual) default.
opts.Logger.Printf("WARNING: ignoring approval auto-approve rules: %v", err)
approvalAutoRules = nil
}
s := &Server{
store: opts.Store,
logStore: opts.LogStore,
Expand Down Expand Up @@ -370,6 +384,7 @@ func New(opts Options) (*Server, error) {
agentReleaseRepo: agentReleaseRepo,
auditHeadShipper: auditHeadShipper,
taskExecutionDisabled: opts.TaskExecutionDisabled,
approvalAutoRules: approvalAutoRules,
terminalBroker: newTerminalBroker(),
terminalHub: newTerminalHub(),
agentControlHub: newAgentControlHub(),
Expand All @@ -386,6 +401,9 @@ func New(opts Options) (*Server, error) {
if s.taskExecutionDisabled {
s.logger.Printf("WARNING: task execution fleet kill switch is enabled; new tasks will not queue and agents will receive no task leases")
}
if len(s.approvalAutoRules) > 0 {
s.logger.Printf("approval auto-approve: %d rule(s) active", len(s.approvalAutoRules))
}
s.emitNotify = s.notifyEvent
s.pluginRPC = plugin.NewRPCRegistry()
s.registerVPNCoreRPC()
Expand Down Expand Up @@ -4564,7 +4582,8 @@ func (s *Server) handleNFTPlan(w http.ResponseWriter, r *http.Request, p princip
ActorID: p.ActorID,
CreatedAt: time.Now().UTC(),
}
if err := s.store.UpsertApproval(approval); err != nil {
approval, err = s.submitApproval(approval)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
Expand Down Expand Up @@ -4744,7 +4763,8 @@ func (s *Server) handleTunnelPlan(w http.ResponseWriter, r *http.Request, p prin
ActorID: p.ActorID,
CreatedAt: time.Now().UTC(),
}
if err := s.store.UpsertApproval(approval); err != nil {
approval, err = s.submitApproval(approval)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
Expand Down Expand Up @@ -4795,7 +4815,8 @@ func (s *Server) handleWireGuardPlan(w http.ResponseWriter, r *http.Request, p p
ActorID: p.ActorID,
CreatedAt: time.Now().UTC(),
}
if err := s.store.UpsertApproval(approval); err != nil {
approval, err = s.submitApproval(approval)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
Expand Down Expand Up @@ -5385,48 +5406,80 @@ func (s *Server) handleApprove(w http.ResponseWriter, r *http.Request, p princip
if !s.requireApprovalDecisionScopes(w, p, approval) {
return
}
if approval.Status != model.ApprovalPending {
writeJSON(w, http.StatusOK, toApprovalView(approval))
updated, err := s.approveApprovalCore(p, approval, req.QueueApply, req.PlanSHA256)
if err != nil {
var decisionErr *approvalDecisionError
if errors.As(err, &decisionErr) {
if decisionErr.taskExecutionDisabled {
writeTaskExecutionDisabled(w)
return
}
writeError(w, decisionErr.status, decisionErr.err)
return
}
writeError(w, http.StatusInternalServerError, err)
return
}
if approvalRequiresPlanHash(approval) && req.PlanSHA256 == "" {
writeError(w, http.StatusBadRequest, apiError(model.APIErrorBadRequest, "plan_sha256 is required for this approval"))
return
writeJSON(w, http.StatusOK, toApprovalView(updated))
}

// approvalDecisionError pairs a failed approval decision with the HTTP
// response the manual endpoint would have produced, so non-HTTP callers (the
// auto-approve policy engine) share one decision path without losing response
// fidelity at the endpoint.
type approvalDecisionError struct {
status int
err error
// taskExecutionDisabled selects the dedicated kill-switch response body.
taskExecutionDisabled bool
}

func (e *approvalDecisionError) Error() string { return e.err.Error() }

// approveApprovalCore is the single decision path behind both the manual
// approve endpoint and auto-approve policies. It re-validates the reviewed
// plan (the plan_sha256 binding), re-checks plugin-specific freshness, marks
// the approval approved, and optionally queues the apply task. Keeping one
// implementation matters most for the hash binding: an approval must only
// ever transition for the exact stored plan bytes, whichever entry point
// drove the decision.
func (s *Server) approveApprovalCore(p principal, approval model.Approval, queueApply bool, planSHA256 string) (model.Approval, error) {
if approval.Status != model.ApprovalPending {
// Already-decided approvals stay idempotent, matching the manual
// endpoint's retry semantics.
return approval, nil
}
if req.PlanSHA256 != "" {
if approvalRequiresPlanHash(approval) && planSHA256 == "" {
return approval, &approvalDecisionError{status: http.StatusBadRequest, err: apiError(model.APIErrorBadRequest, "plan_sha256 is required for this approval")}
}
if planSHA256 != "" {
sum := sha256.Sum256([]byte(approval.Plan))
if !strings.EqualFold(req.PlanSHA256, hex.EncodeToString(sum[:])) {
writeError(w, http.StatusConflict, apiError(model.APIErrorApprovalStale, "plan changed since review; re-review before approving"))
return
if !strings.EqualFold(planSHA256, hex.EncodeToString(sum[:])) {
return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(model.APIErrorApprovalStale, "plan changed since review; re-review before approving")}
}
}
if approval.Plugin == "nftpolicy" {
if err := s.requireCurrentNetPolicyApproval(approval); err != nil {
writeError(w, http.StatusConflict, apiError(model.APIErrorApprovalStale, err.Error()))
return
return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(model.APIErrorApprovalStale, err.Error())}
}
}
if approval.Plugin == proxyCorePlugin {
if err := s.requireCurrentProxyCoreApproval(approval); err != nil {
writeError(w, http.StatusConflict, apiError(model.APIErrorApprovalStale, err.Error()))
return
return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(model.APIErrorApprovalStale, err.Error())}
}
}
if approval.Plugin == agentUpdatePlugin {
if err := s.requireCurrentAgentUpdateApproval(approval); err != nil {
if errors.Is(err, errAgentUpdateApprovalStale) {
if rejectErr := s.rejectAgentUpdateApprovalWithReason(approval, err.Error(), s.now()); rejectErr != nil {
writeError(w, http.StatusInternalServerError, rejectErr)
return
return approval, &approvalDecisionError{status: http.StatusInternalServerError, err: rejectErr}
}
writeError(w, http.StatusConflict, apiError(model.APIErrorApprovalStale, err.Error()))
return
return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(model.APIErrorApprovalStale, err.Error())}
}
writeError(w, http.StatusConflict, apiError(model.APIErrorBadRequest, err.Error()))
return
return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(model.APIErrorBadRequest, err.Error())}
}
}
if req.QueueApply && s.taskExecutionDisabled {
if queueApply && s.taskExecutionDisabled {
s.recordPrincipalAudit(p, model.AuditEvent{
ID: id.New("audit"),
NodeID: approval.NodeID,
Expand All @@ -5436,36 +5489,31 @@ func (s *Server) handleApprove(w http.ResponseWriter, r *http.Request, p princip
Reason: errTaskExecutionDisabled.Error(),
Metadata: map[string]string{"approval_id": approval.ID},
})
writeTaskExecutionDisabled(w)
return
return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(apiErrorTaskExecutionDisabled, errTaskExecutionDisabled.Error()), taskExecutionDisabled: true}
}
applyScript := ""
if req.QueueApply {
if queueApply {
switch approval.Plugin {
case "selfdns":
var err error
applyScript, err = selfdns.ApplyScriptFromPlan(approval.Plan)
if err != nil {
writeError(w, http.StatusConflict, apiError(model.APIErrorApprovalStale, "selfdns plan is no longer applyable; re-plan before approving"))
return
return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(model.APIErrorApprovalStale, "selfdns plan is no longer applyable; re-plan before approving")}
}
if err := s.requireSelfDNSDeploymentForApproval(approval); err != nil {
writeError(w, http.StatusConflict, apiError(model.APIErrorApprovalStale, err.Error()))
return
return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(model.APIErrorApprovalStale, err.Error())}
}
case proxyCorePlugin:
var err error
applyScript, err = s.proxyCoreApplyScript(approval)
if err != nil {
writeError(w, http.StatusConflict, apiError(model.APIErrorApprovalStale, err.Error()))
return
return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(model.APIErrorApprovalStale, err.Error())}
}
case agentUpdatePlugin:
var err error
applyScript, err = agentUpdateApplyScript(approval)
if err != nil {
writeError(w, http.StatusConflict, apiError(model.APIErrorBadRequest, err.Error()))
return
return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(model.APIErrorBadRequest, err.Error())}
}
default:
applyScript = s.applyScriptFor(approval)
Expand All @@ -5474,10 +5522,9 @@ func (s *Server) handleApprove(w http.ResponseWriter, r *http.Request, p princip
approval.Status = model.ApprovalApproved
approval.ApprovedBy = p.ActorID
if err := s.store.UpsertApproval(approval); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
return approval, &approvalDecisionError{status: http.StatusInternalServerError, err: err}
}
if req.QueueApply {
if queueApply {
timeoutSec := approvalApplyTaskTimeoutSec(approval.Plugin)
task := model.Task{
ID: id.New("task"),
Expand All @@ -5503,21 +5550,18 @@ func (s *Server) handleApprove(w http.ResponseWriter, r *http.Request, p princip
Reason: err.Error(),
Metadata: map[string]string{"approval_id": approval.ID},
})
writeTaskExecutionDisabled(w)
return
return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(apiErrorTaskExecutionDisabled, errTaskExecutionDisabled.Error()), taskExecutionDisabled: true}
}
writeError(w, http.StatusInternalServerError, err)
return
return approval, &approvalDecisionError{status: http.StatusInternalServerError, err: err}
}
if approval.Plugin == "selfdns" {
if err := s.markSelfDNSApplying(approval); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
return approval, &approvalDecisionError{status: http.StatusInternalServerError, err: err}
}
}
}
s.recordPrincipalAudit(p, model.AuditEvent{ID: id.New("audit"), NodeID: approval.NodeID, Action: "network." + approval.Plugin + ".approve", Scope: approvalDecisionAuditScope(approval), Metadata: map[string]string{"approval_id": approval.ID}})
writeJSON(w, http.StatusOK, toApprovalView(approval))
return approval, nil
}

func (s *Server) handleRejectApproval(w http.ResponseWriter, r *http.Request, p principal) {
Expand Down
3 changes: 2 additions & 1 deletion internal/server/server_agent_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,8 @@ func (s *Server) createAgentUpdateApproval(nodeID, actorID string, force bool, m
CreatedAt: now,
UpdatedAt: now,
}
if err := s.store.UpsertApproval(approval); err != nil {
approval, err = s.submitApproval(approval)
if err != nil {
return model.Approval{}, err
}
policy.LastPlannedVersion = policy.TargetVersion
Expand Down
Loading
Loading