Skip to content

Commit 175bfcd

Browse files
committed
chore: release v0.7.9
1 parent 91b5c2f commit 175bfcd

10 files changed

Lines changed: 103 additions & 54 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "nodenet",
33
"private": true,
4-
"version": "0.7.8",
4+
"version": "0.7.9",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "nodenet"
3-
version = "0.7.8"
3+
version = "0.7.9"
44
description = "macOS desktop VPN server controller and monitor"
55
authors = ["NodeNet"]
66
edition = "2021"

src-tauri/src/commands.rs

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -922,6 +922,7 @@ pub fn list_ssh_public_keys() -> Result<Vec<String>, String> {
922922
#[tauri::command]
923923
pub fn read_ssh_public_key(path: String) -> Result<String, String> {
924924
let path = expand_public_key_path(&path)?;
925+
let path = validate_public_key_path(path)?;
925926
fs::read_to_string(path).map_err(|error| error.to_string())
926927
}
927928

@@ -1252,9 +1253,13 @@ fn first_u16(line: &str) -> Option<u16> {
12521253
/// epoch-ms timestamps (frontend MetricPoint format saved to local disk).
12531254
fn latest_timestamps(cache: &Value) -> HashMap<String, i64> {
12541255
let mut since = HashMap::new();
1255-
let Some(obj) = cache.as_object() else { return since };
1256+
let Some(obj) = cache.as_object() else {
1257+
return since;
1258+
};
12561259
for (server_id, history) in obj {
1257-
let Some(arr) = history.as_array() else { continue };
1260+
let Some(arr) = history.as_array() else {
1261+
continue;
1262+
};
12581263
let latest_ms = arr
12591264
.iter()
12601265
.filter_map(|point| {
@@ -1281,7 +1286,9 @@ fn merge_cache_delta(base: &mut Value, delta: Value) {
12811286
return;
12821287
};
12831288
for (server_id, new_points) in delta_obj {
1284-
let Some(new_arr) = new_points.as_array() else { continue };
1289+
let Some(new_arr) = new_points.as_array() else {
1290+
continue;
1291+
};
12851292
if new_arr.is_empty() {
12861293
continue;
12871294
}
@@ -1307,6 +1314,28 @@ fn expand_public_key_path(path: &str) -> Result<PathBuf, String> {
13071314
Ok(PathBuf::from(trimmed))
13081315
}
13091316

1317+
fn validate_public_key_path(path: PathBuf) -> Result<PathBuf, String> {
1318+
if path.extension().and_then(|extension| extension.to_str()) != Some("pub") {
1319+
return Err("SSH public key must be a .pub file".to_string());
1320+
}
1321+
1322+
let base_dirs = directories::BaseDirs::new()
1323+
.ok_or_else(|| "unable to resolve user directories".to_string())?;
1324+
let ssh_dir = base_dirs.home_dir().join(".ssh");
1325+
let ssh_dir = fs::canonicalize(&ssh_dir).map_err(|error| error.to_string())?;
1326+
let path = fs::canonicalize(&path).map_err(|error| error.to_string())?;
1327+
1328+
if !path.starts_with(&ssh_dir) {
1329+
return Err("SSH public key must be inside ~/.ssh".to_string());
1330+
}
1331+
1332+
if !path.is_file() {
1333+
return Err("SSH public key path must be a file".to_string());
1334+
}
1335+
1336+
Ok(path)
1337+
}
1338+
13101339
#[cfg(test)]
13111340
mod tests {
13121341
use super::*;

src-tauri/src/config.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ pub fn upsert_server(mut server: ServerConfig) -> Result<AppConfig> {
151151
pub fn delete_server(server_id: &str) -> Result<AppConfig> {
152152
let mut config = load_config()?;
153153
config.servers.retain(|server| server.id != server_id);
154+
if config.monitor_server_id.as_deref() == Some(server_id) {
155+
config.monitor_server_id = None;
156+
}
154157
save_config(&config)?;
155158
Ok(config)
156159
}
@@ -178,6 +181,9 @@ pub fn upsert_bastion(mut bastion: BastionConfig) -> Result<AppConfig> {
178181
pub fn delete_bastion(bastion_id: &str) -> Result<AppConfig> {
179182
let mut config = load_config()?;
180183
config.bastions.retain(|bastion| bastion.id != bastion_id);
184+
if config.monitor_bastion_id.as_deref() == Some(bastion_id) {
185+
config.monitor_bastion_id = None;
186+
}
181187
save_config(&config)?;
182188
Ok(config)
183189
}

src-tauri/src/keychain.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,11 @@ pub async fn read_password(
4949
.context("failed to execute security find-generic-password")?;
5050

5151
if !output.status.success() {
52-
return Ok(None);
52+
let stderr = String::from_utf8_lossy(&output.stderr);
53+
if stderr.contains("could not be found") {
54+
return Ok(None);
55+
}
56+
return Err(anyhow!("keychain read failed: {}", stderr.trim()));
5357
}
5458

5559
let password = String::from_utf8_lossy(&output.stdout).trim().to_string();

src-tauri/src/monitor.rs

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -193,9 +193,7 @@ pub async fn ping_from_monitor(
193193
.map(|value| value.round().max(0.0) as u128);
194194
let status = if !is_online {
195195
"offline"
196-
} else if ping_ms.is_some_and(|value| value > 1_000) {
197-
"warning"
198-
} else if is_transient_failure {
196+
} else if ping_ms.is_some_and(|value| value > 1_000) || is_transient_failure {
199197
"warning"
200198
} else {
201199
"online"
@@ -1182,10 +1180,7 @@ fn shell_single_quote(value: &str) -> String {
11821180
/// `since` maps server IDs to the last known timestamp in milliseconds.
11831181
/// Returns a JSON object with the same shape as the full metrics cache,
11841182
/// containing only the new points for each server.
1185-
pub async fn fetch_metrics_delta(
1186-
app: &AppHandle,
1187-
since: &HashMap<String, i64>,
1188-
) -> Result<Value> {
1183+
pub async fn fetch_metrics_delta(app: &AppHandle, since: &HashMap<String, i64>) -> Result<Value> {
11891184
let config = load_config()?;
11901185
let Some(monitor) = monitor_server(&config)? else {
11911186
return Ok(Value::Object(Default::default()));

src-tauri/src/ssh.rs

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,7 @@ async fn reap_stale_connections() {
157157
.filter_map(|(key, conn)| {
158158
conn.try_lock()
159159
.ok()
160-
.filter(|c| {
161-
c.last_used.elapsed() >= Duration::from_secs(SSH_IDLE_TIMEOUT_SECS)
162-
})
160+
.filter(|c| c.last_used.elapsed() >= Duration::from_secs(SSH_IDLE_TIMEOUT_SECS))
163161
.map(|_| key.clone())
164162
})
165163
.collect()
@@ -660,9 +658,9 @@ async fn execute_streaming_once(
660658
.await
661659
.context("ssh stderr stream task failed")??;
662660

663-
// Exit 255 means SSH itself failed (connection lost/refused), not the remote command.
664-
// For streaming commands the output was already shown live; only surface SSH-level errors.
665-
if status.code() == Some(255) {
661+
// The output was already shown live; the final event only needs to carry
662+
// command failure state back to the UI.
663+
if !status.success() {
666664
bail!("ssh command failed with status {status}");
667665
}
668666

@@ -1156,17 +1154,31 @@ fn create_askpass_script(
11561154
let bastion_host = server.bastion_host.as_deref().unwrap_or_default();
11571155
let target_secret = target_secret.unwrap_or_default();
11581156
let bastion_secret = bastion_secret.unwrap_or(target_secret);
1159-
fs::write(
1160-
&path,
1161-
format!(
1162-
"#!/bin/sh\nprompt=\"$1\"\ncase \"$prompt\" in\n *{}*|*{}*) printf '%s\\n' {} ;;\n *) printf '%s\\n' {} ;;\nesac\n",
1163-
shell_case_pattern(&bastion_account),
1164-
shell_case_pattern(bastion_host),
1165-
shell_single_quote(bastion_secret),
1166-
shell_single_quote(target_secret)
1167-
),
1168-
)
1169-
.with_context(|| format!("failed to write askpass script {}", path.display()))?;
1157+
let script = format!(
1158+
"#!/bin/sh\nprompt=\"$1\"\ncase \"$prompt\" in\n *{}*|*{}*) printf '%s\\n' {} ;;\n *) printf '%s\\n' {} ;;\nesac\n",
1159+
shell_case_pattern(&bastion_account),
1160+
shell_case_pattern(bastion_host),
1161+
shell_single_quote(bastion_secret),
1162+
shell_single_quote(target_secret)
1163+
);
1164+
1165+
#[cfg(unix)]
1166+
{
1167+
use std::{io::Write, os::unix::fs::OpenOptionsExt};
1168+
let mut file = fs::OpenOptions::new()
1169+
.write(true)
1170+
.create_new(true)
1171+
.mode(0o700)
1172+
.open(&path)
1173+
.with_context(|| format!("failed to create askpass script {}", path.display()))?;
1174+
file.write_all(script.as_bytes())
1175+
.with_context(|| format!("failed to write askpass script {}", path.display()))?;
1176+
}
1177+
#[cfg(not(unix))]
1178+
{
1179+
fs::write(&path, script)
1180+
.with_context(|| format!("failed to write askpass script {}", path.display()))?;
1181+
}
11701182

11711183
#[cfg(unix)]
11721184
{

src-tauri/src/terminal.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -579,13 +579,10 @@ fn verify_known_host(
579579
return Ok(());
580580
}
581581

582-
return Err(std::io::Error::new(
583-
std::io::ErrorKind::Other,
584-
format!(
585-
"SSH host key for '{host_key}' has changed. \
582+
return Err(std::io::Error::other(format!(
583+
"SSH host key for '{host_key}' has changed. \
586584
If the server was reinstalled, remove it from Settings → Known Hosts."
587-
),
588-
)
585+
))
589586
.into());
590587
}
591588

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
33
"productName": "NodeNet",
4-
"version": "0.7.8",
4+
"version": "0.7.9",
55
"identifier": "com.nodenet.app",
66
"build": {
77
"beforeDevCommand": "pnpm dev",

src/components/SetupPresets.tsx

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ interface SshKeyPair {
1818

1919
type PresetId =
2020
| "install3xui"
21+
| "sshKey"
2122
| "ipReputation"
2223
| "bbr"
2324
| "benchmark"
2425
| "region"
2526
| "hardenSsh"
26-
| "ufw"
27-
| "sshKey";
27+
| "ufw";
2828

2929
interface PresetItem {
3030
id: PresetId;
@@ -36,6 +36,13 @@ interface PresetItem {
3636
}
3737

3838
const presets: PresetItem[] = [
39+
{
40+
id: "sshKey",
41+
name: "Copy SSH public key",
42+
description: "Adds a selected ~/.ssh public key to authorized_keys on the server.",
43+
command: "",
44+
recommended: true,
45+
},
3946
{
4047
id: "install3xui",
4148
name: "Install 3x-ui panel",
@@ -79,9 +86,9 @@ const presets: PresetItem[] = [
7986
{
8087
id: "hardenSsh",
8188
name: "Harden SSH",
82-
description: "Disables password auth, enables public key auth, and restarts SSH.",
89+
description: "Requires an authorized key, disables password auth, and restarts SSH.",
8390
command:
84-
"for file in /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf; do [ -f \"$file\" ] || continue; sed -i -E 's/^[#[:space:]]*PasswordAuthentication[[:space:]]+.*/PasswordAuthentication no/' \"$file\"; sed -i -E 's/^[#[:space:]]*PubkeyAuthentication[[:space:]]+.*/PubkeyAuthentication yes/' \"$file\"; sed -i -E 's/^[#[:space:]]*KbdInteractiveAuthentication[[:space:]]+.*/KbdInteractiveAuthentication no/' \"$file\"; done; printf 'PasswordAuthentication no\\nPubkeyAuthentication yes\\nKbdInteractiveAuthentication no\\n' > /etc/ssh/sshd_config.d/99-nodenet-hardening.conf && sshd -t && systemctl restart ssh",
91+
"test -s ~/.ssh/authorized_keys || { echo 'No SSH public key found in ~/.ssh/authorized_keys. Run Copy SSH public key first.' >&2; exit 1; }; for file in /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf; do [ -f \"$file\" ] || continue; sed -i -E 's/^[#[:space:]]*PasswordAuthentication[[:space:]]+.*/PasswordAuthentication no/' \"$file\"; sed -i -E 's/^[#[:space:]]*PubkeyAuthentication[[:space:]]+.*/PubkeyAuthentication yes/' \"$file\"; sed -i -E 's/^[#[:space:]]*KbdInteractiveAuthentication[[:space:]]+.*/KbdInteractiveAuthentication no/' \"$file\"; done; printf 'PasswordAuthentication no\\nPubkeyAuthentication yes\\nKbdInteractiveAuthentication no\\n' > /etc/ssh/sshd_config.d/99-nodenet-hardening.conf && sshd -t && (systemctl restart ssh || systemctl restart sshd)",
8592
recommended: true,
8693
},
8794
{
@@ -92,13 +99,6 @@ const presets: PresetItem[] = [
9299
recommended: true,
93100
outputWindow: true,
94101
},
95-
{
96-
id: "sshKey",
97-
name: "Copy SSH public key",
98-
description: "Adds a selected ~/.ssh public key to authorized_keys on the server.",
99-
command: "",
100-
recommended: true,
101-
},
102102
];
103103

104104
export default function SetupPresets({ server, onPanelInfoSaved, onServerUpdated, onDone }: SetupPresetsProps) {
@@ -147,7 +147,7 @@ export default function SetupPresets({ server, onPanelInfoSaved, onServerUpdated
147147
setNewKeyName(`nodenet_${server.id}_ed25519`);
148148
}, [server.id]);
149149

150-
const runPreset = async (preset: PresetItem) => {
150+
const runPreset = async (preset: PresetItem, rethrow = false) => {
151151
setError("");
152152
setMessage("");
153153
setRunning(preset.id);
@@ -175,7 +175,11 @@ export default function SetupPresets({ server, onPanelInfoSaved, onServerUpdated
175175
setShowPanelCredentialPrompt(true);
176176
}
177177
} catch (err) {
178-
setError(err instanceof Error ? err.message : String(err));
178+
const error = err instanceof Error ? err : new Error(String(err));
179+
setError(error.message);
180+
if (rethrow) {
181+
throw error;
182+
}
179183
} finally {
180184
setRunning(null);
181185
}
@@ -188,7 +192,7 @@ export default function SetupPresets({ server, onPanelInfoSaved, onServerUpdated
188192
try {
189193
for (const preset of presets) {
190194
if (selected[preset.id]) {
191-
await runPreset(preset);
195+
await runPreset(preset, true);
192196
}
193197
}
194198
if (selected.install3xui) {
@@ -207,16 +211,18 @@ export default function SetupPresets({ server, onPanelInfoSaved, onServerUpdated
207211
throw new Error("Your management IP is required before configuring UFW.");
208212
}
209213
const ip = shellQuote(managementIp.trim());
214+
const sshPort = Math.max(1, Math.min(65535, Math.round(server.sshPort || 22)));
210215
return [
211216
"if ! command -v ufw >/dev/null 2>&1; then apt-get update && apt-get install -y ufw; fi",
212-
`ufw allow from ${ip} to any port 22`,
213-
"ufw delete allow 22/tcp || true",
217+
`ufw allow from ${ip} to any port ${sshPort}`,
218+
`ufw delete allow ${sshPort}/tcp || true`,
219+
sshPort === 22 ? "" : "ufw delete allow 22/tcp || true",
214220
"ufw allow 65333/tcp",
215221
"ufw allow 443/tcp",
216222
"yes | ufw enable",
217223
"ufw reload",
218224
"ufw status verbose",
219-
].join(" && ");
225+
].filter(Boolean).join(" && ");
220226
}
221227

222228
if (preset.id === "sshKey") {

0 commit comments

Comments
 (0)