Files
proxy/internal/app/maintenance.go
T

199 lines
6.4 KiB
Go

package app
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"time"
"proxyctl/internal/profile"
"proxyctl/internal/target"
)
// MutationLock serializes all state-changing operations across processes.
type MutationLock interface {
Lock(context.Context) (unlock func(), err error)
}
// LogEntry is the privacy-safe operation record written to bounded local logs.
type LogEntry struct {
Time time.Time
Operation string
Target string
Result string
Detail string
}
type OperationLogger interface{ Write(LogEntry) error }
type Maintenance struct {
state profile.State
targets []target.Target
lock MutationLock
logger OperationLogger
}
func NewMaintenance(state profile.State, targets []target.Target, lock MutationLock, logger OperationLogger) *Maintenance {
return &Maintenance{state: state, targets: append([]target.Target(nil), targets...), lock: lock, logger: logger}
}
type Diagnostic struct {
TargetID string
Check target.Check
}
type DoctorReport struct{ Diagnostics []Diagnostic }
func (r DoctorReport) Healthy() bool {
for _, diagnostic := range r.Diagnostics {
if !diagnostic.Check.OK {
return false
}
}
return true
}
func (r DoctorReport) String() string {
var output strings.Builder
for _, diagnostic := range r.Diagnostics {
status := "OK"
if !diagnostic.Check.OK {
status = "FAIL"
}
name := diagnostic.Check.Name
if diagnostic.TargetID != "" {
name = diagnostic.TargetID + ": " + name
}
fmt.Fprintf(&output, "[%s] %s — %s\n", status, name, profile.RedactText(diagnostic.Check.Evidence))
}
return output.String()
}
// Doctor only invokes observational Target methods.
func (m *Maintenance) Doctor(ctx context.Context, applicationChecks []target.Check) DoctorReport {
report := DoctorReport{}
for _, check := range applicationChecks {
check.Evidence = profile.RedactText(check.Evidence)
report.Diagnostics = append(report.Diagnostics, Diagnostic{Check: check})
}
active := activeFromState(m.state)
for _, adapter := range m.targets {
availability := adapter.Availability(ctx)
report.Diagnostics = append(report.Diagnostics, Diagnostic{TargetID: adapter.ID(), Check: target.Check{Name: "Target availability", OK: availability.Available, Evidence: profile.RedactText(availability.Evidence)}})
inspection := adapter.Inspect(ctx, active)
report.Diagnostics = append(report.Diagnostics, Diagnostic{TargetID: adapter.ID(), Check: target.Check{Name: "detected state", OK: inspection.State != target.Drifted, Evidence: profile.RedactText(string(inspection.State) + ": " + inspection.Evidence)}})
if diagnosable, ok := adapter.(target.Diagnosable); ok {
for _, check := range diagnosable.DiagnosticChecks(ctx, active) {
check.Evidence = profile.RedactText(check.Evidence)
report.Diagnostics = append(report.Diagnostics, Diagnostic{TargetID: adapter.ID(), Check: check})
}
}
}
return report
}
type ResetTargetResult struct {
TargetID string
Inspection target.Inspection
Skipped bool
OperationErr error
LogErr error
Err error
}
type ResetResult struct {
Cancelled bool
Targets []ResetTargetResult
Err error
}
// ResetConfirmation performs only read-only inspection and returns every
// warning required by Targets that a reset would attempt to disable.
func (m *Maintenance) ResetConfirmation(ctx context.Context) string {
active := activeFromState(m.state)
var warnings []string
for _, adapter := range m.targets {
inspection := adapter.Inspect(ctx, active)
if inspection.State != target.Enabled && inspection.State != target.Drifted {
continue
}
if warning := adapter.Confirmation(false); warning != "" {
warnings = append(warnings, adapter.ID()+": "+warning)
}
}
return strings.Join(warnings, "\n")
}
func (m *Maintenance) Reset(ctx context.Context, confirmed bool) ResetResult {
if !confirmed {
return ResetResult{Cancelled: true}
}
if m.lock == nil {
return ResetResult{Err: errors.New("mutation lock is not configured")}
}
unlock, err := m.lock.Lock(ctx)
if err != nil {
return ResetResult{Err: fmt.Errorf("lock proxyctl mutation: %w", err)}
}
defer unlock()
active := activeFromState(m.state)
result := ResetResult{}
for _, adapter := range m.targets {
inspection := adapter.Inspect(ctx, active)
if inspection.State != target.Enabled && inspection.State != target.Drifted {
result.Targets = append(result.Targets, ResetTargetResult{TargetID: adapter.ID(), Inspection: inspection, Skipped: true})
continue
}
operation := adapter.SetDesired(ctx, false, active)
finalInspection := operation.Inspection
detail, outcome := finalInspection.Evidence, "success"
if operation.Err != nil {
// OperationResult inspection is explicitly advisory on failure. Reset
// must publish and log a fresh observation just like interactive
// Target operations do.
finalInspection = adapter.Inspect(ctx, active)
outcome = "failure"
detail = fmt.Sprintf("%v; fresh inspection %s: %s", operation.Err, finalInspection.State, finalInspection.Evidence)
result.Err = errors.Join(result.Err, fmt.Errorf("reset %s Target: %w", adapter.ID(), redactError(operation.Err)))
}
detail = RedactLogDetail(detail)
operationErr := redactError(operation.Err)
var logErr error
if m.logger != nil {
if err := m.logger.Write(LogEntry{Time: time.Now(), Operation: "reset", Target: adapter.ID(), Result: outcome, Detail: detail}); err != nil {
logErr = redactError(err)
result.Err = errors.Join(result.Err, fmt.Errorf("record reset %s Target operation log: %w", adapter.ID(), logErr))
}
}
result.Targets = append(result.Targets, ResetTargetResult{
TargetID: adapter.ID(), Inspection: finalInspection,
OperationErr: operationErr, LogErr: logErr, Err: errors.Join(operationErr, logErr),
})
}
return result
}
func activeFromState(state profile.State) *profile.Profile {
for i := range state.Profiles {
if state.Profiles[i].Name == state.ActiveProfile {
value := state.Profiles[i]
return &value
}
}
return nil
}
var authenticatedURL = regexp.MustCompile(`(?i)(?:https?|socks5h?)://[^\s'\"<>]*@[^\s'\"<>]+`)
// RedactLogDetail omits complete authenticated URLs, not just their passwords.
func RedactLogDetail(value string) string {
value = authenticatedURL.ReplaceAllString(value, "[REDACTED AUTHENTICATED URL]")
return profile.RedactText(value)
}
func redactError(err error) error {
if err == nil {
return nil
}
return errors.New(profile.RedactText(err.Error()))
}