mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-15 04:53:00 +08:00
367 lines
11 KiB
Go
367 lines
11 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/matthewlu070111/smart-srun/core/internal/application"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/config"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/domain"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/openwrt"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/wireless"
|
|
)
|
|
|
|
type WifiSetupView struct {
|
|
OK bool `json:"ok"`
|
|
Job string `json:"job"`
|
|
State string `json:"state"`
|
|
Message string `json:"message"`
|
|
Iface string `json:"iface,omitempty"`
|
|
Radio string `json:"radio,omitempty"`
|
|
Encryption string `json:"encryption,omitempty"`
|
|
ExpiresAt int64 `json:"expires_at,omitempty"`
|
|
}
|
|
|
|
type wifiJob struct {
|
|
view WifiSetupView
|
|
owner string
|
|
digest [32]byte
|
|
revision uint64
|
|
actionID string
|
|
cancelRequested bool
|
|
connection wizardConnection
|
|
group *wireless.Group
|
|
savedID string
|
|
saveDigest [32]byte
|
|
}
|
|
|
|
// The coordinator drains all workers before start/undo; the lease remains while
|
|
// the browser verifies the draft. mu protects only short status/lease changes.
|
|
// Network I/O takes device.mu, never this status mutex.
|
|
type wifiWizard struct {
|
|
mu sync.Mutex
|
|
device *deviceWireless
|
|
daemon *Daemon
|
|
jobs map[string]*wifiJob
|
|
order []string
|
|
active string
|
|
blocked bool
|
|
paths wireless.Paths
|
|
}
|
|
|
|
type wizardStore struct {
|
|
wireless.Store
|
|
runner openwrt.Runner
|
|
}
|
|
|
|
func (s wizardStore) Reload(ctx context.Context) error {
|
|
// Firewall first, so a new uplink never comes up before its WAN policy.
|
|
if _, err := s.runner.Run(ctx, "/etc/init.d/firewall", "reload"); err != nil {
|
|
return domain.Errorf(domain.CodeInternal, "重载防火墙配置失败").Wrap(err)
|
|
}
|
|
return s.Store.Reload(ctx)
|
|
}
|
|
|
|
func newWifiWizard(d *Daemon, device *deviceWireless) *wifiWizard {
|
|
return &wifiWizard{daemon: d, device: device, jobs: map[string]*wifiJob{}, paths: wireless.Paths{Dir: filepath.Join(d.paths.Recovery(), "setup-wifi")}}
|
|
}
|
|
|
|
func (m *wifiWizard) transactionStore() wireless.Store {
|
|
if m.device.wizardStore != nil {
|
|
return m.device.wizardStore
|
|
}
|
|
return wizardStore{Store: m.device.store}
|
|
}
|
|
|
|
func (m *wifiWizard) recover(ctx context.Context) error {
|
|
m.device.mu.Lock()
|
|
defer m.device.mu.Unlock()
|
|
g, found, err := wireless.LoadGroup(m.transactionStore(), m.paths, m.daemon.clock.Now)
|
|
if err == nil && found {
|
|
cfg := m.daemon.config.Snapshot()
|
|
data, _ := config.Marshal(cfg)
|
|
err = g.Recover(ctx, cfg.Revision, data)
|
|
}
|
|
m.mu.Lock()
|
|
m.blocked = err != nil
|
|
m.mu.Unlock()
|
|
return err
|
|
}
|
|
|
|
func (m *wifiWizard) check(request application.Request) error {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.blocked {
|
|
return domain.Errorf(domain.CodeRecoveryRequired, "上次无线向导需要恢复,暂不执行网络操作")
|
|
}
|
|
if m.active == "" {
|
|
return nil
|
|
}
|
|
job := m.jobs[m.active]
|
|
if request.Kind.WifiSetup() && request.SetupJob == m.active && request.Owner == job.owner {
|
|
return nil
|
|
}
|
|
if (job.view.State == "ready" || job.view.State == "connected") && request.Kind.Discovery() && request.Owner == job.owner && request.Interface == job.connection.Iface && request.ProbeSSID == job.connection.SSID && m.daemon.clock.Now().Unix() < job.view.ExpiresAt {
|
|
return nil
|
|
}
|
|
return domain.Errorf(domain.CodeBusy, "无线向导正在使用网络,请先完成或取消向导")
|
|
}
|
|
|
|
func (m *wifiWizard) configBlocked() bool {
|
|
if m == nil {
|
|
return false
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.blocked || m.active != ""
|
|
}
|
|
|
|
func (m *wifiWizard) view(job, owner string) (WifiSetupView, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
entry := m.jobs[job]
|
|
if entry == nil || entry.owner != owner {
|
|
return WifiSetupView{}, domain.Errorf(domain.CodeNotFound, "没有当前会话的无线任务")
|
|
}
|
|
return entry.view, nil
|
|
}
|
|
|
|
func (m *wifiWizard) publish(job, state, message string, release bool) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
entry := m.jobs[job]
|
|
entry.view.State, entry.view.Message = state, message
|
|
entry.view.OK = state != "failed"
|
|
if release {
|
|
entry.connection.Key = ""
|
|
entry.group = nil
|
|
if m.active == job {
|
|
m.active = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *wifiWizard) observe(action application.Action) {
|
|
if m == nil || action.Request.Kind != application.KindWifiSetupStart || !action.State.Terminal() {
|
|
return
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
entry := m.jobs[action.Request.SetupJob]
|
|
if entry != nil && entry.view.State == "starting" {
|
|
entry.view.State, entry.view.Message, entry.view.OK = "failed", action.Message, false
|
|
if action.State == application.StateCancelled || action.State == application.StateInterrupted {
|
|
entry.view.State = "cancelled"
|
|
entry.view.OK = true
|
|
}
|
|
if m.active == entry.view.Job {
|
|
m.active = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
type wifiRoutingRunner struct {
|
|
actions application.Runner
|
|
wizard *wifiWizard
|
|
}
|
|
|
|
func (r wifiRoutingRunner) Run(ctx context.Context, action application.Action, report func(application.Phase)) application.Outcome {
|
|
if !action.Request.Kind.WifiSetup() {
|
|
return r.actions.Run(ctx, action, report)
|
|
}
|
|
// Mounted even without a wizard, so this answers instead of the worker's
|
|
// "not implemented" fallback. A build on a router with no manageable radio
|
|
// has a capability problem, not a missing feature, and telling a user the
|
|
// wizard was never written sends them to the wrong place.
|
|
if r.wizard == nil {
|
|
return application.Outcome{State: application.StateFailed,
|
|
Code: domain.CodeUnsupportedCapability,
|
|
Message: "这台设备没有可管理的无线客户端,无法使用无线向导"}
|
|
}
|
|
var err error
|
|
if action.Request.Kind == application.KindWifiSetupStart {
|
|
err = r.wizard.start(ctx, action)
|
|
} else {
|
|
err = r.wizard.undo(ctx, action.Request.SetupJob)
|
|
}
|
|
if err != nil {
|
|
code, _ := domain.CodeOf(err)
|
|
return application.Outcome{State: application.StateFailed, Code: code, Message: err.Error()}
|
|
}
|
|
return application.Outcome{State: application.StateSucceeded, Message: "无线向导操作已完成"}
|
|
}
|
|
|
|
func (m *wifiWizard) start(ctx context.Context, action application.Action) error {
|
|
ctx, cancelWork := context.WithTimeout(ctx, 180*time.Second)
|
|
defer cancelWork()
|
|
var params WifiSetupParams
|
|
if err := json.Unmarshal([]byte(action.Request.PrivateJSON), ¶ms); err != nil {
|
|
return domain.Errorf(domain.CodeInvalidArgument, "无线连接参数无效")
|
|
}
|
|
w := m.device
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
m.mu.Lock()
|
|
entry := m.jobs[params.Job]
|
|
if entry == nil || m.active != params.Job || entry.cancelRequested {
|
|
m.mu.Unlock()
|
|
if entry != nil {
|
|
m.publish(params.Job, "cancelled", "无线连接已取消", true)
|
|
}
|
|
return domain.Errorf(domain.CodeCancelled, "无线连接已取消")
|
|
}
|
|
entry.view.State, entry.view.Message = "connecting", "正在连接所选无线网络"
|
|
m.mu.Unlock()
|
|
connection, plans, err := w.wizardPlan(ctx, params)
|
|
if err != nil {
|
|
m.publish(params.Job, "failed", err.Error(), true)
|
|
return err
|
|
}
|
|
var group *wireless.Group
|
|
if len(plans) > 0 {
|
|
group, err = wireless.BeginGroup(ctx, m.transactionStore(), m.paths, wireless.GroupPlan{TaskID: params.Job, ConfigRevision: entry.revision, ConfirmWithin: ConfirmWithin, Packages: plans}, m.daemon.clock.Now)
|
|
if err == nil {
|
|
err = group.Apply(ctx)
|
|
}
|
|
}
|
|
if err == nil {
|
|
settleCtx, cancelSettle := context.WithTimeout(ctx, w.settleWait)
|
|
defer cancelSettle()
|
|
deadline := m.daemon.clock.Now().Add(w.settleWait)
|
|
for !w.wizardAssociated(settleCtx, connection) {
|
|
if ctx.Err() != nil {
|
|
err = domain.Errorf(domain.CodeCancelled, "无线连接已取消")
|
|
break
|
|
}
|
|
if settleCtx.Err() != nil || !m.daemon.clock.Now().Before(deadline) {
|
|
err = domain.Errorf(domain.CodeDeadlineExceeded, "无线网络未在限时内关联并取得 IPv4 地址")
|
|
break
|
|
}
|
|
timer := m.daemon.clock.NewTimerAt(m.daemon.clock.Now().Add(w.settlePoll))
|
|
select {
|
|
case <-settleCtx.Done():
|
|
timer.Stop()
|
|
case <-timer.C():
|
|
}
|
|
}
|
|
}
|
|
if ctx.Err() != nil && err == nil {
|
|
err = domain.Errorf(domain.CodeCancelled, "无线连接已取消")
|
|
}
|
|
if err != nil {
|
|
undoCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), UndoBudget)
|
|
defer cancel()
|
|
if group == nil {
|
|
group, _, _ = wireless.LoadGroup(m.transactionStore(), m.paths, m.daemon.clock.Now)
|
|
}
|
|
var undoErr error
|
|
if group != nil {
|
|
undoErr = group.Rollback(undoCtx)
|
|
}
|
|
if undoErr != nil {
|
|
m.mu.Lock()
|
|
m.blocked = true
|
|
m.mu.Unlock()
|
|
err = domain.Errorf(domain.CodeRecoveryRequired, "无线连接失败,恢复尚未完成,请检查网络状态").Wrap(errors.Join(err, undoErr))
|
|
}
|
|
state := "failed"
|
|
if undoErr == nil && ctx.Err() == context.Canceled {
|
|
state = "cancelled"
|
|
}
|
|
m.publish(params.Job, state, err.Error(), true)
|
|
return err
|
|
}
|
|
m.mu.Lock()
|
|
entry.connection, entry.group = connection, group
|
|
entry.view.Iface, entry.view.Radio, entry.view.Encryption = connection.Iface, connection.Radio, connection.Encryption
|
|
entry.view.ExpiresAt = m.daemon.clock.Now().Add(ConfirmWithin).Unix()
|
|
if group != nil {
|
|
entry.view.ExpiresAt = group.ExpiresAt().Unix()
|
|
}
|
|
entry.view.State, entry.view.Message = "ready", "已连接,等待完成向导保存"
|
|
if connection.Reused {
|
|
entry.view.State, entry.view.Message = "connected", "已复用当前无线连接"
|
|
}
|
|
cancelRequested := entry.cancelRequested
|
|
m.mu.Unlock()
|
|
if cancelRequested {
|
|
return m.undoLocked(context.WithoutCancel(ctx), params.Job)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *wifiWizard) undo(ctx context.Context, job string) error {
|
|
m.device.mu.Lock()
|
|
defer m.device.mu.Unlock()
|
|
return m.undoLocked(ctx, job)
|
|
}
|
|
|
|
func (m *wifiWizard) undoLocked(ctx context.Context, job string) error {
|
|
m.mu.Lock()
|
|
entry := m.jobs[job]
|
|
if entry == nil || m.active != job {
|
|
m.mu.Unlock()
|
|
return nil
|
|
}
|
|
group := entry.group
|
|
m.mu.Unlock()
|
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), UndoBudget)
|
|
defer cancel()
|
|
if group != nil {
|
|
if err := group.Rollback(ctx); err != nil {
|
|
m.mu.Lock()
|
|
m.blocked = true
|
|
m.mu.Unlock()
|
|
m.publish(job, "failed", "无线连接恢复尚未完成,请检查网络状态", true)
|
|
return domain.Errorf(domain.CodeRecoveryRequired, "无线连接恢复尚未完成").Wrap(err)
|
|
}
|
|
}
|
|
m.publish(job, "cancelled", "已取消临时连接并恢复原配置", true)
|
|
return nil
|
|
}
|
|
|
|
func (m *wifiWizard) runExpiry(ctx context.Context) {
|
|
for {
|
|
timer := m.daemon.clock.NewTimerAt(m.daemon.clock.Now().Add(time.Second))
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return
|
|
case <-timer.C():
|
|
}
|
|
m.mu.Lock()
|
|
entry := m.jobs[m.active]
|
|
var job, owner string
|
|
if entry != nil && (entry.view.State == "ready" || entry.view.State == "connected") && m.daemon.clock.Now().Unix() >= entry.view.ExpiresAt {
|
|
job, owner = entry.view.Job, entry.owner
|
|
}
|
|
m.mu.Unlock()
|
|
if job != "" {
|
|
_, err := m.cancel(ctx, job, owner)
|
|
if err != nil && ctx.Err() == nil {
|
|
m.daemon.onError(err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *wifiWizard) shutdown() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), UndoBudget)
|
|
defer cancel()
|
|
m.mu.Lock()
|
|
active := m.active
|
|
m.mu.Unlock()
|
|
if active != "" {
|
|
if err := m.undo(ctx, active); err != nil {
|
|
m.daemon.onError(err)
|
|
}
|
|
}
|
|
}
|