diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index a249d0b..cea1b36 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -887,6 +887,12 @@ func (a *App) handleListRemoteSessions(w http.ResponseWriter, r *http.Request) { limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) sessions, found, err := a.store.ListRemoteSessions(r.Context(), deviceID, store.RemoteSessionListOptions{Limit: limit}) if err != nil { + logStructured(map[string]any{ + "event": "remote_sessions.list_failed", + "request_id": requestID(r.Context()), + "device_id": deviceID, + "error": err.Error(), + }) writeError(w, http.StatusInternalServerError, "failed to load remote sessions") return } diff --git a/server/internal/store/sqlite.go b/server/internal/store/sqlite.go index ee71e24..24f9986 100644 --- a/server/internal/store/sqlite.go +++ b/server/internal/store/sqlite.go @@ -155,9 +155,6 @@ CREATE TABLE IF NOT EXISTS remote_sessions ( FOREIGN KEY(device_id) REFERENCES devices(id) ); -CREATE INDEX IF NOT EXISTS idx_remote_sessions_device_created_at ON remote_sessions(device_id, created_at); -CREATE INDEX IF NOT EXISTS idx_remote_sessions_status_expires_at ON remote_sessions(status, expires_at); - CREATE TABLE IF NOT EXISTS audit_events ( id TEXT PRIMARY KEY, actor TEXT NOT NULL, @@ -182,11 +179,33 @@ CREATE INDEX IF NOT EXISTS idx_audit_events_device_created_at ON audit_events(de `ALTER TABLE commands ADD COLUMN expires_at TEXT`, `ALTER TABLE commands ADD COLUMN cancelled_at TEXT`, `ALTER TABLE commands ADD COLUMN expired_at TEXT`, + `ALTER TABLE remote_sessions ADD COLUMN target TEXT NOT NULL DEFAULT 'ssh'`, + `ALTER TABLE remote_sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'failed'`, + `ALTER TABLE remote_sessions ADD COLUMN server_host TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE remote_sessions ADD COLUMN server_port INTEGER NOT NULL DEFAULT 22`, + `ALTER TABLE remote_sessions ADD COLUMN remote_port INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE remote_sessions ADD COLUMN local_host TEXT NOT NULL DEFAULT '127.0.0.1'`, + `ALTER TABLE remote_sessions ADD COLUMN local_port INTEGER NOT NULL DEFAULT 22`, + `ALTER TABLE remote_sessions ADD COLUMN command_id TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE remote_sessions ADD COLUMN created_at TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE remote_sessions ADD COLUMN expires_at TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE remote_sessions ADD COLUMN started_at TEXT`, + `ALTER TABLE remote_sessions ADD COLUMN closed_at TEXT`, + `ALTER TABLE remote_sessions ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''`, } { if _, err := s.db.ExecContext(ctx, stmt); err != nil && !isDuplicateColumnError(err) { return err } } + + for _, stmt := range []string{ + `CREATE INDEX IF NOT EXISTS idx_remote_sessions_device_created_at ON remote_sessions(device_id, created_at)`, + `CREATE INDEX IF NOT EXISTS idx_remote_sessions_status_expires_at ON remote_sessions(status, expires_at)`, + } { + if _, err := s.db.ExecContext(ctx, stmt); err != nil { + return err + } + } return nil } diff --git a/server/internal/store/sqlite_test.go b/server/internal/store/sqlite_test.go new file mode 100644 index 0000000..9790d19 --- /dev/null +++ b/server/internal/store/sqlite_test.go @@ -0,0 +1,65 @@ +package store + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + + _ "modernc.org/sqlite" +) + +func TestMigrateLegacyRemoteSessionsTable(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "legacy.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + _, err = db.ExecContext(ctx, ` +CREATE TABLE devices ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + hostname TEXT NOT NULL, + openwrt_version TEXT NOT NULL, + inventory_json TEXT NOT NULL DEFAULT '{}', + metrics_json TEXT NOT NULL DEFAULT '{}', + last_seen_at TEXT, + created_at TEXT NOT NULL +); +CREATE TABLE remote_sessions ( + id TEXT PRIMARY KEY, + device_id TEXT NOT NULL +); +INSERT INTO devices (id, token, hostname, openwrt_version, created_at) +VALUES ('dev_legacy', 'tok_legacy', 'legacy-router', 'OpenWrt', '2026-01-01T00:00:00Z'); +INSERT INTO remote_sessions (id, device_id) +VALUES ('rs_legacy', 'dev_legacy'); +`) + if err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + + st, err := OpenSQLite(ctx, path) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + sessions, found, err := st.ListRemoteSessions(ctx, "dev_legacy", RemoteSessionListOptions{}) + if err != nil { + t.Fatal(err) + } + if !found { + t.Fatal("legacy device was not found") + } + if len(sessions) != 1 { + t.Fatalf("got %d sessions, want 1", len(sessions)) + } + if sessions[0].Target != "ssh" { + t.Fatalf("got target %q, want ssh", sessions[0].Target) + } +}