rust: nexus-core storage, vault, keychain, export/import, data-dir migration

This commit is contained in:
Andy Dixon
2026-10-11 16:05:13 +01:00
parent f588a797f9
commit e03298de11
25 changed files with 4539 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
target/
+1939
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
[workspace]
resolver = "3"
members = ["core", "app"]
[workspace.package]
version = "0.1.0"
edition = "2024"
[profile.dev.package."*"]
opt-level = 2
[profile.release]
lto = "thin"
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "nexus"
version.workspace = true
edition.workspace = true
[dependencies]
nexus-core = { path = "../core" }
Binary file not shown.
Binary file not shown.
Binary file not shown.
+95
View File
@@ -0,0 +1,95 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Note: the bundled TTFs have their private-use-area (U+E000–U+F8FF) cmap entries removed so
they do not shadow the icon font. Glyph outlines are unchanged.
@@ -0,0 +1,96 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Note: the bundled TTFs have their private-use-area (U+E000–U+F8FF) cmap entries removed so
they do not shadow the icon font. Glyph outlines are unchanged.
+1
View File
@@ -0,0 +1 @@
fn main() {}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "nexus-core"
version.workspace = true
edition.workspace = true
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.37", features = ["bundled"] }
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
argon2 = "0.5"
aes-gcm = "0.10"
rand = "0.8"
base64 = "0.22"
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "tokio", "crypto-rust"] }
+63
View File
@@ -0,0 +1,63 @@
-- Initial schema for Alchementary Link
-- Creates the core tables for profiles, known hosts, preferences, and audit log.
CREATE TABLE IF NOT EXISTS profiles (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT DEFAULT '',
protocol TEXT NOT NULL CHECK(protocol IN ('ssh', 'telnet', 'raw')),
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT DEFAULT '',
tags TEXT DEFAULT '[]',
folder TEXT DEFAULT '',
environment TEXT DEFAULT '',
notes TEXT DEFAULT '',
favourite INTEGER DEFAULT 0,
last_connected TEXT,
connect_timeout INTEGER DEFAULT 30,
read_timeout INTEGER DEFAULT 0,
terminal_font_family TEXT DEFAULT '',
terminal_font_size INTEGER DEFAULT 0,
terminal_scrollback INTEGER DEFAULT 0,
terminal_cursor_style TEXT DEFAULT '',
ssh_config TEXT,
telnet_config TEXT,
raw_config TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS known_hosts (
id TEXT PRIMARY KEY,
host TEXT NOT NULL,
port INTEGER NOT NULL,
key_type TEXT NOT NULL,
fingerprint TEXT NOT NULL,
public_key TEXT NOT NULL,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
trusted INTEGER DEFAULT 1,
UNIQUE(host, port, key_type)
);
CREATE TABLE IF NOT EXISTS preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
action TEXT NOT NULL,
profile_id TEXT DEFAULT '',
session_id TEXT DEFAULT '',
details TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (1, datetime('now'));
@@ -0,0 +1,59 @@
-- Migration 002: Add GitHub protocol support
-- Adds the 'github' value to the protocol CHECK constraint and a github_config column.
-- SQLite does not support ALTER TABLE to modify CHECK constraints or add columns
-- with complex defaults atomically. We recreate the profiles table.
-- Step 1: Create the new table with the updated constraint.
CREATE TABLE IF NOT EXISTS profiles_new (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT DEFAULT '',
protocol TEXT NOT NULL CHECK(protocol IN ('ssh', 'telnet', 'raw', 'github')),
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT DEFAULT '',
tags TEXT DEFAULT '[]',
folder TEXT DEFAULT '',
environment TEXT DEFAULT '',
notes TEXT DEFAULT '',
favourite INTEGER DEFAULT 0,
last_connected TEXT,
connect_timeout INTEGER DEFAULT 30,
read_timeout INTEGER DEFAULT 0,
terminal_font_family TEXT DEFAULT '',
terminal_font_size INTEGER DEFAULT 14,
terminal_scrollback INTEGER DEFAULT 10000,
terminal_cursor_style TEXT DEFAULT 'block',
ssh_config TEXT,
telnet_config TEXT,
raw_config TEXT,
github_config TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Step 2: Copy existing data (github_config will be NULL for legacy rows).
INSERT OR IGNORE INTO profiles_new (
id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config,
created_at, updated_at
)
SELECT
id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, NULL,
created_at, updated_at
FROM profiles;
-- Step 3: Drop old table and rename.
DROP TABLE profiles;
ALTER TABLE profiles_new RENAME TO profiles;
-- Step 4: Record the migration version.
INSERT INTO schema_version (version, applied_at) VALUES (2, datetime('now'));
+15
View File
@@ -0,0 +1,15 @@
-- Migration 003: Add snippets table for the command snippet library.
-- Stores reusable commands that can be sent to active sessions.
CREATE TABLE IF NOT EXISTS snippets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
command TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
folder TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO schema_version (version, applied_at) VALUES (3, datetime('now'));
+105
View File
@@ -0,0 +1,105 @@
-- Migration 004: Add VNC protocol support
-- Adds the 'vnc' value to the protocol CHECK constraint and a vnc_config column.
-- Step 1: Create the new table with the updated constraint.
CREATE TABLE IF NOT EXISTS profiles_new (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT DEFAULT '',
protocol TEXT NOT NULL CHECK(
protocol IN ('ssh', 'telnet', 'raw', 'github', 'vnc')
),
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT DEFAULT '',
tags TEXT DEFAULT '[]',
folder TEXT DEFAULT '',
environment TEXT DEFAULT '',
notes TEXT DEFAULT '',
favourite INTEGER DEFAULT 0,
last_connected TEXT,
connect_timeout INTEGER DEFAULT 30,
read_timeout INTEGER DEFAULT 0,
terminal_font_family TEXT DEFAULT '',
terminal_font_size INTEGER DEFAULT 14,
terminal_scrollback INTEGER DEFAULT 10000,
terminal_cursor_style TEXT DEFAULT 'block',
ssh_config TEXT,
telnet_config TEXT,
raw_config TEXT,
github_config TEXT,
vnc_config TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Step 2: Copy existing data (vnc_config will be NULL for legacy rows).
INSERT
OR IGNORE INTO profiles_new (
id,
display_name,
description,
protocol,
host,
port,
username,
tags,
folder,
environment,
notes,
favourite,
last_connected,
connect_timeout,
read_timeout,
terminal_font_family,
terminal_font_size,
terminal_scrollback,
terminal_cursor_style,
ssh_config,
telnet_config,
raw_config,
github_config,
vnc_config,
created_at,
updated_at
)
SELECT
id,
display_name,
description,
protocol,
host,
port,
username,
tags,
folder,
environment,
notes,
favourite,
last_connected,
connect_timeout,
read_timeout,
terminal_font_family,
terminal_font_size,
terminal_scrollback,
terminal_cursor_style,
ssh_config,
telnet_config,
raw_config,
github_config,
NULL,
created_at,
updated_at
FROM
profiles;
-- Step 3: Drop old table and rename.
DROP TABLE profiles;
ALTER TABLE
profiles_new RENAME TO profiles;
-- Step 4: Record the migration version.
INSERT INTO
schema_version (version, applied_at)
VALUES
(4, datetime('now'));
+105
View File
@@ -0,0 +1,105 @@
-- Migration 005: Add IRC protocol support
-- Adds the 'irc' value to the protocol CHECK constraint and an irc_config column.
CREATE TABLE IF NOT EXISTS profiles_new (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT DEFAULT '',
protocol TEXT NOT NULL CHECK(
protocol IN ('ssh', 'telnet', 'raw', 'github', 'vnc', 'irc')
),
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT DEFAULT '',
tags TEXT DEFAULT '[]',
folder TEXT DEFAULT '',
environment TEXT DEFAULT '',
notes TEXT DEFAULT '',
favourite INTEGER DEFAULT 0,
last_connected TEXT,
connect_timeout INTEGER DEFAULT 30,
read_timeout INTEGER DEFAULT 0,
terminal_font_family TEXT DEFAULT '',
terminal_font_size INTEGER DEFAULT 14,
terminal_scrollback INTEGER DEFAULT 10000,
terminal_cursor_style TEXT DEFAULT 'block',
ssh_config TEXT,
telnet_config TEXT,
raw_config TEXT,
github_config TEXT,
vnc_config TEXT,
irc_config TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT
OR IGNORE INTO profiles_new (
id,
display_name,
description,
protocol,
host,
port,
username,
tags,
folder,
environment,
notes,
favourite,
last_connected,
connect_timeout,
read_timeout,
terminal_font_family,
terminal_font_size,
terminal_scrollback,
terminal_cursor_style,
ssh_config,
telnet_config,
raw_config,
github_config,
vnc_config,
irc_config,
created_at,
updated_at
)
SELECT
id,
display_name,
description,
protocol,
host,
port,
username,
tags,
folder,
environment,
notes,
favourite,
last_connected,
connect_timeout,
read_timeout,
terminal_font_family,
terminal_font_size,
terminal_scrollback,
terminal_cursor_style,
ssh_config,
telnet_config,
raw_config,
github_config,
vnc_config,
NULL,
created_at,
updated_at
FROM
profiles;
DROP TABLE profiles;
ALTER TABLE
profiles_new RENAME TO profiles;
INSERT INTO
schema_version (version, applied_at)
VALUES
(5, datetime('now'));
@@ -0,0 +1,15 @@
-- Migration 006: Remember the channels open for each saved IRC profile.
CREATE TABLE IF NOT EXISTS irc_channel_state (
profile_id TEXT NOT NULL,
room_name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY (profile_id, room_name)
);
CREATE INDEX IF NOT EXISTS idx_irc_channel_state_profile_position
ON irc_channel_state (profile_id, position);
INSERT INTO schema_version (version, applied_at)
VALUES (6, datetime('now'));
@@ -0,0 +1,108 @@
-- Migration 007: Add Serial protocol support
-- Adds the 'serial' value to the protocol CHECK constraint and a serial_config column.
CREATE TABLE IF NOT EXISTS profiles_new (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT DEFAULT '',
protocol TEXT NOT NULL CHECK(
protocol IN ('ssh', 'telnet', 'raw', 'github', 'vnc', 'irc', 'serial')
),
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT DEFAULT '',
tags TEXT DEFAULT '[]',
folder TEXT DEFAULT '',
environment TEXT DEFAULT '',
notes TEXT DEFAULT '',
favourite INTEGER DEFAULT 0,
last_connected TEXT,
connect_timeout INTEGER DEFAULT 30,
read_timeout INTEGER DEFAULT 0,
terminal_font_family TEXT DEFAULT '',
terminal_font_size INTEGER DEFAULT 14,
terminal_scrollback INTEGER DEFAULT 10000,
terminal_cursor_style TEXT DEFAULT 'block',
ssh_config TEXT,
telnet_config TEXT,
raw_config TEXT,
github_config TEXT,
vnc_config TEXT,
irc_config TEXT,
serial_config TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT
OR IGNORE INTO profiles_new (
id,
display_name,
description,
protocol,
host,
port,
username,
tags,
folder,
environment,
notes,
favourite,
last_connected,
connect_timeout,
read_timeout,
terminal_font_family,
terminal_font_size,
terminal_scrollback,
terminal_cursor_style,
ssh_config,
telnet_config,
raw_config,
github_config,
vnc_config,
irc_config,
serial_config,
created_at,
updated_at
)
SELECT
id,
display_name,
description,
protocol,
host,
port,
username,
tags,
folder,
environment,
notes,
favourite,
last_connected,
connect_timeout,
read_timeout,
terminal_font_family,
terminal_font_size,
terminal_scrollback,
terminal_cursor_style,
ssh_config,
telnet_config,
raw_config,
github_config,
vnc_config,
irc_config,
NULL,
created_at,
updated_at
FROM
profiles;
DROP TABLE profiles;
ALTER TABLE
profiles_new RENAME TO profiles;
INSERT INTO
schema_version (version, applied_at)
VALUES
(7, datetime('now'));
+128
View File
@@ -0,0 +1,128 @@
//! Data directories, and the one-time move of Alchementary Link data into Nexus Terminal's.
use std::path::{Path, PathBuf};
pub const APP_DIR: &str = "NexusTerminal";
pub const LEGACY_APP_DIR: &str = "AlchementaryLink";
#[derive(Clone, Debug)]
pub struct AppConfig {
pub data_dir: PathBuf,
pub db_path: PathBuf,
pub vault_dir: PathBuf,
pub recordings_dir: PathBuf,
pub log_path: PathBuf,
/// Set when this run copied data across from Alchementary Link.
pub migrated_from: Option<PathBuf>,
}
/// `~/Library/Application Support` on macOS, `%APPDATA%` on Windows, `$XDG_CONFIG_HOME` or
/// `~/.config` elsewhere — the same bases the Go app used.
pub fn base_dir() -> PathBuf {
let home = || std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from).unwrap_or_default();
if cfg!(target_os = "macos") {
home().join("Library/Application Support")
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(PathBuf::from).unwrap_or_else(|| home().join("AppData").join("Roaming"))
} else {
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).unwrap_or_else(|| home().join(".config"))
}
}
impl AppConfig {
pub fn in_dir(data_dir: PathBuf) -> AppConfig {
AppConfig {
db_path: data_dir.join("nexus.db"),
vault_dir: data_dir.join("vault"),
recordings_dir: data_dir.join("recordings"),
log_path: data_dir.join("nexus.log"),
data_dir,
migrated_from: None,
}
}
/// Resolves the data directory, migrating legacy data first, and creates the directories.
pub fn load() -> std::io::Result<AppConfig> {
let base = base_dir();
let mut cfg = AppConfig::in_dir(base.join(APP_DIR));
cfg.migrated_from = migrate_legacy(&base.join(LEGACY_APP_DIR), &cfg)?;
for d in [&cfg.data_dir, &cfg.vault_dir] {
create_private_dir(d)?;
}
Ok(cfg)
}
}
fn create_private_dir(d: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(d)?;
#[cfg(unix)]
std::fs::set_permissions(d, std::os::unix::fs::PermissionsExt::from_mode(0o700))?;
Ok(())
}
/// Copies (never moves) `AlchementaryLink/{link.db, vault/, recordings/}` into the Nexus
/// directory when Nexus has no database yet. A pre-rebrand `NexusTerminal/nexus.db` wins as-is.
pub fn migrate_legacy(legacy: &Path, cfg: &AppConfig) -> std::io::Result<Option<PathBuf>> {
let legacy_db = legacy.join("link.db");
if cfg.db_path.exists() || !legacy_db.exists() {
return Ok(None);
}
create_private_dir(&cfg.data_dir)?;
// Fold the WAL into the main file so the copy is complete.
if let Ok(c) = rusqlite::Connection::open(&legacy_db) {
let _ = c.pragma_update(None, "wal_checkpoint", "TRUNCATE");
}
std::fs::copy(&legacy_db, &cfg.db_path)?;
copy_dir(&legacy.join("vault"), &cfg.vault_dir)?;
copy_dir(&legacy.join("recordings"), &cfg.recordings_dir)?;
std::fs::write(cfg.data_dir.join("migrated-from"), legacy.to_string_lossy().as_bytes())?;
Ok(Some(legacy.to_path_buf()))
}
fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
let Ok(rd) = std::fs::read_dir(from) else { return Ok(()) };
create_private_dir(to)?;
for e in rd.flatten() {
if e.file_type()?.is_file() && !to.join(e.file_name()).exists() {
std::fs::copy(e.path(), to.join(e.file_name()))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("nexus-cfg-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
#[test]
fn migrates_legacy_once_and_keeps_source() {
let base = tmp("mig");
let legacy = base.join(LEGACY_APP_DIR);
std::fs::create_dir_all(legacy.join("vault")).unwrap();
std::fs::write(legacy.join("link.db"), b"db").unwrap();
std::fs::write(legacy.join("vault/vault.json"), b"{}").unwrap();
let cfg = AppConfig::in_dir(base.join(APP_DIR));
assert_eq!(migrate_legacy(&legacy, &cfg).unwrap(), Some(legacy.clone()));
assert_eq!(std::fs::read(&cfg.db_path).unwrap(), b"db");
assert!(cfg.vault_dir.join("vault.json").exists());
assert!(legacy.join("link.db").exists());
// Second run: Nexus already has a database, nothing happens.
std::fs::write(legacy.join("link.db"), b"newer").unwrap();
assert_eq!(migrate_legacy(&legacy, &cfg).unwrap(), None);
assert_eq!(std::fs::read(&cfg.db_path).unwrap(), b"db");
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn no_legacy_no_migration() {
let base = tmp("none");
let cfg = AppConfig::in_dir(base.join(APP_DIR));
assert_eq!(migrate_legacy(&base.join(LEGACY_APP_DIR), &cfg).unwrap(), None);
}
}
+90
View File
@@ -0,0 +1,90 @@
//! Password-encrypted profile exports: `[version 1][salt 32][nonce 12][AES-256-GCM ciphertext]`
//! over a JSON bundle, with the vault's Argon2id parameters. `.link` files from Alchementary
//! Link are the same format.
use crate::models::Profile;
use crate::secrets::{derive_key, random_bytes};
use crate::Result;
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use serde::{Deserialize, Serialize};
use std::path::Path;
const VERSION: u8 = 1;
const MAX_IMPORT_BYTES: u64 = 10 * 1024 * 1024;
pub const EXTENSION: &str = "nexus";
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Bundle {
version: i64,
exported_at: String,
#[serde(default)]
profiles: Vec<Profile>,
count: usize,
}
pub fn encrypt_and_export(profiles: &[Profile], password: &str, path: &Path) -> Result<()> {
if password.is_empty() {
return Err("exportimport: password cannot be empty".into());
}
if profiles.is_empty() {
return Err("exportimport: no profiles to export".into());
}
let bundle = Bundle {
version: 1,
exported_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
profiles: profiles.to_vec(),
count: profiles.len(),
};
let plain = serde_json::to_vec_pretty(&bundle)?;
let salt = random_bytes::<32>();
let nonce = random_bytes::<12>();
let key = derive_key(password, &salt)?;
let ct = Aes256Gcm::new((&key).into()).encrypt(Nonce::from_slice(&nonce), plain.as_slice()).map_err(|e| e.to_string())?;
let mut out = vec![VERSION];
out.extend(salt);
out.extend(nonce);
out.extend(ct);
crate::secrets::write_private(path, &out)
}
pub fn decrypt_and_import(path: &Path, password: &str) -> Result<Vec<Profile>> {
if password.is_empty() {
return Err("exportimport: password cannot be empty".into());
}
if std::fs::metadata(path)?.len() > MAX_IMPORT_BYTES {
return Err("exportimport: file is too large (max 10 MiB)".into());
}
let data = std::fs::read(path)?;
if data.len() < 1 + 32 + 12 + 1 + 16 {
return Err("exportimport: file is too small or corrupt".into());
}
if data[0] != VERSION {
return Err(format!("exportimport: unsupported format version {}", data[0]).into());
}
let key = derive_key(password, &data[1..33])?;
let plain = Aes256Gcm::new((&key).into())
.decrypt(Nonce::from_slice(&data[33..45]), &data[45..])
.map_err(|_| "exportimport: decryption failed — wrong password or corrupt file")?;
let bundle: Bundle = serde_json::from_slice(&plain).map_err(|e| format!("exportimport: invalid bundle JSON: {e}"))?;
Ok(bundle.profiles)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_go_export_and_round_trips() {
let fx = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/profiles.link");
assert!(decrypt_and_import(&fx, "nope").is_err());
let ps = decrypt_and_import(&fx, "export-pass").unwrap();
assert_eq!(ps.len(), 6);
assert_eq!(ps[0].ssh.as_ref().unwrap().jump_hosts[0].port, 2222);
let out = std::env::temp_dir().join(format!("nexus-export-{}.nexus", std::process::id()));
encrypt_and_export(&ps, "pw", &out).unwrap();
assert_eq!(decrypt_and_import(&out, "pw").unwrap(), ps);
std::fs::remove_file(out).unwrap();
}
}
+11
View File
@@ -0,0 +1,11 @@
//! nexus-core: everything in Nexus Terminal that isn't drawing — storage, secrets, protocol
//! clients and the session manager. The egui app is one client of this crate.
pub mod config;
pub mod exportimport;
pub mod models;
pub mod secrets;
pub mod storage;
pub type Error = Box<dyn std::error::Error + Send + Sync>;
pub type Result<T> = std::result::Result<T, Error>;
+913
View File
@@ -0,0 +1,913 @@
//! Data model shared by storage, the session manager and the UI. Field names serialise exactly as
//! the Go structs did, so database JSON columns and `.link`/`.nexus` exports round-trip unchanged.
use chrono::{DateTime, FixedOffset, Local};
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::BTreeMap;
pub const DEFAULT_SSH_PORT: u16 = 22;
pub const DEFAULT_TELNET_PORT: u16 = 23;
pub const DEFAULT_RAW_PORT: u16 = 23;
pub const DEFAULT_GITHUB_PORT: u16 = 443;
pub const DEFAULT_VNC_PORT: u16 = 5900;
pub const DEFAULT_IRC_PORT: u16 = 6667;
pub const DEFAULT_IRC_TLS_PORT: u16 = 6697;
pub const DEFAULT_CONNECT_TIMEOUT: i64 = 30;
pub const DEFAULT_SCROLLBACK: i64 = 10000;
pub const DEFAULT_FONT_SIZE: i64 = 14;
pub const DEFAULT_FONT_FAMILY: &str = "JetBrains Mono, Menlo, Monaco, Consolas, monospace";
pub const DEFAULT_CURSOR_STYLE: &str = "block";
pub const DEFAULT_TERMINAL_TYPE: &str = "xterm-256color";
pub const DEFAULT_KEEPALIVE_INTERVAL: i64 = 30;
pub const DEFAULT_KEEPALIVE_COUNT_MAX: i64 = 3;
pub type Time = DateTime<FixedOffset>;
pub fn now() -> Time {
Local::now().fixed_offset()
}
/// Go's zero `time.Time`, which is what a missing timestamp decodes to.
pub fn zero_time() -> Time {
DateTime::parse_from_rfc3339("0001-01-01T00:00:00Z").unwrap()
}
/// Go writes nil slices and maps as `null`; treat that as empty.
fn nd<'de, D: Deserializer<'de>, T: Default + Deserialize<'de>>(d: D) -> Result<T, D::Error> {
Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Protocol {
#[default]
Ssh,
Telnet,
Raw,
Github,
Vnc,
Irc,
Serial,
Local,
}
impl Protocol {
pub const SAVED: [Protocol; 7] =
[Protocol::Ssh, Protocol::Telnet, Protocol::Raw, Protocol::Github, Protocol::Vnc, Protocol::Irc, Protocol::Serial];
pub fn id(self) -> &'static str {
match self {
Protocol::Ssh => "ssh",
Protocol::Telnet => "telnet",
Protocol::Raw => "raw",
Protocol::Github => "github",
Protocol::Vnc => "vnc",
Protocol::Irc => "irc",
Protocol::Serial => "serial",
Protocol::Local => "local",
}
}
pub fn from_id(s: &str) -> Option<Self> {
[Protocol::Local].into_iter().chain(Self::SAVED).find(|p| p.id() == s)
}
pub fn label(self) -> &'static str {
match self {
Protocol::Ssh => "SSH",
Protocol::Telnet => "Telnet",
Protocol::Raw => "RAW TCP",
Protocol::Github => "GitHub",
Protocol::Vnc => "VNC",
Protocol::Irc => "IRC",
Protocol::Serial => "Serial",
Protocol::Local => "Local",
}
}
/// Protocols whose sessions render in a terminal view.
pub fn is_terminal(self) -> bool {
matches!(self, Protocol::Ssh | Protocol::Telnet | Protocol::Serial | Protocol::Local)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum HostKeyPolicy {
#[serde(rename = "strict")]
Strict,
#[default]
#[serde(rename = "ask")]
Ask,
#[serde(rename = "accept-new")]
AcceptNew,
#[serde(rename = "insecure")]
Insecure,
}
impl HostKeyPolicy {
pub const ALL: [HostKeyPolicy; 4] = [Self::Strict, Self::Ask, Self::AcceptNew, Self::Insecure];
pub fn label(self) -> &'static str {
match self {
Self::Strict => "Strict (known hosts only)",
Self::Ask => "Ask on new or changed keys",
Self::AcceptNew => "Accept new, reject changed",
Self::Insecure => "Insecure (accept anything)",
}
}
}
/// How a credential is obtained. Unknown strings (and the legacy "prompt") are kept as
/// ask-every-time, matching Go's normaliseSecretMode.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Default)]
pub enum SecretMode {
#[default]
#[serde(rename = "saved")]
Saved,
#[serde(rename = "session-only")]
SessionOnly,
#[serde(rename = "ask-every-time")]
AskEveryTime,
}
impl<'de> Deserialize<'de> for SecretMode {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Ok(match Option::<String>::deserialize(d)?.as_deref().unwrap_or("") {
"saved" | "" => SecretMode::Saved,
"session-only" => SecretMode::SessionOnly,
_ => SecretMode::AskEveryTime,
})
}
}
impl SecretMode {
pub const ALL: [SecretMode; 3] = [Self::Saved, Self::SessionOnly, Self::AskEveryTime];
pub fn label(self) -> &'static str {
match self {
Self::Saved => "Save securely",
Self::SessionOnly => "Remember for this session",
Self::AskEveryTime => "Ask every time",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum LineEnding {
Lf,
Cr,
#[default]
Crlf,
None,
}
impl LineEnding {
pub const ALL: [LineEnding; 4] = [Self::Crlf, Self::Lf, Self::Cr, Self::None];
pub fn bytes(self) -> &'static [u8] {
match self {
Self::Lf => b"\n",
Self::Cr => b"\r",
Self::Crlf => b"\r\n",
Self::None => b"",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Lf => "LF (\\n)",
Self::Cr => "CR (\\r)",
Self::Crlf => "CRLF (\\r\\n)",
Self::None => "None",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Profile {
#[serde(default)]
pub id: String,
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub protocol: Protocol,
#[serde(default)]
pub host: String,
#[serde(default)]
pub port: i64,
#[serde(default)]
pub username: String,
#[serde(default, deserialize_with = "nd")]
pub tags: Vec<String>,
#[serde(default)]
pub folder: String,
#[serde(default)]
pub environment: String,
#[serde(default)]
pub notes: String,
#[serde(default)]
pub favourite: bool,
#[serde(default)]
pub last_connected: Option<Time>,
#[serde(default)]
pub connect_timeout: i64,
#[serde(default)]
pub read_timeout: i64,
#[serde(default = "zero_time")]
pub created_at: Time,
#[serde(default = "zero_time")]
pub updated_at: Time,
#[serde(default)]
pub terminal_font_family: String,
#[serde(default)]
pub terminal_font_size: i64,
#[serde(default)]
pub terminal_scrollback: i64,
#[serde(default)]
pub terminal_cursor_style: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<SshConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub telnet: Option<TelnetConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw: Option<RawConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github: Option<GitHubConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vnc: Option<VncConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub irc: Option<IrcConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub serial: Option<SerialConfig>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct SshConfig {
#[serde(deserialize_with = "nd")]
pub auth_methods: Vec<String>,
pub secret_mode: SecretMode,
pub private_key_path: String,
pub key_secret_mode: SecretMode,
pub host_key_policy: HostKeyPolicy,
pub terminal_type: String,
pub keepalive_interval: i64,
pub keepalive_count_max: i64,
pub compression: bool,
pub agent_auth: bool,
pub agent_forwarding: bool,
pub auto_reconnect: bool,
pub max_reconnect_tries: i64,
#[serde(deserialize_with = "nd")]
pub login_scripts: Vec<LoginScriptStep>,
#[serde(rename = "totpSecret")]
pub totp_secret: String,
pub proxy_type: String,
pub proxy_host: String,
pub proxy_port: i64,
pub proxy_username: String,
pub proxy_password: String,
#[serde(deserialize_with = "nd")]
pub jump_hosts: Vec<JumpHost>,
#[serde(deserialize_with = "nd")]
pub port_forwards: Vec<PortForward>,
pub startup_command: String,
#[serde(deserialize_with = "nd")]
pub env_vars: BTreeMap<String, String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct LoginScriptStep {
#[serde(rename = "type")]
pub kind: String,
pub value: String,
pub timeout: i64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct JumpHost {
pub host: String,
pub port: i64,
pub username: String,
pub auth_method: String,
pub secret_mode: SecretMode,
pub private_key_path: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ForwardDirection {
#[default]
Local,
Remote,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct PortForward {
pub id: String,
pub direction: ForwardDirection,
pub bind_host: String,
pub bind_port: i64,
pub dest_host: String,
pub dest_port: i64,
pub enabled: bool,
pub label: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct TelnetConfig {
pub local_echo: bool,
pub line_ending: LineEnding,
pub terminal_type: String,
pub encoding: String,
pub negotiation_debug: bool,
pub line_mode: bool,
pub auto_reconnect: bool,
pub max_reconnect_tries: i64,
#[serde(deserialize_with = "nd")]
pub login_scripts: Vec<LoginScriptStep>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct RawConfig {
pub auto_reconnect: bool,
pub line_ending: LineEnding,
pub encoding: String,
pub show_timestamps: bool,
pub show_direction: bool,
#[serde(deserialize_with = "nd")]
pub payload_presets: Vec<PayloadPreset>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct PayloadPreset {
pub id: String,
pub name: String,
/// base64-encoded for binary safety
pub payload: String,
/// text, hex, escaped
pub format: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct GitHubConfig {
#[serde(rename = "baseURL")]
pub base_url: String,
pub secret_mode: SecretMode,
pub refresh_interval_secs: i64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct VncConfig {
pub vnc_host: String,
pub vnc_port: i64,
pub secret_mode: SecretMode,
pub view_only: bool,
pub scale_to_fit: bool,
pub quality: i64,
pub shared_session: bool,
pub ssh_tunnel: bool,
pub ssh_host: String,
pub ssh_port: i64,
pub ssh_username: String,
pub ssh_auth_method: String,
pub ssh_secret_mode: SecretMode,
#[serde(rename = "sshPrivateKeyPath")]
pub ssh_private_key: String,
pub ssh_agent_auth: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct IrcConfig {
pub nick: String,
pub real_name: String,
#[serde(rename = "useTLS")]
pub use_tls: bool,
pub server_password_required: bool,
pub server_password_mode: SecretMode,
pub nick_serv_identify: bool,
pub nick_serv_account: String,
pub nick_serv_mode: SecretMode,
#[serde(deserialize_with = "nd")]
pub auto_join_rooms: Vec<String>,
pub ssh_tunnel: bool,
pub ssh_host: String,
pub ssh_port: i64,
pub ssh_username: String,
pub ssh_auth_method: String,
pub ssh_secret_mode: SecretMode,
#[serde(rename = "sshPrivateKeyPath")]
pub ssh_private_key: String,
pub ssh_agent_auth: bool,
pub auto_reconnect: bool,
pub max_reconnect_tries: i64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Parity {
#[default]
None,
Odd,
Even,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum FlowControl {
#[default]
None,
Xonxoff,
Rtscts,
}
/// Serial line settings. The device path lives in `Profile::host`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct SerialConfig {
pub baud_rate: u32,
pub data_bits: u8,
pub parity: Parity,
pub stop_bits: u8,
pub flow_control: FlowControl,
pub line_ending: LineEnding,
pub local_echo: bool,
pub dtr_on_connect: bool,
pub rts_on_connect: bool,
pub encoding: String,
pub auto_reconnect: bool,
pub max_reconnect_tries: i64,
}
impl Default for SerialConfig {
fn default() -> Self {
Self {
baud_rate: 115200,
data_bits: 8,
parity: Parity::None,
stop_bits: 1,
flow_control: FlowControl::None,
line_ending: LineEnding::Cr,
local_echo: false,
dtr_on_connect: true,
rts_on_connect: true,
encoding: "utf-8".into(),
auto_reconnect: true,
max_reconnect_tries: 5,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct GitHubRepo {
pub full_name: String,
pub name: String,
pub owner: String,
pub description: String,
#[serde(rename = "htmlURL")]
pub html_url: String,
pub default_branch: String,
pub latest_committer: String,
pub latest_commit_at: String,
pub private: bool,
pub fork: bool,
pub archived: bool,
pub language: String,
pub stars: i64,
pub pushed_at: String,
pub updated_at: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum SessionState {
#[default]
Connecting,
Connected,
Disconnected,
Reconnecting,
Error,
}
#[derive(Clone, Debug)]
pub struct SessionInfo {
pub id: String,
pub profile_id: String,
pub profile_name: String,
pub protocol: Protocol,
pub host: String,
pub port: i64,
pub state: SessionState,
pub tab_name: String,
pub pinned: bool,
pub connected_at: Time,
pub error: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct KnownHost {
pub id: String,
pub host: String,
pub port: i64,
pub key_type: String,
pub fingerprint: String,
pub public_key: String,
pub first_seen: Time,
pub last_seen: Time,
pub trusted: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AppPreferences {
pub theme: String,
pub terminal_theme: String,
pub default_protocol: String,
pub confirm_on_close: bool,
pub confirm_on_disconnect: bool,
pub show_status_bar: bool,
pub sidebar_width: i64,
pub default_font_family: String,
pub default_font_size: i64,
pub default_scrollback: i64,
pub default_cursor_style: String,
pub use_native_paste: bool,
pub local_shell: String,
}
impl Default for AppPreferences {
fn default() -> Self {
Self {
theme: "dark".into(),
terminal_theme: "charcoal".into(),
default_protocol: "ssh".into(),
confirm_on_close: true,
confirm_on_disconnect: true,
show_status_bar: true,
sidebar_width: 280,
default_font_family: DEFAULT_FONT_FAMILY.into(),
default_font_size: DEFAULT_FONT_SIZE,
default_scrollback: DEFAULT_SCROLLBACK,
default_cursor_style: DEFAULT_CURSOR_STYLE.into(),
use_native_paste: false,
local_shell: String::new(),
}
}
}
impl AppPreferences {
/// Fills zero values a stored preference blob may carry (Go's applyDefaults).
pub fn apply_defaults(&mut self) {
let d = Self::default();
if self.theme.is_empty() {
self.theme = d.theme;
}
if self.terminal_theme.is_empty() {
self.terminal_theme = d.terminal_theme;
}
if self.default_protocol.is_empty() {
self.default_protocol = d.default_protocol;
}
if self.sidebar_width == 0 {
self.sidebar_width = d.sidebar_width;
}
if self.default_font_family.is_empty() {
self.default_font_family = d.default_font_family;
}
if self.default_font_size == 0 {
self.default_font_size = d.default_font_size;
}
if self.default_scrollback == 0 {
self.default_scrollback = d.default_scrollback;
}
if self.default_cursor_style.is_empty() {
self.default_cursor_style = d.default_cursor_style;
}
}
}
#[derive(Clone, Debug)]
pub struct AuditEntry {
pub id: String,
pub timestamp: Time,
pub action: String,
pub profile_id: String,
pub session_id: String,
pub details: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Snippet {
pub id: String,
pub name: String,
pub command: String,
pub description: String,
pub folder: String,
pub tags: Vec<String>,
pub created_at: Time,
pub updated_at: Time,
}
pub fn new_id() -> String {
uuid::Uuid::new_v4().to_string()
}
impl Profile {
/// A profile with protocol defaults filled in (Go's NewProfile).
pub fn new(protocol: Protocol) -> Profile {
let now = now();
let mut p = Profile {
id: new_id(),
display_name: String::new(),
description: String::new(),
protocol,
host: String::new(),
port: 0,
username: String::new(),
tags: vec![],
folder: String::new(),
environment: String::new(),
notes: String::new(),
favourite: false,
last_connected: None,
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
read_timeout: 0,
created_at: now,
updated_at: now,
terminal_font_family: DEFAULT_FONT_FAMILY.into(),
terminal_font_size: DEFAULT_FONT_SIZE,
terminal_scrollback: DEFAULT_SCROLLBACK,
terminal_cursor_style: DEFAULT_CURSOR_STYLE.into(),
ssh: None,
telnet: None,
raw: None,
github: None,
vnc: None,
irc: None,
serial: None,
};
match protocol {
Protocol::Ssh => {
p.port = DEFAULT_SSH_PORT.into();
p.ssh = Some(SshConfig::defaults());
}
Protocol::Telnet => {
p.port = DEFAULT_TELNET_PORT.into();
p.telnet = Some(TelnetConfig::defaults());
}
Protocol::Raw => {
p.port = DEFAULT_RAW_PORT.into();
p.raw = Some(RawConfig::defaults());
}
Protocol::Github => {
p.port = DEFAULT_GITHUB_PORT.into();
p.host = "github.com".into();
p.github = Some(GitHubConfig::defaults());
}
Protocol::Vnc => {
p.port = DEFAULT_VNC_PORT.into();
p.vnc = Some(VncConfig::defaults());
}
Protocol::Irc => {
p.port = DEFAULT_IRC_PORT.into();
p.irc = Some(IrcConfig::defaults());
}
Protocol::Serial => p.serial = Some(SerialConfig::default()),
Protocol::Local => {}
}
p
}
/// Fills missing fields the way Go's NormalizeProfileDefaults did.
pub fn normalize(&mut self) {
let d = Profile::new(self.protocol);
if self.id.is_empty() {
self.id = d.id.clone();
}
if self.connect_timeout == 0 {
self.connect_timeout = DEFAULT_CONNECT_TIMEOUT;
}
if self.terminal_font_family.is_empty() {
self.terminal_font_family = DEFAULT_FONT_FAMILY.into();
}
if self.terminal_font_size == 0 {
self.terminal_font_size = DEFAULT_FONT_SIZE;
}
if self.terminal_scrollback == 0 {
self.terminal_scrollback = DEFAULT_SCROLLBACK;
}
if self.terminal_cursor_style.is_empty() {
self.terminal_cursor_style = DEFAULT_CURSOR_STYLE.into();
}
if self.created_at == zero_time() {
self.created_at = d.created_at;
}
if self.updated_at == zero_time() {
self.updated_at = d.updated_at;
}
if self.port == 0 && self.protocol != Protocol::Serial {
self.port = d.port;
}
if self.protocol == Protocol::Github && self.host.is_empty() {
self.host = d.host.clone();
}
if self.display_name.is_empty() {
self.display_name = self.default_name();
}
match self.protocol {
Protocol::Ssh => {
let c = self.ssh.get_or_insert_with(SshConfig::defaults);
if c.auth_methods.is_empty() {
c.auth_methods = SshConfig::defaults().auth_methods;
}
if c.terminal_type.is_empty() {
c.terminal_type = DEFAULT_TERMINAL_TYPE.into();
}
if c.keepalive_interval == 0 {
c.keepalive_interval = DEFAULT_KEEPALIVE_INTERVAL;
}
if c.keepalive_count_max == 0 {
c.keepalive_count_max = DEFAULT_KEEPALIVE_COUNT_MAX;
}
}
Protocol::Telnet => {
let c = self.telnet.get_or_insert_with(TelnetConfig::defaults);
if c.terminal_type.is_empty() {
c.terminal_type = DEFAULT_TERMINAL_TYPE.into();
}
if c.encoding.is_empty() {
c.encoding = "utf-8".into();
}
}
Protocol::Raw => {
let c = self.raw.get_or_insert_with(RawConfig::defaults);
if c.encoding.is_empty() {
c.encoding = "utf-8".into();
}
for p in &mut c.payload_presets {
if p.id.is_empty() {
p.id = new_id();
}
if p.format.is_empty() {
p.format = "text".into();
}
}
}
Protocol::Github => {
let c = self.github.get_or_insert_with(GitHubConfig::defaults);
if c.base_url.is_empty() {
c.base_url = "https://api.github.com".into();
}
if c.refresh_interval_secs == 0 {
c.refresh_interval_secs = 300;
}
}
Protocol::Vnc => {
let c = self.vnc.get_or_insert_with(VncConfig::defaults);
if c.vnc_port == 0 {
c.vnc_port = DEFAULT_VNC_PORT.into();
}
if c.quality < 0 {
c.quality = 6;
}
if c.ssh_port == 0 {
c.ssh_port = DEFAULT_SSH_PORT.into();
}
if c.ssh_auth_method.is_empty() {
c.ssh_auth_method = "publickey".into();
}
}
Protocol::Irc => {
let c = self.irc.get_or_insert_with(IrcConfig::defaults);
if c.nick.is_empty() {
c.nick = "nexus".into();
}
if c.real_name.is_empty() {
c.real_name = "Nexus Terminal".into();
}
if c.ssh_port == 0 {
c.ssh_port = DEFAULT_SSH_PORT.into();
}
if c.ssh_auth_method.is_empty() {
c.ssh_auth_method = "publickey".into();
}
}
Protocol::Serial => {
self.serial.get_or_insert_with(SerialConfig::default);
}
Protocol::Local => {}
}
}
fn default_name(&self) -> String {
if !self.host.is_empty() {
return self.host.clone();
}
if let Some(v) = &self.vnc
&& !v.vnc_host.is_empty()
{
return v.vnc_host.clone();
}
match self.protocol {
Protocol::Github => "GitHub".into(),
p => p.label().into(),
}
}
/// The default tab name (Go's createTabName).
pub fn tab_name(&self) -> String {
if !self.display_name.is_empty() {
self.display_name.clone()
} else if !self.username.is_empty() {
format!("{}@{}", self.username, self.host)
} else {
self.host.clone()
}
}
pub fn auto_reconnect(&self) -> bool {
match self.protocol {
Protocol::Ssh => self.ssh.as_ref().is_some_and(|c| c.auto_reconnect),
Protocol::Telnet => self.telnet.as_ref().is_some_and(|c| c.auto_reconnect),
Protocol::Raw => self.raw.as_ref().is_some_and(|c| c.auto_reconnect),
Protocol::Irc => self.irc.as_ref().is_some_and(|c| c.auto_reconnect),
Protocol::Serial => self.serial.as_ref().is_some_and(|c| c.auto_reconnect),
_ => false,
}
}
pub fn max_reconnect_tries(&self) -> i64 {
let n = match self.protocol {
Protocol::Ssh => self.ssh.as_ref().map_or(0, |c| c.max_reconnect_tries),
Protocol::Telnet => self.telnet.as_ref().map_or(0, |c| c.max_reconnect_tries),
Protocol::Irc => self.irc.as_ref().map_or(0, |c| c.max_reconnect_tries),
Protocol::Serial => self.serial.as_ref().map_or(0, |c| c.max_reconnect_tries),
_ => 0,
};
if n > 0 { n } else { 5 }
}
}
impl SshConfig {
pub fn defaults() -> Self {
SshConfig {
auth_methods: vec!["publickey".into(), "password".into(), "keyboard-interactive".into()],
terminal_type: DEFAULT_TERMINAL_TYPE.into(),
keepalive_interval: DEFAULT_KEEPALIVE_INTERVAL,
keepalive_count_max: DEFAULT_KEEPALIVE_COUNT_MAX,
..Default::default()
}
}
}
impl TelnetConfig {
pub fn defaults() -> Self {
TelnetConfig { terminal_type: DEFAULT_TERMINAL_TYPE.into(), encoding: "utf-8".into(), ..Default::default() }
}
}
impl RawConfig {
pub fn defaults() -> Self {
RawConfig { encoding: "utf-8".into(), ..Default::default() }
}
}
impl GitHubConfig {
pub fn defaults() -> Self {
GitHubConfig { base_url: "https://api.github.com".into(), refresh_interval_secs: 300, ..Default::default() }
}
}
impl VncConfig {
pub fn defaults() -> Self {
VncConfig {
vnc_port: DEFAULT_VNC_PORT.into(),
scale_to_fit: true,
shared_session: true,
quality: 6,
ssh_port: DEFAULT_SSH_PORT.into(),
ssh_auth_method: "publickey".into(),
..Default::default()
}
}
}
impl IrcConfig {
pub fn defaults() -> Self {
IrcConfig {
nick: "nexus".into(),
real_name: "Nexus Terminal".into(),
server_password_mode: SecretMode::AskEveryTime,
ssh_port: DEFAULT_SSH_PORT.into(),
ssh_auth_method: "publickey".into(),
auto_reconnect: true,
..Default::default()
}
}
}
+257
View File
@@ -0,0 +1,257 @@
//! Credential storage: the OS keychain first, then an Argon2id + AES-256-GCM vault file that is
//! byte-compatible with the Go app's `vault/vault.json`.
use crate::Result;
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Mutex;
const VAULT_FILE: &str = "vault.json";
const MAX_VAULT_BYTES: u64 = 10 * 1024 * 1024;
/// Argon2id with the Go parameters: t=3, m=128 MiB, p=4, 32-byte key.
pub fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32]> {
let params = argon2::Params::new(128 * 1024, 3, 4, Some(32)).map_err(|e| e.to_string())?;
let mut key = [0u8; 32];
argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params)
.hash_password_into(password.as_bytes(), salt, &mut key)
.map_err(|e| e.to_string())?;
Ok(key)
}
pub fn random_bytes<const N: usize>() -> [u8; N] {
let mut b = [0u8; N];
rand::thread_rng().fill_bytes(&mut b);
b
}
/// nonce(12) ‖ ciphertext ‖ tag
pub fn seal(key: &[u8; 32], plaintext: &[u8]) -> Vec<u8> {
let nonce = random_bytes::<12>();
let mut out = nonce.to_vec();
out.extend(Aes256Gcm::new(key.into()).encrypt(Nonce::from_slice(&nonce), plaintext).expect("aes-gcm encrypt"));
out
}
pub fn open(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
if data.len() < 12 {
return Err("ciphertext too short".into());
}
Aes256Gcm::new(key.into())
.decrypt(Nonce::from_slice(&data[..12]), &data[12..])
.map_err(|_| "decryption failed".into())
}
#[derive(Serialize, Deserialize)]
struct VaultFile {
version: i64,
salt: String,
secrets: BTreeMap<String, String>,
}
struct Unlocked {
key: [u8; 32],
vault: VaultFile,
}
impl Drop for Unlocked {
fn drop(&mut self) {
self.key.fill(0);
}
}
pub struct Vault {
dir: PathBuf,
state: Mutex<Option<Unlocked>>,
}
impl Vault {
pub fn new(dir: PathBuf) -> Vault {
Vault { dir, state: Mutex::new(None) }
}
fn path(&self) -> PathBuf {
self.dir.join(VAULT_FILE)
}
pub fn is_initialised(&self) -> bool {
self.path().exists()
}
pub fn is_unlocked(&self) -> bool {
self.state.lock().unwrap().is_some()
}
pub fn initialise(&self, password: &str) -> Result<()> {
if self.is_initialised() {
return Err("secrets: vault already initialised".into());
}
let salt = random_bytes::<32>();
let u = Unlocked {
key: derive_key(password, &salt)?,
vault: VaultFile { version: 1, salt: B64.encode(salt), secrets: BTreeMap::new() },
};
std::fs::create_dir_all(&self.dir)?;
self.save(&u)?;
*self.state.lock().unwrap() = Some(u);
Ok(())
}
pub fn unlock(&self, password: &str) -> Result<()> {
let meta = std::fs::metadata(self.path()).map_err(|e| format!("secrets: failed to load vault: {e}"))?;
if meta.len() > MAX_VAULT_BYTES {
return Err("secrets: vault file is too large".into());
}
let vault: VaultFile = serde_json::from_slice(&std::fs::read(self.path())?)?;
let salt = B64.decode(&vault.salt).map_err(|e| format!("secrets: corrupt vault salt: {e}"))?;
let u = Unlocked { key: derive_key(password, &salt)?, vault };
// An empty vault accepts any password, as in Go; otherwise one entry must decrypt.
if let Some(v) = u.vault.secrets.values().next() {
let data = B64.decode(v).unwrap_or_default();
open(&u.key, &data).map_err(|_| "secrets: incorrect master password or corrupt vault")?;
}
*self.state.lock().unwrap() = Some(u);
Ok(())
}
pub fn lock(&self) {
*self.state.lock().unwrap() = None;
}
fn save(&self, u: &Unlocked) -> Result<()> {
let data = serde_json::to_vec_pretty(&u.vault)?;
write_private(&self.path(), &data)
}
pub fn get(&self, key: &str) -> Result<String> {
let st = self.state.lock().unwrap();
let u = st.as_ref().ok_or("secrets: vault is locked")?;
let enc = u.vault.secrets.get(key).ok_or_else(|| format!("secrets: key {key:?} not found"))?;
let data = B64.decode(enc).map_err(|e| format!("failed to decode secret: {e}"))?;
Ok(String::from_utf8_lossy(&open(&u.key, &data)?).into_owned())
}
pub fn set(&self, key: &str, value: &str) -> Result<()> {
let mut st = self.state.lock().unwrap();
let u = st.as_mut().ok_or("secrets: vault is locked")?;
let enc = B64.encode(seal(&u.key, value.as_bytes()));
u.vault.secrets.insert(key.to_string(), enc);
self.save(u)
}
pub fn delete_prefix(&self, prefix: &str) -> Result<()> {
let mut st = self.state.lock().unwrap();
let u = st.as_mut().ok_or("secrets: vault is locked")?;
u.vault.secrets.retain(|k, _| !k.starts_with(prefix));
self.save(u)
}
pub fn delete(&self, key: &str) -> Result<()> {
let mut st = self.state.lock().unwrap();
let u = st.as_mut().ok_or("secrets: vault is locked")?;
u.vault.secrets.remove(key);
self.save(u)
}
}
/// Writes a file readable only by the owner.
pub fn write_private(path: &std::path::Path, data: &[u8]) -> Result<()> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
let mut f = opts.open(path)?;
f.write_all(data)?;
f.sync_all()?;
Ok(())
}
pub const KEYCHAIN_SERVICE: &str = "NexusTerminal";
/// The service name Alchementary Link wrote under; read as a fallback (§7 of the port plan).
pub const LEGACY_KEYCHAIN_SERVICE: &str = "AlchementaryLink";
fn entry(service: &str, key: &str) -> keyring::Result<keyring::Entry> {
// go-keyring's Windows target is "service:user"; keyring's default differs.
if cfg!(windows) {
keyring::Entry::new_with_target(&format!("{service}:{key}"), service, key)
} else {
keyring::Entry::new(service, key)
}
}
/// The OS keychain. Every call can fail (no secret service, locked keyring); callers fall back
/// to the vault.
pub struct Keychain {
service: String,
legacy: Option<String>,
}
impl Keychain {
pub fn new(service: &str, legacy: Option<&str>) -> Keychain {
Keychain { service: service.into(), legacy: legacy.map(Into::into) }
}
pub fn available(&self) -> bool {
let probe = "__nexus_keychain_probe__";
entry(&self.service, probe).and_then(|e| e.set_password("probe").and_then(|_| e.delete_credential())).is_ok()
}
/// Reads a secret; a miss under the current service falls back to the legacy one and copies
/// the value forward. Legacy items are never deleted, so the old app keeps working.
pub fn get(&self, key: &str) -> Option<String> {
if let Ok(v) = entry(&self.service, key).and_then(|e| e.get_password()) {
return Some(v);
}
let legacy = self.legacy.as_deref()?;
let v = entry(legacy, key).and_then(|e| e.get_password()).ok()?;
let _ = self.set(key, &v);
Some(v)
}
pub fn set(&self, key: &str, value: &str) -> Result<()> {
entry(&self.service, key)
.and_then(|e| e.set_password(value))
.map_err(|e| format!("keychain: failed to store secret {key:?}: {e}").into())
}
pub fn delete(&self, key: &str) {
let _ = entry(&self.service, key).and_then(|e| e.delete_credential());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_go_vault_fixture() {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/vault");
let v = Vault::new(dir);
assert!(v.is_initialised());
assert!(v.unlock("wrong").is_err());
v.unlock("correct horse battery staple").unwrap();
assert_eq!(v.get("profile:00000000-0000-0000-0000-000000000001:password").unwrap(), "s3cret-ssh");
assert_eq!(v.get("profile:00000000-0000-0000-0000-000000000001:totp").unwrap(), "JBSWY3DPEHPK3PXP");
}
#[test]
fn vault_round_trip() {
let dir = std::env::temp_dir().join(format!("nexus-vault-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let v = Vault::new(dir.clone());
v.initialise("pw").unwrap();
v.set("a", "1").unwrap();
v.lock();
assert!(v.get("a").is_err());
v.unlock("pw").unwrap();
assert_eq!(v.get("a").unwrap(), "1");
std::fs::remove_dir_all(dir).unwrap();
}
}
+500
View File
@@ -0,0 +1,500 @@
//! SQLite persistence: profiles, known hosts, preferences, audit log, snippets and IRC channel
//! state. The schema and migration files are the Go app's, so existing databases open unchanged.
use crate::models::*;
use crate::Result;
use chrono::{DateTime, SecondsFormat};
use rusqlite::{Connection, OptionalExtension, Row, params};
use std::path::Path;
use std::sync::Mutex;
const MIGRATIONS: &[(i64, &str)] = &[
(1, include_str!("../migrations/001_initial.sql")),
(2, include_str!("../migrations/002_github_protocol.sql")),
(3, include_str!("../migrations/003_snippets.sql")),
(4, include_str!("../migrations/004_vnc_protocol.sql")),
(5, include_str!("../migrations/005_irc_protocol.sql")),
(6, include_str!("../migrations/006_irc_channel_state.sql")),
(7, include_str!("../migrations/007_serial_protocol.sql")),
];
const PROFILE_COLS: &str = "id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config, serial_config,
created_at, updated_at";
/// Formats like Go's `time.RFC3339` (whole seconds, `Z` for UTC).
pub fn fmt_time(t: &Time) -> String {
t.to_rfc3339_opts(SecondsFormat::Secs, true)
}
pub fn parse_time(s: &str) -> Time {
DateTime::parse_from_rfc3339(s).unwrap_or_else(|_| zero_time())
}
pub struct Store {
db: Mutex<Connection>,
}
fn json_col<T: serde::Serialize>(v: &Option<T>) -> Option<String> {
v.as_ref().and_then(|v| serde_json::to_string(v).ok())
}
fn parse_col<T: serde::de::DeserializeOwned>(s: Option<String>) -> Option<T> {
s.filter(|s| !s.is_empty() && s != "null").and_then(|s| serde_json::from_str(&s).ok())
}
fn scan_profile(r: &Row) -> rusqlite::Result<Profile> {
let protocol: String = r.get(3)?;
let tags: String = r.get::<_, Option<String>>(7)?.unwrap_or_default();
let last: Option<String> = r.get(12)?;
Ok(Profile {
id: r.get(0)?,
display_name: r.get(1)?,
description: r.get::<_, Option<String>>(2)?.unwrap_or_default(),
protocol: Protocol::from_id(&protocol).unwrap_or_default(),
host: r.get(4)?,
port: r.get(5)?,
username: r.get::<_, Option<String>>(6)?.unwrap_or_default(),
tags: serde_json::from_str::<Option<Vec<String>>>(&tags).ok().flatten().unwrap_or_default(),
folder: r.get::<_, Option<String>>(8)?.unwrap_or_default(),
environment: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
notes: r.get::<_, Option<String>>(10)?.unwrap_or_default(),
favourite: r.get::<_, Option<i64>>(11)?.unwrap_or(0) == 1,
last_connected: last.filter(|s| !s.is_empty()).and_then(|s| DateTime::parse_from_rfc3339(&s).ok()),
connect_timeout: r.get::<_, Option<i64>>(13)?.unwrap_or(0),
read_timeout: r.get::<_, Option<i64>>(14)?.unwrap_or(0),
terminal_font_family: r.get::<_, Option<String>>(15)?.unwrap_or_default(),
terminal_font_size: r.get::<_, Option<i64>>(16)?.unwrap_or(0),
terminal_scrollback: r.get::<_, Option<i64>>(17)?.unwrap_or(0),
terminal_cursor_style: r.get::<_, Option<String>>(18)?.unwrap_or_default(),
ssh: parse_col(r.get(19)?),
telnet: parse_col(r.get(20)?),
raw: parse_col(r.get(21)?),
github: parse_col(r.get(22)?),
vnc: parse_col(r.get(23)?),
irc: parse_col(r.get(24)?),
serial: parse_col(r.get(25)?),
created_at: parse_time(&r.get::<_, String>(26)?),
updated_at: parse_time(&r.get::<_, String>(27)?),
})
}
fn scan_known_host(r: &Row) -> rusqlite::Result<KnownHost> {
Ok(KnownHost {
id: r.get(0)?,
host: r.get(1)?,
port: r.get(2)?,
key_type: r.get(3)?,
fingerprint: r.get(4)?,
public_key: r.get(5)?,
first_seen: parse_time(&r.get::<_, String>(6)?),
last_seen: parse_time(&r.get::<_, String>(7)?),
trusted: r.get::<_, Option<i64>>(8)?.unwrap_or(1) == 1,
})
}
fn scan_snippet(r: &Row) -> rusqlite::Result<Snippet> {
let tags: String = r.get(5)?;
Ok(Snippet {
id: r.get(0)?,
name: r.get(1)?,
command: r.get(2)?,
description: r.get(3)?,
folder: r.get(4)?,
tags: serde_json::from_str::<Option<Vec<String>>>(&tags).ok().flatten().unwrap_or_default(),
created_at: parse_time(&r.get::<_, String>(6)?),
updated_at: parse_time(&r.get::<_, String>(7)?),
})
}
impl Store {
pub fn open(path: &Path) -> Result<Store> {
let db = Connection::open(path).map_err(|e| format!("storage: failed to open database: {e}"))?;
db.busy_timeout(std::time::Duration::from_secs(5))?;
db.pragma_update(None, "journal_mode", "WAL")?;
let s = Store { db: Mutex::new(db) };
s.migrate().map_err(|e| format!("storage: migration failed: {e}"))?;
Ok(s)
}
fn migrate(&self) -> Result<()> {
let db = self.db.lock().unwrap();
db.execute_batch("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)")?;
let current: i64 = db.query_row("SELECT COALESCE(MAX(version), 0) FROM schema_version", [], |r| r.get(0))?;
for (ver, sql) in MIGRATIONS {
if *ver > current {
db.execute_batch(sql).map_err(|e| format!("failed to apply migration {ver:03}: {e}"))?;
}
}
Ok(())
}
fn profile_params(p: &Profile) -> [Box<dyn rusqlite::ToSql>; 25] {
[
Box::new(p.display_name.clone()),
Box::new(p.description.clone()),
Box::new(p.protocol.id()),
Box::new(p.host.clone()),
Box::new(p.port),
Box::new(p.username.clone()),
Box::new(serde_json::to_string(&p.tags).unwrap_or_else(|_| "[]".into())),
Box::new(p.folder.clone()),
Box::new(p.environment.clone()),
Box::new(p.notes.clone()),
Box::new(p.favourite as i64),
Box::new(p.last_connected.as_ref().map(fmt_time)),
Box::new(p.connect_timeout),
Box::new(p.read_timeout),
Box::new(p.terminal_font_family.clone()),
Box::new(p.terminal_font_size),
Box::new(p.terminal_scrollback),
Box::new(p.terminal_cursor_style.clone()),
Box::new(json_col(&p.ssh)),
Box::new(json_col(&p.telnet)),
Box::new(json_col(&p.raw)),
Box::new(json_col(&p.github)),
Box::new(json_col(&p.vnc)),
Box::new(json_col(&p.irc)),
Box::new(json_col(&p.serial)),
]
}
pub fn create_profile(&self, p: &Profile) -> Result<()> {
let mut args: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(p.id.clone())];
args.extend(Self::profile_params(p));
args.push(Box::new(fmt_time(&p.created_at)));
args.push(Box::new(fmt_time(&p.updated_at)));
let sql = format!("INSERT INTO profiles ({PROFILE_COLS}) VALUES ({})", vec!["?"; 28].join(","));
self.db
.lock()
.unwrap()
.execute(&sql, rusqlite::params_from_iter(args.iter().map(|b| b.as_ref())))
.map_err(|e| format!("storage: failed to create profile: {e}"))?;
Ok(())
}
pub fn update_profile(&self, p: &Profile) -> Result<()> {
let mut args: Vec<Box<dyn rusqlite::ToSql>> = Self::profile_params(p).into_iter().collect();
args.push(Box::new(fmt_time(&p.updated_at)));
args.push(Box::new(p.id.clone()));
let n = self
.db
.lock()
.unwrap()
.execute(
"UPDATE profiles SET display_name=?, description=?, protocol=?, host=?, port=?, username=?,
tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?,
connect_timeout=?, read_timeout=?,
terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?,
ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?, irc_config=?, serial_config=?,
updated_at=? WHERE id=?",
rusqlite::params_from_iter(args.iter().map(|b| b.as_ref())),
)
.map_err(|e| format!("storage: failed to update profile: {e}"))?;
if n == 0 {
return Err(format!("storage: profile {:?} not found", p.id).into());
}
Ok(())
}
pub fn get_profile(&self, id: &str) -> Result<Profile> {
self.db
.lock()
.unwrap()
.query_row(&format!("SELECT {PROFILE_COLS} FROM profiles WHERE id = ?"), [id], scan_profile)
.optional()?
.ok_or_else(|| format!("storage: profile {id:?} not found").into())
}
pub fn delete_profile(&self, id: &str) -> Result<()> {
let mut db = self.db.lock().unwrap();
let tx = db.transaction()?;
tx.execute("DELETE FROM irc_channel_state WHERE profile_id = ?", [id])?;
if tx.execute("DELETE FROM profiles WHERE id = ?", [id])? == 0 {
return Err(format!("storage: profile {id:?} not found").into());
}
tx.commit()?;
Ok(())
}
pub fn list_profiles(&self) -> Result<Vec<Profile>> {
let db = self.db.lock().unwrap();
let mut st = db.prepare(&format!("SELECT {PROFILE_COLS} FROM profiles ORDER BY display_name"))?;
let rows = st.query_map([], scan_profile)?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn set_favourite(&self, id: &str, fav: bool) -> Result<()> {
let n = self.db.lock().unwrap().execute(
"UPDATE profiles SET favourite = ?, updated_at = ? WHERE id = ?",
params![fav as i64, fmt_time(&now()), id],
)?;
if n == 0 {
return Err(format!("storage: profile {id:?} not found").into());
}
Ok(())
}
pub fn update_last_connected(&self, id: &str) -> Result<()> {
let now = fmt_time(&now());
self.db
.lock()
.unwrap()
.execute("UPDATE profiles SET last_connected = ?, updated_at = ? WHERE id = ?", params![now, now, id])?;
Ok(())
}
/// Imports profiles with a skip/overwrite/duplicate strategy; returns how many were written.
pub fn import_profiles(&self, profiles: Vec<Profile>, strategy: &str) -> Result<usize> {
if !matches!(strategy, "skip" | "overwrite" | "duplicate") {
return Err(format!("storage: unknown import strategy {strategy:?}").into());
}
let mut n = 0;
for mut p in profiles {
let exists = self.get_profile(&p.id).is_ok();
match (strategy, exists) {
("skip", true) => continue,
("overwrite", true) => {
self.update_profile(&p)?;
n += 1;
continue;
}
("duplicate", true) => {
p.id = new_id();
p.created_at = now();
p.updated_at = p.created_at;
}
_ => {}
}
self.create_profile(&p)?;
n += 1;
}
Ok(n)
}
pub fn get_known_host(&self, host: &str, port: i64) -> Result<Option<KnownHost>> {
Ok(self
.db
.lock()
.unwrap()
.query_row(
"SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted
FROM known_hosts WHERE host = ? AND port = ?",
params![host, port],
scan_known_host,
)
.optional()?)
}
pub fn save_known_host(&self, kh: &KnownHost) -> Result<()> {
self.db.lock().unwrap().execute(
"INSERT INTO known_hosts (id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(host, port, key_type) DO UPDATE SET
fingerprint=excluded.fingerprint, public_key=excluded.public_key,
last_seen=excluded.last_seen, trusted=excluded.trusted",
params![
if kh.id.is_empty() { new_id() } else { kh.id.clone() },
kh.host,
kh.port,
kh.key_type,
kh.fingerprint,
kh.public_key,
fmt_time(&kh.first_seen),
fmt_time(&kh.last_seen),
kh.trusted as i64
],
)?;
Ok(())
}
pub fn list_known_hosts(&self) -> Result<Vec<KnownHost>> {
let db = self.db.lock().unwrap();
let mut st = db.prepare(
"SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted
FROM known_hosts ORDER BY host, port",
)?;
let rows = st.query_map([], scan_known_host)?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn delete_known_host(&self, id: &str) -> Result<()> {
if self.db.lock().unwrap().execute("DELETE FROM known_hosts WHERE id = ?", [id])? == 0 {
return Err(format!("storage: known host {id:?} not found").into());
}
Ok(())
}
pub fn add_audit(&self, action: &str, profile_id: &str, session_id: &str, details: &str) {
let _ = self.db.lock().unwrap().execute(
"INSERT INTO audit_log (id, timestamp, action, profile_id, session_id, details) VALUES (?, ?, ?, ?, ?, ?)",
params![new_id(), fmt_time(&now()), action, profile_id, session_id, details],
);
}
pub fn list_audit(&self, limit: i64) -> Result<Vec<AuditEntry>> {
let db = self.db.lock().unwrap();
let mut st = db.prepare(
"SELECT id, timestamp, action, profile_id, session_id, details FROM audit_log ORDER BY timestamp DESC LIMIT ?",
)?;
let rows = st
.query_map([limit], |r| {
Ok(AuditEntry {
id: r.get(0)?,
timestamp: parse_time(&r.get::<_, String>(1)?),
action: r.get(2)?,
profile_id: r.get::<_, Option<String>>(3)?.unwrap_or_default(),
session_id: r.get::<_, Option<String>>(4)?.unwrap_or_default(),
details: r.get::<_, Option<String>>(5)?.unwrap_or_default(),
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn irc_last_rooms(&self, profile_id: &str) -> Result<Vec<String>> {
let db = self.db.lock().unwrap();
let mut st =
db.prepare("SELECT room_name FROM irc_channel_state WHERE profile_id = ? ORDER BY position, room_name")?;
let rows = st.query_map([profile_id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
Ok(rows)
}
pub fn save_irc_last_rooms(&self, profile_id: &str, rooms: &[String]) -> Result<()> {
let profile_id = profile_id.trim();
if profile_id.is_empty() {
return Err("storage: IRC channel state requires a profile ID".into());
}
let rooms = dedup_rooms(&[rooms]);
let mut db = self.db.lock().unwrap();
let tx = db.transaction()?;
tx.execute("DELETE FROM irc_channel_state WHERE profile_id = ?", [profile_id])?;
let now = fmt_time(&now());
for (i, room) in rooms.iter().enumerate() {
tx.execute(
"INSERT INTO irc_channel_state (profile_id, room_name, position, updated_at) VALUES (?, ?, ?, ?)",
params![profile_id, room, i as i64, now],
)?;
}
tx.commit()?;
Ok(())
}
pub fn preferences(&self) -> AppPreferences {
let v: Option<String> = self
.db
.lock()
.unwrap()
.query_row("SELECT value FROM preferences WHERE key = '_all'", [], |r| r.get(0))
.optional()
.ok()
.flatten();
let mut p = v.and_then(|v| serde_json::from_str::<AppPreferences>(&v).ok()).unwrap_or_default();
p.apply_defaults();
p
}
pub fn save_preferences(&self, p: &AppPreferences) -> Result<()> {
self.db.lock().unwrap().execute(
"INSERT INTO preferences (key, value) VALUES ('_all', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
[serde_json::to_string(p)?],
)?;
Ok(())
}
pub fn list_snippets(&self) -> Result<Vec<Snippet>> {
let db = self.db.lock().unwrap();
let mut st = db.prepare(
"SELECT id, name, command, description, folder, tags, created_at, updated_at FROM snippets ORDER BY name ASC",
)?;
let rows = st.query_map([], scan_snippet)?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn create_snippet(&self, s: &Snippet) -> Result<()> {
self.db.lock().unwrap().execute(
"INSERT INTO snippets (id, name, command, description, folder, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
params![s.id, s.name, s.command, s.description, s.folder, serde_json::to_string(&s.tags)?, fmt_time(&s.created_at), fmt_time(&s.updated_at)],
)?;
Ok(())
}
pub fn update_snippet(&self, s: &Snippet) -> Result<()> {
self.db.lock().unwrap().execute(
"UPDATE snippets SET name=?, command=?, description=?, folder=?, tags=?, updated_at=? WHERE id=?",
params![s.name, s.command, s.description, s.folder, serde_json::to_string(&s.tags)?, fmt_time(&s.updated_at), s.id],
)?;
Ok(())
}
pub fn delete_snippet(&self, id: &str) -> Result<()> {
self.db.lock().unwrap().execute("DELETE FROM snippets WHERE id = ?", [id])?;
Ok(())
}
}
/// Trims, drops blanks and removes case-insensitive duplicates, keeping first-seen order.
pub fn dedup_rooms(groups: &[&[String]]) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut out = vec![];
for g in groups {
for r in g.iter() {
let r = r.trim();
if !r.is_empty() && seen.insert(r.to_lowercase()) {
out.push(r.to_string());
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn opens_go_database_and_migrates() {
let fx = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/link.db");
let tmp = std::env::temp_dir().join(format!("nexus-store-{}.db", std::process::id()));
std::fs::copy(fx, &tmp).unwrap();
let s = Store::open(&tmp).unwrap();
let ps = s.list_profiles().unwrap();
assert_eq!(ps.len(), 6);
let ssh = ps.iter().find(|p| p.protocol == Protocol::Ssh).unwrap();
assert!(ssh.favourite);
assert_eq!(ssh.tags, ["fixture", "ssh"]);
assert_eq!(ssh.last_connected.map(|t| fmt_time(&t)).as_deref(), Some("2026-01-02T03:04:05Z"));
let c = ssh.ssh.as_ref().unwrap();
assert_eq!(c.env_vars["LANG"], "en_GB.UTF-8");
assert_eq!(c.login_scripts.len(), 2);
assert_eq!(c.port_forwards[0].bind_port, 8080);
let irc = ps.iter().find(|p| p.protocol == Protocol::Irc).unwrap();
assert_eq!(s.irc_last_rooms(&irc.id).unwrap(), ["#rust", "#nexus"]);
assert_eq!(s.list_known_hosts().unwrap()[0].fingerprint, "SHA256:abc");
assert_eq!(s.list_snippets().unwrap()[0].command, "uptime");
assert_eq!(s.preferences().terminal_theme, "dracula");
// Migration 007 lets serial profiles in.
let mut p = Profile::new(Protocol::Serial);
p.host = "/dev/ttyUSB0".into();
p.normalize();
s.create_profile(&p).unwrap();
assert_eq!(s.get_profile(&p.id).unwrap(), p.clone().with_secs());
// Round trip of an existing row is lossless.
s.update_profile(ssh).unwrap();
assert_eq!(&s.get_profile(&ssh.id).unwrap(), ssh);
drop(s);
let _ = std::fs::remove_file(&tmp);
}
impl Profile {
/// Storage keeps whole seconds.
fn with_secs(mut self) -> Self {
self.created_at = parse_time(&fmt_time(&self.created_at));
self.updated_at = parse_time(&fmt_time(&self.updated_at));
self
}
}
}
+2
View File
@@ -0,0 +1,2 @@
[toolchain]
channel = "1.99.0"