Files
nexusterm/src/main/db/migrations/001_init.sql
T
ylhan 430d78b9f0 NexusTerm: an SSH session manager for Linux
Connection tree with interleaved folders and hosts, tabbed terminals
grouped by host, a pinned local shell, TOFU host key verification, and
credentials held by the OS keyring.

Terminal bytes travel on a per-session MessagePort with credit-based flow
control, so one session running `cat` on a large file cannot stall the
others. Transports reconnect on their own with jittered backoff, keeping
the scrollback and the tab while replacing only the connection beneath.

156 tests, including integration tests against a real sshd and a
migration exercised against a database built in the old shape.
2026-09-17 03:12:24 +08:00

140 lines
5.2 KiB
SQL

-- Initial schema.
--
-- Timestamps are unix milliseconds (INTEGER): sortable, timezone-free, and
-- directly comparable against Date.now() without a parse on every read.
--
-- Encryption boundary — only three columns in this database hold ciphertext:
-- credentials.ciphertext, private_keys.ciphertext, master_key_meta.ciphertext
-- Everything else (hostnames, ports, usernames, folder names) is plaintext on
-- purpose. The connection inventory has to be queryable, sortable and
-- searchable; encrypting it wholesale would degrade all three, and a leaked
-- hostname is a different order of problem from a leaked password.
-- ---------------------------------------------------------------- the tree --
--
-- Folders and hosts form one adjacency list, each with a nullable parent.
-- Chosen over a materialized path or nested set because real depth is 3-5
-- levels, so a recursive CTE read is cheap, while a move is a single-row
-- UPDATE — which is exactly what drag-and-drop does constantly.
CREATE TABLE folders (
id INTEGER PRIMARY KEY,
parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
-- REAL, not INTEGER. Reordering writes the midpoint between two neighbours
-- instead of renumbering every sibling. The cost is precision exhaustion
-- after ~50 repeated midpoint inserts in the same gap, at which point the
-- layer is renumbered in one pass.
sort_order REAL NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_folders_parent ON folders (parent_id, sort_order);
CREATE TABLE hosts (
id INTEGER PRIMARY KEY,
folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
label TEXT NOT NULL,
hostname TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 22 CHECK (port BETWEEN 1 AND 65535),
username TEXT NOT NULL,
auth_type TEXT NOT NULL CHECK (auth_type IN ('password', 'key', 'agent')),
-- Only meaningful when auth_type = 'key'.
key_path TEXT,
color TEXT,
sort_order REAL NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_hosts_folder ON hosts (folder_id, sort_order);
-- ----------------------------------------------------------- the secrets --
CREATE TABLE credentials (
id INTEGER PRIMARY KEY,
host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('password', 'passphrase')),
ciphertext BLOB NOT NULL,
nonce BLOB NOT NULL,
-- Per-record HKDF salt. The data key is derived as HKDF(master, salt, info)
-- rather than using the master key directly, so a nonce collision in one
-- record cannot compromise another.
salt BLOB NOT NULL,
created_at INTEGER NOT NULL,
-- One password (and one passphrase) per host.
UNIQUE (host_id, kind)
);
-- Imported private key material, for keys the user pastes in rather than
-- referencing by path. Same encryption treatment as credentials.
CREATE TABLE private_keys (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
ciphertext BLOB NOT NULL,
nonce BLOB NOT NULL,
salt BLOB NOT NULL,
created_at INTEGER NOT NULL
);
-- The data master key (DMK), sealed by the OS keyring via Electron's
-- safeStorage. Exactly one row, enforced by the CHECK.
CREATE TABLE master_key_meta (
id INTEGER PRIMARY KEY CHECK (id = 1),
ciphertext BLOB NOT NULL,
-- Which safeStorage backend sealed it. Recorded so a later downgrade is
-- detectable: a record sealed by gnome_libsecret on a machine that now
-- reports basic_text means the keyring changed underneath us, and an
-- opaque "decrypt failed" would be the wrong thing to show the user.
backend TEXT NOT NULL,
created_at INTEGER NOT NULL
);
-- ------------------------------------------------------- trust and tunnels --
-- Trust-on-first-use host key store.
CREATE TABLE known_hosts (
host TEXT NOT NULL,
port INTEGER NOT NULL,
key_type TEXT NOT NULL,
-- SHA256 base64 fingerprint, OpenSSH format.
fingerprint TEXT NOT NULL,
added_at INTEGER NOT NULL,
PRIMARY KEY (host, port, key_type)
);
CREATE TABLE port_forwards (
id INTEGER PRIMARY KEY,
host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('local', 'remote', 'dynamic')),
bind_host TEXT NOT NULL DEFAULT '127.0.0.1',
bind_port INTEGER NOT NULL,
-- Null for 'dynamic' (-D), which is a SOCKS proxy and has no fixed target.
dest_host TEXT,
dest_port INTEGER,
auto_start INTEGER NOT NULL DEFAULT 0 CHECK (auto_start IN (0, 1)),
created_at INTEGER NOT NULL,
CHECK (
(type = 'dynamic' AND dest_host IS NULL AND dest_port IS NULL)
OR (type IN ('local', 'remote') AND dest_host IS NOT NULL AND dest_port IS NOT NULL)
)
);
CREATE INDEX idx_port_forwards_host ON port_forwards (host_id);
CREATE TABLE history (
id INTEGER PRIMARY KEY,
host_id INTEGER REFERENCES hosts(id) ON DELETE SET NULL,
connected_at INTEGER NOT NULL,
duration_ms INTEGER,
exit_status TEXT
);
CREATE INDEX idx_history_host ON history (host_id, connected_at DESC);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);