mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-15 04:53:00 +08:00
353 lines
15 KiB
Go
353 lines
15 KiB
Go
// Package application is the coordinator: the single writer of account state,
|
|
// the action index and the queue.
|
|
//
|
|
// Everything that changes those happens on one goroutine. Callers send it a
|
|
// message and wait for the answer, and the workers that touch the network send
|
|
// their results back the same way. That is not a style preference: spec 02
|
|
// requires a single writer, and the alternative -- a mutex around a state
|
|
// struct that several goroutines update -- is what makes "the old worker's
|
|
// result overwrote the new one" possible in the first place. Here a late result
|
|
// arrives as a message, is compared against the current sequence, and is
|
|
// dropped.
|
|
//
|
|
// The scheduling arithmetic lives next door in policy, where it can be tested
|
|
// without starting anything.
|
|
package application
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/matthewlu070111/smart-srun/core/internal/domain"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/observe"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/policy"
|
|
)
|
|
|
|
// Kind is the closed set of things the coordinator can be asked to do.
|
|
//
|
|
// A closed set, not a string handed to a dispatcher: spec 03 requires unknown
|
|
// actions to be refused as InvalidArgument and forbids ever resolving a command
|
|
// name dynamically. The account and hotspot CRUD verbs the LuCI page also calls
|
|
// "actions" are not here -- those are configuration transactions and go through
|
|
// the config repository, which is a different kind of write with a different
|
|
// kind of failure.
|
|
type Kind string
|
|
|
|
const (
|
|
// KindLogin is an explicit manual login.
|
|
KindLogin Kind = "manual_login"
|
|
// KindLogout is an explicit manual logout.
|
|
KindLogout Kind = "manual_logout"
|
|
// KindRelogin is logout followed by login on the same line.
|
|
KindRelogin Kind = "relogin"
|
|
// KindSwitchCampus changes the active campus account and authenticates it.
|
|
KindSwitchCampus Kind = "switch_campus"
|
|
// KindSwitchHotspot moves the uplink to a hotspot profile.
|
|
KindSwitchHotspot Kind = "switch_hotspot"
|
|
// KindMaintain is one round of the automatic authentication loop. It is
|
|
// scheduler-only: no RPC submits it.
|
|
KindMaintain Kind = "maintain"
|
|
// KindForcedLogout is one account's share of the quiet-hours sweep.
|
|
KindForcedLogout Kind = "forced_logout"
|
|
// Quiet transitions are scheduler-only and retain automatic identity rules.
|
|
KindQuietHotspot Kind = "quiet_hotspot"
|
|
KindQuietCampus Kind = "quiet_campus"
|
|
// KindPresetsRefresh reads a selected line without authenticating an account.
|
|
KindPresetsRefresh Kind = "presets_refresh"
|
|
// KindDetectACID reads portal pages on a selected line without credentials.
|
|
KindDetectACID Kind = "detect_acid"
|
|
KindDetectEnvironment Kind = "detect_environment"
|
|
KindDetectOperators Kind = "detect_operators"
|
|
KindDetectIdentity Kind = "detect_identity"
|
|
KindDetectVerify Kind = "detect_verify"
|
|
KindWifiSetupStart Kind = "wifi_setup_start"
|
|
KindWifiSetupCancel Kind = "wifi_setup_cancel"
|
|
)
|
|
|
|
var kinds = []Kind{KindLogin, KindLogout, KindRelogin, KindSwitchCampus,
|
|
KindSwitchHotspot, KindMaintain, KindForcedLogout, KindQuietHotspot, KindQuietCampus, KindPresetsRefresh, KindDetectACID, KindDetectEnvironment, KindDetectOperators, KindDetectIdentity, KindDetectVerify, KindWifiSetupStart, KindWifiSetupCancel}
|
|
|
|
func (k Kind) WifiSetup() bool { return k == KindWifiSetupStart || k == KindWifiSetupCancel }
|
|
|
|
func (k Kind) Discovery() bool {
|
|
return k == KindDetectACID || k == KindDetectEnvironment || k == KindDetectOperators || k == KindDetectIdentity || k == KindDetectVerify
|
|
}
|
|
|
|
func (k Kind) Valid() bool { return slices.Contains(kinds, k) }
|
|
|
|
func (k Kind) switches() bool {
|
|
return k == KindLogout || k == KindSwitchCampus || k == KindSwitchHotspot || k == KindQuietHotspot || k == KindQuietCampus || k.WifiSetup()
|
|
}
|
|
|
|
// Priority is where this kind sits in spec 04's order.
|
|
func (k Kind) Priority() policy.Priority {
|
|
switch k {
|
|
case KindPresetsRefresh:
|
|
return policy.PriorityPeriodic
|
|
case KindMaintain:
|
|
return policy.PriorityMaintenance
|
|
case KindForcedLogout, KindQuietHotspot, KindQuietCampus:
|
|
return policy.PriorityQuietBoundary
|
|
default:
|
|
return policy.PriorityUserAction
|
|
}
|
|
}
|
|
|
|
// Manual reports whether a user asked for this directly. Manual actions still
|
|
// run while automatic authentication is switched off.
|
|
func (k Kind) Manual() bool {
|
|
return k != KindMaintain && k != KindForcedLogout && k != KindQuietHotspot && k != KindQuietCampus && k != KindPresetsRefresh
|
|
}
|
|
|
|
// State is the action lifecycle from spec 04. The four terminal states are
|
|
// irreversible.
|
|
type State string
|
|
|
|
const (
|
|
StateQueued State = "queued"
|
|
StateRunning State = "running"
|
|
|
|
StateSucceeded State = "succeeded"
|
|
StateFailed State = "failed"
|
|
StateCancelled State = "cancelled"
|
|
// StateInterrupted is an action that was still going when the service
|
|
// stopped. It is not "failed": spec 02 forbids replaying an action the user
|
|
// force-stopped, and the two need to be distinguishable to obey that.
|
|
StateInterrupted State = "interrupted"
|
|
)
|
|
|
|
func (s State) Terminal() bool {
|
|
switch s {
|
|
case StateSucceeded, StateFailed, StateCancelled, StateInterrupted:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Phase is progress within a running action.
|
|
//
|
|
// Spec 04 requires it separately from State because the last log line is not a
|
|
// completion signal: an action stuck in `challenge` and an action stuck in
|
|
// `verify` are different problems, and neither is visible from "running".
|
|
type Phase string
|
|
|
|
const (
|
|
PhaseWaitingLink Phase = "waiting_link"
|
|
PhaseChallenge Phase = "challenge"
|
|
PhaseLogin Phase = "login"
|
|
PhaseVerify Phase = "verify"
|
|
PhaseLogout Phase = "logout"
|
|
PhaseSwitch Phase = "switch"
|
|
PhaseFetch Phase = "fetch"
|
|
)
|
|
|
|
// Request is one submission.
|
|
type Request struct {
|
|
Kind Kind
|
|
AccountID string
|
|
// HotspotID is only meaningful for KindSwitchHotspot.
|
|
HotspotID string
|
|
// Interface is an explicit uplink for account-free catalogue refreshes.
|
|
Interface string
|
|
// Probe fields contain only the draft address and selected network, never
|
|
// credentials. Strings keep action copies immutable across worker boundaries.
|
|
ProbeURL string
|
|
ProbeSSID string
|
|
ProbeMode string
|
|
ProbeSchool string
|
|
ProbeACID string
|
|
Owner string
|
|
SetupJob string
|
|
// PrivateJSON carries an unsaved wizard draft to its worker only. Readers
|
|
// and observers receive a redacted copy; terminal history keeps only its
|
|
// digest so duplicate submissions remain comparable after erasure.
|
|
PrivateJSON string `json:"-"`
|
|
privateDigest [32]byte
|
|
// IdempotencyKey is supplied by the caller -- the LuCI click id, or the CLI
|
|
// invocation. Resubmitting the same key returns the same action instead of
|
|
// starting a second one, which is what makes a double-click harmless.
|
|
IdempotencyKey string
|
|
// IgnoreQuiet is the single-action quiet-hours override from spec 04. It
|
|
// applies to this action and nothing else: the maintenance loop behind it
|
|
// stays suspended.
|
|
IgnoreQuiet bool
|
|
// CheckRevision distinguishes an unchecked internal request from revision 0.
|
|
CheckRevision bool
|
|
ConfigRevision uint64
|
|
}
|
|
|
|
// fingerprint is everything about a request except its key.
|
|
//
|
|
// Two submissions sharing a key must be the same request. If they are not, the
|
|
// caller has reused a key for different work, and returning the first action's
|
|
// id would report the wrong thing as finished -- so it is a Conflict. The
|
|
// fields are joined with a separator that cannot appear in an id, so no pair of
|
|
// different requests can collide.
|
|
func (r Request) fingerprint() string {
|
|
quiet := "0"
|
|
if r.IgnoreQuiet {
|
|
quiet = "1"
|
|
}
|
|
return string(r.Kind) + "\x00" + r.AccountID + "\x00" + r.HotspotID + "\x00" + quiet + "\x00" + r.Interface + "\x00" + strconv.FormatBool(r.CheckRevision) + ":" + strconv.FormatUint(r.ConfigRevision, 10) + "\x00" + r.ProbeURL + "\x00" + r.ProbeSSID + "\x00" + r.ProbeMode + "\x00" + r.ProbeSchool + "\x00" + r.ProbeACID + "\x00" + r.Owner + "\x00" + hex.EncodeToString(r.privateDigest[:]) + "\x00" + r.SetupJob
|
|
}
|
|
|
|
// Validate refuses a request the coordinator could not act on.
|
|
func (r Request) Validate() error {
|
|
if len(r.PrivateJSON) > 16<<10 || (r.PrivateJSON != "" && r.Kind != KindDetectIdentity && r.Kind != KindDetectVerify && r.Kind != KindWifiSetupStart) {
|
|
return domain.Errorf(domain.CodeInvalidArgument, "此任务不接受私有参数或参数过长")
|
|
}
|
|
if !r.Kind.Valid() {
|
|
return domain.Errorf(domain.CodeInvalidArgument, "未知的动作类型:%q", string(r.Kind))
|
|
}
|
|
if r.Kind.WifiSetup() {
|
|
if len(r.SetupJob) != 32 || strings.Trim(r.SetupJob, "0123456789abcdef") != "" || r.AccountID != "" || r.HotspotID != "" || r.IgnoreQuiet {
|
|
return domain.Errorf(domain.CodeInvalidArgument, "无线向导参数无效")
|
|
}
|
|
} else if r.SetupJob != "" {
|
|
return domain.Errorf(domain.CodeInvalidArgument, "此动作不接受无线向导任务")
|
|
}
|
|
if r.Kind == KindPresetsRefresh || r.Kind.Discovery() {
|
|
if r.Interface == "" || len(r.Interface) > 64 || strings.ContainsAny(r.Interface, "\x00\r\n\t /\\") {
|
|
return domain.Errorf(domain.CodeInvalidArgument, "需要明确的有效线路 iface")
|
|
}
|
|
if r.AccountID != "" || r.HotspotID != "" || r.IgnoreQuiet {
|
|
return domain.Errorf(domain.CodeInvalidArgument, "此任务不接受账号、热点或静默覆盖参数")
|
|
}
|
|
} else if r.Interface != "" {
|
|
return domain.Errorf(domain.CodeInvalidArgument, "此动作不接受 iface")
|
|
}
|
|
if !r.Kind.Discovery() && (r.ProbeURL != "" || r.ProbeSSID != "" || r.ProbeMode != "" || r.ProbeSchool != "" || r.ProbeACID != "") {
|
|
return domain.Errorf(domain.CodeInvalidArgument, "此动作不接受探测参数")
|
|
}
|
|
if r.Kind.Discovery() && ((r.Kind != KindDetectEnvironment && r.ProbeURL == "") || len(r.ProbeURL) > 2048 || len(r.ProbeSSID) > 32 || len(r.ProbeSchool) > 128 || len(r.ProbeACID) > 64 || strings.ContainsAny(r.ProbeURL+r.ProbeSSID+r.ProbeMode+r.ProbeSchool+r.ProbeACID, "\x00\r\n")) {
|
|
return domain.Errorf(domain.CodeInvalidArgument, "探测参数无效")
|
|
}
|
|
if r.AccountID == "" && r.Kind != KindSwitchHotspot && r.Kind != KindPresetsRefresh && !r.Kind.Discovery() && !r.Kind.WifiSetup() {
|
|
return domain.Errorf(domain.CodeInvalidArgument, "动作 %s 需要指定账号", string(r.Kind))
|
|
}
|
|
if (r.Kind == KindSwitchHotspot || r.Kind == KindQuietHotspot || r.Kind == KindQuietCampus) && r.HotspotID == "" {
|
|
return domain.Errorf(domain.CodeInvalidArgument, "切换热点需要指定热点")
|
|
}
|
|
if r.IdempotencyKey == "" {
|
|
return domain.Errorf(domain.CodeInvalidArgument, "动作需要一个幂等键")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Action is the coordinator's record of one submission.
|
|
//
|
|
// It is copied out to callers, never handed out by pointer: a reader holding a
|
|
// pointer into the coordinator's state would be a second writer's worth of
|
|
// races away from the single-writer rule.
|
|
type Action struct {
|
|
ID string
|
|
Request Request
|
|
// Line is the resolved line this action occupies while it runs. Two actions
|
|
// on one line never overlap.
|
|
Line string
|
|
|
|
State State
|
|
Phase Phase
|
|
Message string
|
|
Code domain.ErrorCode
|
|
// ResultJSON is a bounded, credential-free task result. Only action.get
|
|
// publishes it; periodic snapshots do not duplicate retained task payloads.
|
|
ResultJSON string
|
|
|
|
QueuedAt time.Time
|
|
StartedAt time.Time
|
|
EndedAt time.Time
|
|
// Timings are complete only after the worker exits, including cancellation
|
|
// cleanup. They do not infer packet loss or connection continuity.
|
|
Timings []PhaseTiming
|
|
WorkerMilliseconds int64
|
|
|
|
// Sequence is assigned at dispatch and increases forever. A result carrying
|
|
// an older sequence belongs to a worker that was cancelled and finished
|
|
// anyway; it is dropped rather than applied.
|
|
Sequence uint64
|
|
// MaintenanceDeferred means the current uplink intentionally is a hotspot;
|
|
// this is a pause, not a failed password attempt that spends retry budget.
|
|
MaintenanceDeferred bool
|
|
// Connectivity is copied from the worker's Outcome for the daemon's
|
|
// DEBUG log; it is not part of any RPC view.
|
|
Connectivity *ConnectivityDiagnosis
|
|
|
|
// ordinal is submission order. It breaks ties in the queue, where the id
|
|
// string cannot: "a10" sorts before "a2".
|
|
ordinal uint64
|
|
// retired records that this action has already been filed in the bounded
|
|
// terminal history, so the two paths that can finish one -- a cancellation
|
|
// and a worker's result -- cannot file it twice.
|
|
retired bool
|
|
}
|
|
|
|
// transition moves to the next state, refusing to leave a terminal one.
|
|
func (a *Action) transition(next State, at time.Time) bool {
|
|
if a.State.Terminal() {
|
|
return false
|
|
}
|
|
a.State = next
|
|
if next == StateRunning {
|
|
a.StartedAt = at
|
|
}
|
|
if next.Terminal() {
|
|
a.EndedAt = at
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Outcome is what a Runner reports.
|
|
type Outcome struct {
|
|
// State must be StateSucceeded or StateFailed. Anything else is a Runner
|
|
// that decided the action's fate on its own; the coordinator owns that.
|
|
State State
|
|
Message string
|
|
Code domain.ErrorCode
|
|
MaintenanceDeferred bool
|
|
|
|
// Observation is what the attempt learned about the line, when it learned
|
|
// anything. It travels back with the result rather than being written by
|
|
// the worker directly: spec 02 makes the coordinator the one place that
|
|
// decides whether a worker's answer is still current, and a worker writing
|
|
// the shared projection itself would be deciding that for itself -- which
|
|
// is the second writer this whole design exists to avoid.
|
|
Observation *observe.Observation
|
|
ResultJSON string
|
|
// Connectivity is the Internet check's diagnosis, for a DEBUG line. It is
|
|
// never shown to a user as the result message.
|
|
Connectivity *ConnectivityDiagnosis
|
|
}
|
|
|
|
// Runner performs one action.
|
|
//
|
|
// This is the seam between the coordinator, which is scheduling and nothing
|
|
// else, and the packages that touch the network. It is defined here because
|
|
// this is the consumer: spec 02 asks for interfaces where they are used, and
|
|
// the alternative -- auth exporting a Coordinator-shaped interface -- would
|
|
// point the dependency the wrong way.
|
|
//
|
|
// report is how progress reaches the state a user polls. It may be called any
|
|
// number of times and never blocks the coordinator's loop.
|
|
type Runner interface {
|
|
Run(ctx context.Context, action Action, report func(Phase)) Outcome
|
|
}
|
|
|
|
// Receipt is what a submitter gets back. Accepting an action is not performing
|
|
// it: spec 03 is explicit that queued does not mean succeeded, so the receipt
|
|
// carries an id to poll rather than a result.
|
|
type Receipt struct {
|
|
ActionID string
|
|
State State
|
|
// Duplicate says the key matched an action that already existed, so nothing
|
|
// new was started.
|
|
Duplicate bool
|
|
}
|
|
|
|
func formatID(n uint64) string { return "a" + strconv.FormatUint(n, 10) }
|