Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
70 changes: 56 additions & 14 deletions cmd/server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -4214,50 +4214,92 @@ 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
}

var req struct {
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)
if err != nil {
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})
}

Expand Down
4 changes: 2 additions & 2 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion internal/firmware/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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},
}
Expand Down Expand Up @@ -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
}
67 changes: 60 additions & 7 deletions internal/jobs/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"sort"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
30 changes: 25 additions & 5 deletions internal/scheduler/scheduler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -92,6 +96,7 @@ type Scheduler struct {
db *sql.DB
fwService *firmware.Service
wsHub *websocket.Hub
refresher DeviceRefresher

mu sync.Mutex
running bool
Expand All @@ -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,
Expand Down Expand Up @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,6 @@ <h3>Batch Configuration</h3>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin="anonymous"></script>
<script src="js/jszip.min.js"></script>
<script type="module" src="js/app.js?v=122"></script>
<script type="module" src="js/app.js?v=124"></script>
</body>
</html>
Loading
Loading