167 lines
5.1 KiB
Go
167 lines
5.1 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: 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: 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
|
|
Err error
|
|
}
|
|
type ResetResult struct {
|
|
Cancelled bool
|
|
Targets []ResetTargetResult
|
|
Err error
|
|
}
|
|
|
|
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)
|
|
detail, outcome := operation.Inspection.Evidence, "success"
|
|
if operation.Err != nil {
|
|
outcome = "failure"
|
|
detail = operation.Err.Error()
|
|
result.Err = errors.Join(result.Err, fmt.Errorf("reset %s Target: %w", adapter.ID(), redactError(operation.Err)))
|
|
}
|
|
detail = RedactLogDetail(detail)
|
|
if m.logger != nil {
|
|
_ = m.logger.Write(LogEntry{Time: time.Now(), Operation: "reset", Target: adapter.ID(), Result: outcome, Detail: detail})
|
|
}
|
|
result.Targets = append(result.Targets, ResetTargetResult{TargetID: adapter.ID(), Inspection: operation.Inspection, Err: redactError(operation.Err)})
|
|
}
|
|
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()))
|
|
}
|