rust: nexus-core storage, vault, keychain, export/import, data-dir migration
This commit is contained in:
@@ -0,0 +1 @@
|
||||
target/
|
||||
Generated
+1939
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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.
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
fn main() {}
|
||||
@@ -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"] }
|
||||
@@ -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'));
|
||||
@@ -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'));
|
||||
@@ -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'));
|
||||
@@ -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'));
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "1.99.0"
|
||||
Reference in New Issue
Block a user