mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-15 04:53:00 +08:00
339 lines
13 KiB
Go
339 lines
13 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/matthewlu070111/smart-srun/core/internal/application"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/domain"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/openwrt"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/wifi"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/wireless"
|
|
)
|
|
|
|
// ConfirmWithin is how long an applied change has to be confirmed before
|
|
// recovery undoes it.
|
|
//
|
|
// Spec 04 gives the wizard fifteen minutes. A change this program makes on its
|
|
// own is confirmed as soon as the line comes up, so the window only matters
|
|
// when the process dies between applying and confirming -- which is exactly
|
|
// when a router would otherwise be left on a network nobody reported reaching.
|
|
const ConfirmWithin = 15 * time.Minute
|
|
|
|
// Apply moves the managed client and waits for the result to be usable.
|
|
//
|
|
// The sequence is spec 04's: refuse to start on somebody else's half-finished
|
|
// edit, back up, stage into an isolated directory, apply, reload, wait up to a
|
|
// minute for a real association and an address, and only then confirm. A change
|
|
// that does not produce a usable line is undone rather than left in place -- a
|
|
// router on a network it cannot reach is worse than one on the network it was
|
|
// already on.
|
|
func (w *deviceWireless) Apply(ctx context.Context, plan application.WirelessPlan) error {
|
|
// One at a time. See the comment on the field: the coordinator serialises
|
|
// actions, and this serialises the device.
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
if plan.Radio == "" {
|
|
return domain.FieldErrorf(domain.CodeInvalidConfig, "radio",
|
|
"没有指定要使用的无线电")
|
|
}
|
|
if plan.SSID == "" {
|
|
return domain.FieldErrorf(domain.CodeInvalidConfig, "ssid",
|
|
"没有指定要连接的 SSID")
|
|
}
|
|
|
|
section := stationSection(plan.Radio)
|
|
application.ReportPhase(ctx, application.PhasePrepare)
|
|
changes, err := w.changesFor(ctx, section, plan)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return w.applyChanges(ctx, changes, func() error { return w.awaitLine(ctx, plan) })
|
|
}
|
|
|
|
// applyChanges is shared by joining and retiring an uplink. The caller holds mu.
|
|
func (w *deviceWireless) applyChanges(ctx context.Context, changes []wireless.Change, verify func() error) error {
|
|
w.tasks++
|
|
transaction, err := wireless.Begin(ctx, w.store, w.paths, wireless.Plan{
|
|
TaskID: fmt.Sprintf("wl-%d-%d", w.clock.Now().Unix(), w.tasks),
|
|
Package: WirelessPackage,
|
|
Changes: changes,
|
|
ConfigRevision: w.settings.Revision(),
|
|
ConfirmWithin: ConfirmWithin,
|
|
}, w.clock.Now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
application.ReportPhase(ctx, application.PhaseActivate)
|
|
if err := transaction.Apply(ctx, changes); err != nil {
|
|
// Applied or not, the journal says where it got to, and rollback reads
|
|
// that rather than guessing. A failure before the commit has nothing to
|
|
// undo and says so.
|
|
return w.undo(ctx, transaction, err)
|
|
}
|
|
if err := transaction.AwaitConfirm(); err != nil {
|
|
return w.undo(ctx, transaction, err)
|
|
}
|
|
|
|
if err := verify(); err != nil {
|
|
return w.undo(ctx, transaction, err)
|
|
}
|
|
application.ReportPhase(ctx, application.PhaseCommit)
|
|
return transaction.Confirm()
|
|
}
|
|
|
|
// RecoverInterrupted deals with a change a previous run left open.
|
|
//
|
|
// Called once at startup, under the same lock everything else takes, and before
|
|
// any action is scheduled. Spec 04 puts three outcomes here and the difference
|
|
// between them matters: a change from the configuration that is still current
|
|
// and still inside its confirmation window is left alone, one whose window has
|
|
// passed is rolled back, and one whose options somebody else has since edited
|
|
// is reported rather than reverted over the top of them.
|
|
func (w *deviceWireless) RecoverInterrupted(ctx context.Context) error {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
outcome, acted, err := wireless.Recover(ctx, w.store, w.paths,
|
|
w.settings.Revision(), w.clock.Now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !acted || len(outcome.Conflicts) == 0 {
|
|
return nil
|
|
}
|
|
// Reported even though Recover did not fail: "recovery required" with no
|
|
// detail tells a person there is a problem and nothing about where.
|
|
return domain.Errorf(domain.CodeRecoveryRequired,
|
|
"上次无线改动有 %d 项已被其他地方修改,未予还原;需要人工确认",
|
|
len(outcome.Conflicts))
|
|
}
|
|
|
|
// UndoBudget is how long the rollback gets when the change's own context is
|
|
// already dead.
|
|
//
|
|
// Under the coordinator's ten-second shutdown grace on purpose. The undo runs
|
|
// on a context of its own -- a switch the user cancelled cannot unwind itself
|
|
// with a context that was cancelled, and leaving it unwound means leaving a
|
|
// journal on disk that Begin refuses to start on top of, so the next wireless
|
|
// change is blocked until a restart. That is the failure this budget exists to
|
|
// avoid.
|
|
//
|
|
// Bounded rather than generous because the other side of it is a service stop:
|
|
// an undo that outlasted the grace would be reported as a worker that ignored
|
|
// its cancellation. Running out of budget is survivable and already tested --
|
|
// the journal says rolling_back and the next start resumes it -- while hanging
|
|
// the stop is not.
|
|
const UndoBudget = 8 * time.Second
|
|
|
|
// undo rolls the change back and reports which of the two failures the caller
|
|
// is looking at.
|
|
//
|
|
// The original cause survives: "could not reach the network" and "could not
|
|
// reach the network, and could not get back either" are different situations
|
|
// for whoever has to fix it, and the second must not hide the first.
|
|
func (w *deviceWireless) undo(ctx context.Context, transaction *wireless.Transaction,
|
|
cause error) error {
|
|
application.ReportPhase(ctx, application.PhaseRollback)
|
|
|
|
if ctx.Err() != nil {
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeout(context.WithoutCancel(ctx), UndoBudget)
|
|
defer cancel()
|
|
}
|
|
|
|
outcome, err := transaction.Rollback(ctx)
|
|
if err != nil {
|
|
return domain.Errorf(domain.CodeRecoveryRequired,
|
|
"无线改动失败后回滚也没有成功(%d 项已还原,%d 项冲突):%s",
|
|
len(outcome.Restored), len(outcome.Conflicts), cause.Error()).Wrap(err)
|
|
}
|
|
return cause
|
|
}
|
|
|
|
// awaitLine waits for the radio to be on the right network with an address.
|
|
//
|
|
// Both halves are required and spec 04 says why: a scan result proves an access
|
|
// point exists, an association proves the client joined it, and only an address
|
|
// proves the line can carry anything. Authenticating over a client that
|
|
// associated and got nothing is a request that goes nowhere.
|
|
func (w *deviceWireless) awaitLine(ctx context.Context, plan application.WirelessPlan) error {
|
|
wait := w.settleWait
|
|
if seconds := w.settings.Snapshot().Checks.SwitchTimeoutSeconds; seconds > 0 {
|
|
// Keep the transaction's sixty-second safety ceiling even if the
|
|
// shared configuration allows a longer outer readiness budget.
|
|
wait = min(wait, time.Duration(seconds)*time.Second)
|
|
}
|
|
deadline := w.clock.Now().Add(wait)
|
|
application.ReportPhase(ctx, application.PhaseAssociation)
|
|
target := wifi.Target{SSID: plan.SSID, Security: wifi.ParseSecurity(plan.Encryption), Policy: domain.APSelectionAuto}
|
|
if plan.BSSID != "" {
|
|
target.Policy, target.PinnedBSSID = domain.APSelectionFixed, plan.BSSID
|
|
}
|
|
var last error
|
|
for {
|
|
observed, err := w.Association(ctx, plan.Radio)
|
|
switch {
|
|
case err != nil:
|
|
last = err
|
|
case target.Satisfied(observed):
|
|
return nil
|
|
case observed.Joined() && observed.SSID == plan.SSID &&
|
|
((target.Security.Protected() && !observed.Encrypted) || (plan.BSSID != "" && !strings.EqualFold(plan.BSSID, observed.BSSID))):
|
|
last = domain.Errorf(domain.CodeBindingChanged, "实际无线关联与所选接入点或加密要求不符")
|
|
default:
|
|
last = describeWait(plan, observed)
|
|
}
|
|
if err == nil && observed.Joined() && observed.SSID == plan.SSID {
|
|
application.ReportPhase(ctx, application.PhaseAddress)
|
|
} else {
|
|
application.ReportPhase(ctx, application.PhaseAssociation)
|
|
}
|
|
|
|
if !w.clock.Now().Before(deadline) {
|
|
return domain.Errorf(domain.CodeDeadlineExceeded,
|
|
"切换到 %s 后 %s 内没有连上并拿到地址:%s",
|
|
plan.SSID, wait, last.Error())
|
|
}
|
|
wake := w.clock.Now().Add(w.settlePoll)
|
|
if deadline.Before(wake) {
|
|
wake = deadline
|
|
}
|
|
timer := w.clock.NewTimerAt(wake)
|
|
select {
|
|
case <-timer.C():
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return ctx.Err()
|
|
}
|
|
timer.Stop()
|
|
}
|
|
}
|
|
|
|
// describeWait says which of the three things is still missing.
|
|
//
|
|
// "Timed out" names the clock and nothing else. Whether the client never
|
|
// associated, associated with the wrong network, or associated and got no
|
|
// lease are three different faults with three different fixes.
|
|
func describeWait(plan application.WirelessPlan, observed wifi.Association) error {
|
|
switch {
|
|
case !observed.Joined():
|
|
return domain.Errorf(domain.CodeBindingUnavailable, "还没有关联到任何接入点")
|
|
case observed.SSID != plan.SSID:
|
|
return domain.Errorf(domain.CodeConflict,
|
|
"关联到的是 %s,不是 %s", observed.SSID, plan.SSID)
|
|
default:
|
|
return domain.Errorf(domain.CodeBindingUnavailable,
|
|
"已关联 %s,但还没有取得 IPv4 地址", observed.SSID)
|
|
}
|
|
}
|
|
|
|
// changesFor builds the uci changes one plan asks for.
|
|
//
|
|
// Everything this program owns and nothing else: the client section on the
|
|
// chosen radio, and the disabled flag on the other clients it also owns. Spec
|
|
// 04 forbids touching the home access point, and the way to keep that promise
|
|
// is for no change naming one to be constructible here.
|
|
func (w *deviceWireless) changesFor(ctx context.Context, section string,
|
|
plan application.WirelessPlan) ([]wireless.Change, error) {
|
|
|
|
config, err := w.adapter.UCI(ctx, WirelessPackage)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
option := func(name, text string) wireless.Change {
|
|
return wireless.Change{
|
|
Key: wireless.Key{Section: section, Option: name},
|
|
Text: text,
|
|
}
|
|
}
|
|
// An absent option, not an empty one: uci has no empty option, so asking
|
|
// for one writes nothing and leaves the journal describing a value that was
|
|
// never there. Measured, and the 1.x runtime deletes these for the same
|
|
// reason.
|
|
remove := func(name string) wireless.Change {
|
|
return wireless.Change{
|
|
Key: wireless.Key{Section: section, Option: name},
|
|
Delete: true,
|
|
}
|
|
}
|
|
|
|
changes := []wireless.Change{
|
|
// The section first. Stage orders them anyway, but a plan that reads in
|
|
// the order it happens is easier to check against the journal.
|
|
{Key: wireless.Key{Section: section}, Text: openwrt.SectionWifiIface},
|
|
option("device", plan.Radio),
|
|
option("mode", "sta"),
|
|
option("network", uplinkInterface),
|
|
option("ssid", plan.SSID),
|
|
option(openwrt.ManagedMarker, "1"),
|
|
option("disabled", "0"),
|
|
}
|
|
|
|
if plan.Encryption == "" || strings.EqualFold(plan.Encryption, "none") {
|
|
changes = append(changes, option("encryption", "none"), remove("key"))
|
|
} else {
|
|
changes = append(changes, option("encryption", plan.Encryption))
|
|
if plan.Key == "" {
|
|
return nil, domain.FieldErrorf(domain.CodeInvalidConfig, "key",
|
|
"%s 需要密码,但没有配置", plan.SSID)
|
|
}
|
|
changes = append(changes, option("key", plan.Key))
|
|
}
|
|
|
|
if plan.BSSID == "" {
|
|
changes = append(changes, remove("bssid"))
|
|
} else {
|
|
changes = append(changes, option("bssid", strings.ToLower(plan.BSSID)))
|
|
}
|
|
|
|
// Every other client this program owns goes down. Two enabled clients on
|
|
// one radio is the ambiguity openwrt.StationOnRadio refuses to resolve, and
|
|
// leaving an old one up is how a router ends up authenticating over the
|
|
// network it was told to leave.
|
|
for _, station := range openwrt.ManagedStations(config, section, knownSSIDs(w.settings)) {
|
|
if station.Section == section || station.Disabled {
|
|
continue
|
|
}
|
|
changes = append(changes, wireless.Change{
|
|
Key: wireless.Key{Section: station.Section, Option: "disabled"},
|
|
Text: "1",
|
|
})
|
|
}
|
|
return changes, nil
|
|
}
|
|
|
|
// uplinkInterface is the logical interface a managed client attaches to.
|
|
//
|
|
// `wwan` is what the 1.x runtime writes and what the router this was measured
|
|
// on has: the client sections are on wwan and the household's access points are
|
|
// on lan. Writing lan instead would put the campus network inside the home
|
|
// network's bridge.
|
|
const uplinkInterface = "wwan"
|
|
|
|
// knownSSIDs is every campus and hotspot network the configuration names.
|
|
//
|
|
// It is what lets a client the user built by hand be adopted rather than
|
|
// duplicated -- and what stops a home network being adopted, since one whose
|
|
// SSID is not in the configuration is never in this list.
|
|
func knownSSIDs(settings application.Settings) []string {
|
|
cfg := settings.Snapshot()
|
|
names := make([]string, 0, len(cfg.CampusAccounts)+len(cfg.HotspotProfiles))
|
|
for _, account := range cfg.CampusAccounts {
|
|
if account.SSID != "" {
|
|
names = append(names, account.SSID)
|
|
}
|
|
}
|
|
for _, hotspot := range cfg.HotspotProfiles {
|
|
if hotspot.SSID != "" {
|
|
names = append(names, hotspot.SSID)
|
|
}
|
|
}
|
|
return names
|
|
}
|