diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ee300d0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Go tests + run: go test ./... + + - name: Build server + run: go build ./cmd/server + + - name: JavaScript syntax + shell: bash + run: | + set -euo pipefail + for file in web/js/*.js; do + case "$file" in + *jszip.min.js) continue ;; + esac + node --input-type=module --check < "$file" + done diff --git a/cmd/server/api.go b/cmd/server/api.go index e79ab15..ebeb7e4 100644 --- a/cmd/server/api.go +++ b/cmd/server/api.go @@ -4214,14 +4214,14 @@ func sanitizeIPForPath(ip string) string { return strings.ReplaceAll(ip, ":", "-") } -// BatchConfig pushes configuration changes to multiple devices +// BatchConfig pushes configuration changes to multiple devices. func (a *API) BatchConfig(w http.ResponseWriter, r *http.Request) { if !a.requireEdit(w, r) { return } claims := getClaims(r) if claims == nil { - http.Error(w, "unauthorized", 401) + http.Error(w, "unauthorized", http.StatusUnauthorized) return } @@ -4229,15 +4229,63 @@ func (a *API) BatchConfig(w http.ResponseWriter, r *http.Request) { DeviceIDs []int `json:"device_ids"` Changes map[string]any `json:"changes"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request", 400) + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256<<10)) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + if len(req.DeviceIDs) == 0 || len(req.DeviceIDs) > 2500 { + http.Error(w, "device_ids must contain 1-2500 devices", http.StatusBadRequest) return } + if len(req.Changes) == 0 { + http.Error(w, "at least one configuration change is required", http.StatusBadRequest) + return + } + for key, value := range req.Changes { + switch key { + case "ssid": + v, ok := value.(string) + if !ok || strings.TrimSpace(v) == "" || len([]byte(v)) > 64 { + http.Error(w, "invalid ssid", http.StatusBadRequest) + return + } + case "channel": + v, ok := value.(float64) + if !ok || math.IsNaN(v) || math.IsInf(v, 0) || v <= 0 { + http.Error(w, "invalid channel", http.StatusBadRequest) + return + } + case "tx_power": + v, ok := value.(float64) + if !ok || math.IsNaN(v) || math.IsInf(v, 0) { + http.Error(w, "invalid tx_power", http.StatusBadRequest) + return + } + case "password": + v, ok := value.(string) + if !ok || v == "" || len(v) > 4096 { + http.Error(w, "invalid password", http.StatusBadRequest) + return + } + default: + http.Error(w, "unsupported configuration field: "+key, http.StatusBadRequest) + return + } + } - var results []map[string]any + changeKeys := make([]string, 0, len(req.Changes)) + for key := range req.Changes { + changeKeys = append(changeKeys, key) + } + sort.Strings(changeKeys) + changeSummary := "Batch config applied: " + strings.Join(changeKeys, ", ") + + results := make([]map[string]any, 0, len(req.DeviceIDs)) for _, deviceID := range req.DeviceIDs { var ip, mac, username, password string - err := a.DB.QueryRow(` + err := a.DB.QueryRowContext(r.Context(), ` SELECT host(ip_address), mac, COALESCE(username, ''), COALESCE(password, '') FROM devices WHERE id = $1 `, deviceID).Scan(&ip, &mac, &username, &password) @@ -4245,19 +4293,13 @@ func (a *API) BatchConfig(w http.ResponseWriter, r *http.Request) { results = append(results, map[string]any{"device_id": deviceID, "status": "failed", "error": "not found"}) continue } - - err = a.Firmware.ApplyConfig(ip, username, password, req.Changes) - if err != nil { + if err := a.Firmware.ApplyConfig(deviceID, ip, username, password, req.Changes); err != nil { results = append(results, map[string]any{"device_id": deviceID, "status": "failed", "error": err.Error()}) continue } - - // Log - a.logChangelogDevice(mac, fmt.Sprintf("Batch config applied: %v", req.Changes), claims.UserID) - + a.logChangelogDevice(mac, changeSummary, claims.UserID) results = append(results, map[string]any{"device_id": deviceID, "status": "success"}) } - writeJSON(w, map[string]any{"results": results}) } diff --git a/cmd/server/main.go b/cmd/server/main.go index edc261f..adfc2d9 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -241,10 +241,10 @@ func main() { devicePoller := poller.NewPoller(db, statsStore, wsHub, pollerCfg) // Create scheduler - jobScheduler := scheduler.NewScheduler(db, fwService, wsHub) + jobScheduler := scheduler.NewScheduler(db, fwService, wsHub, devicePoller) // Create job runner for async operations - jobRunner := jobs.NewRunner(db, fwService, wsHub) + jobRunner := jobs.NewRunner(db, fwService, wsHub, devicePoller) jobRunner.Start() // Recover pending jobs from previous runs // Create bulk operations controller diff --git a/internal/firmware/service.go b/internal/firmware/service.go index 4490ec6..0bd383f 100644 --- a/internal/firmware/service.go +++ b/internal/firmware/service.go @@ -2283,7 +2283,7 @@ func (s *Service) PushConfig(ip, username, password string, config []byte) error } // ApplyConfig applies specific configuration changes to a device -func (s *Service) ApplyConfig(ip, username, password string, changes map[string]any) error { +func (s *Service) ApplyConfig(deviceID int, ip, username, password string, changes map[string]any) error { credential, err := s.resolveCredential(username, password, true) if err != nil { return err @@ -2314,7 +2314,15 @@ func (s *Service) ApplyConfig(ip, username, password string, changes map[string] } payload["wireless"].(map[string]any)["txPower"] = power } + var storedNewPassword string if pass, ok := changes["password"].(string); ok && pass != "" { + if s.secretStore == nil { + return errors.New("cannot change password: secret store is unavailable") + } + storedNewPassword, err = s.secretStore.Encrypt(pass) + if err != nil { + return fmt.Errorf("encrypt new device password: %w", err) + } payload["users"] = []map[string]any{ {"name": username, "password": pass}, } @@ -2352,5 +2360,10 @@ func (s *Service) ApplyConfig(ip, username, password string, changes map[string] return fmt.Errorf("apply returned status %d", resp.StatusCode) } + if storedNewPassword != "" { + if _, err := s.db.Exec(`UPDATE devices SET username = $2, password = $3 WHERE id = $1`, deviceID, username, storedNewPassword); err != nil { + return fmt.Errorf("configuration applied but new device credential could not be persisted: %w", err) + } + } return nil } diff --git a/internal/jobs/runner.go b/internal/jobs/runner.go index ed4b909..85c81fa 100644 --- a/internal/jobs/runner.go +++ b/internal/jobs/runner.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "log" "sort" @@ -100,11 +101,28 @@ type BackupParams struct { IncludeConfig bool `json:"include_config"` } +type DeviceRefresher interface { + RefreshDeviceByID(deviceID int64) error +} + +type RefreshDeviceResult struct { + DeviceID int `json:"device_id"` + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +type RefreshJobResult struct { + Queued int `json:"queued"` + Failed int `json:"failed"` + Results []RefreshDeviceResult `json:"results"` +} + // Runner manages async job execution type Runner struct { db *sql.DB fwService *firmware.Service wsHub *websocket.Hub + refresher DeviceRefresher mu sync.Mutex running map[string]context.CancelFunc // Active jobs with cancel functions @@ -140,13 +158,14 @@ func sanitizeIPForPath(ip string) string { } // NewRunner creates a new job runner -func NewRunner(db *sql.DB, fwService *firmware.Service, wsHub *websocket.Hub) *Runner { +func NewRunner(db *sql.DB, fwService *firmware.Service, wsHub *websocket.Hub, refresher DeviceRefresher) *Runner { maxJobs := 10 ctx, cancel := context.WithCancel(context.Background()) return &Runner{ db: db, fwService: fwService, wsHub: wsHub, + refresher: refresher, running: make(map[string]context.CancelFunc), jobSem: make(chan struct{}, maxJobs), maxJobs: maxJobs, @@ -331,6 +350,7 @@ func (r *Runner) executeJob(jobID string) { // Check if upgrade was skipped (already at target version) finalStatus := StatusCompleted finalMessage := "Job completed successfully" + var finalError *string // Check result for skipped status or upgrade success if result != nil { @@ -348,6 +368,16 @@ func (r *Runner) executeJob(jobID string) { } } + if refreshResult, ok := result.(*RefreshJobResult); ok { + if refreshResult.Failed > 0 { + finalStatus = StatusFailed + finalMessage = fmt.Sprintf("Refresh queued for %d device(s); %d failed to queue", refreshResult.Queued, refreshResult.Failed) + finalError = &finalMessage + } else { + finalMessage = fmt.Sprintf("Refresh queued for %d device(s)", refreshResult.Queued) + } + } + // Bulk/fanout upgrade results if results, ok := result.([]*firmware.UpgradeResult); ok { allSkipped := len(results) > 0 @@ -378,7 +408,7 @@ func (r *Runner) executeJob(jobID string) { } log.Printf("Job %s: marking as %s", jobID, finalStatus) - r.updateStatus(jobID, finalStatus, nil) + r.updateStatus(jobID, finalStatus, finalError) if result != nil { resultJSON, _ := json.Marshal(result) dbExecIgnore(r.db, `UPDATE job_runs SET result = $1 WHERE id = $2`, resultJSON, jobID) @@ -766,12 +796,35 @@ func (r *Runner) runRebootJob(ctx context.Context, job *JobRun) (interface{}, er return results, nil } -// runRefreshJob triggers device refresh +// runRefreshJob queues real immediate polls for the requested inventory devices. func (r *Runner) runRefreshJob(ctx context.Context, job *JobRun) (interface{}, error) { - // This would integrate with the poller to force immediate refresh - r.logEvent(job.ID, EventProgress, nil, fmt.Sprintf("Refreshing %d devices", len(job.DeviceIDs)), nil) - // TODO: integrate with poller.RefreshDevices() - return map[string]int{"refreshed": len(job.DeviceIDs)}, nil + if r.refresher == nil { + return nil, errors.New("device refresher is unavailable") + } + + result := &RefreshJobResult{Results: make([]RefreshDeviceResult, 0, len(job.DeviceIDs))} + r.logEvent(job.ID, EventProgress, nil, fmt.Sprintf("Queueing refresh for %d devices", len(job.DeviceIDs)), nil) + + for i, deviceID := range job.DeviceIDs { + if err := ctx.Err(); err != nil { + return result, err + } + refreshResult := RefreshDeviceResult{DeviceID: deviceID} + if err := r.refresher.RefreshDeviceByID(int64(deviceID)); err != nil { + refreshResult.Status = "failed" + refreshResult.Message = err.Error() + result.Failed++ + r.logEvent(job.ID, EventWarning, &deviceID, "Refresh could not be queued: "+err.Error(), nil) + } else { + refreshResult.Status = "queued" + refreshResult.Message = "Immediate poll queued" + result.Queued++ + r.logEvent(job.ID, EventStepComplete, &deviceID, "Immediate poll queued", nil) + } + result.Results = append(result.Results, refreshResult) + r.updateProgress(job.ID, i+1, len(job.DeviceIDs)) + } + return result, nil } // CancelJob cancels a running job diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 814fba5..1af9c32 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -71,6 +71,10 @@ type UpgradeParams struct { Fanout bool `json:"fanout"` // For APs: upgrade STAs first } +type DeviceRefresher interface { + RefreshDeviceByID(deviceID int64) error +} + // MaintenanceWindow represents a maintenance window type MaintenanceWindow struct { ID int `json:"id"` @@ -92,6 +96,7 @@ type Scheduler struct { db *sql.DB fwService *firmware.Service wsHub *websocket.Hub + refresher DeviceRefresher mu sync.Mutex running bool @@ -111,11 +116,12 @@ type Scheduler struct { } // NewScheduler creates a new scheduler -func NewScheduler(db *sql.DB, fwService *firmware.Service, wsHub *websocket.Hub) *Scheduler { +func NewScheduler(db *sql.DB, fwService *firmware.Service, wsHub *websocket.Hub, refresher DeviceRefresher) *Scheduler { s := &Scheduler{ db: db, fwService: fwService, wsHub: wsHub, + refresher: refresher, maxConcurrentJobs: 5, checkInterval: 10 * time.Second, respectMaintenance: true, @@ -719,11 +725,25 @@ func (s *Scheduler) runRebootJob(ctx context.Context, job ScheduledJob) error { return nil } -// runRefreshJob triggers a poll refresh for devices +// runRefreshJob queues immediate polls for the scheduled devices. func (s *Scheduler) runRefreshJob(ctx context.Context, job ScheduledJob) error { - // This would trigger the poller to refresh specific devices - // For now, just log it - actual implementation would call poller.RefreshDevice() - log.Printf("Job %d: refresh job for %d devices", job.ID, len(job.DeviceIDs)) + if s.refresher == nil { + return errors.New("device refresher is unavailable") + } + failed := 0 + for _, deviceID := range job.DeviceIDs { + if err := ctx.Err(); err != nil { + return err + } + if err := s.refresher.RefreshDeviceByID(int64(deviceID)); err != nil { + failed++ + log.Printf("Job %d: refresh device %d could not be queued: %v", job.ID, deviceID, err) + } + } + if failed > 0 { + return fmt.Errorf("%d of %d device refreshes failed to queue", failed, len(job.DeviceIDs)) + } + log.Printf("Job %d: queued refresh for %d devices", job.ID, len(job.DeviceIDs)) return nil } diff --git a/web/index.html b/web/index.html index 4adedf6..419a22b 100644 --- a/web/index.html +++ b/web/index.html @@ -467,6 +467,6 @@