adding some buttons

This commit is contained in:
ssmithx 2026-07-28 13:39:00 -04:00
parent 2da8e2c698
commit 7b69200244
6 changed files with 155 additions and 23 deletions

View File

@ -245,6 +245,7 @@ impl RpcHandler {
"openwrt.scan" => self.handle_openwrt_scan(params).await,
"openwrt.get-status" => self.handle_openwrt_get_status(params).await,
"openwrt.forget" => self.handle_openwrt_forget().await,
"openwrt.set-password" => self.handle_openwrt_set_password(params).await,
"openwrt.provision-tollgate" => self.handle_openwrt_provision_tollgate(params).await,
"openwrt.scan-wifi" => self.handle_openwrt_scan_wifi(params).await,
"openwrt.configure-wan" => self.handle_openwrt_configure_wan(params).await,

View File

@ -172,6 +172,59 @@ impl RpcHandler {
Ok(serde_json::json!({ "ok": true }))
}
/// Set the router's SSH login password from the app — no manual SSH/
/// console session on the router required. Works for a fresh flash
/// (root has no password yet — connect with `current_password: ""`)
/// or to rotate an existing one. On success the new credentials are
/// persisted the same way a normal login does, so the app is fully
/// connected afterward with no separate "Connect" step needed.
///
/// Params: `{ "host": "192.168.1.1", "ssh_user": "root",
/// "current_password": "", "new_password": "..." }`
pub(super) async fn handle_openwrt_set_password(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let p = params.unwrap_or_default();
let host = p
.get("host")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("host is required"))?
.to_string();
let ssh_user = p
.get("ssh_user")
.and_then(|v| v.as_str())
.unwrap_or("root")
.to_string();
let current_password = p
.get("current_password")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let new_password = p
.get("new_password")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("new_password is required"))?
.to_string();
let router = Router::connect_password(&host, 22, &ssh_user, &current_password)?;
router.verify_openwrt()?;
router.set_password(&ssh_user, &new_password)?;
net_router::configure_router(
&self.config.data_dir,
net_router::RouterType::OpenWrt,
&host,
None,
Some(&ssh_user),
Some(&new_password),
)
.await?;
Ok(serde_json::json!({ "ok": true, "host": host }))
}
/// Provision TollGate on an OpenWrt router and create the "archipelago" SSID.
///
/// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "",

View File

@ -82,6 +82,22 @@ impl Router {
Ok(out)
}
/// Set `user`'s login password via BusyBox `passwd`, non-interactively.
///
/// BusyBox's passwd applet reads the new password twice from stdin even
/// without a tty (unlike shadow-utils' passwd, which requires one), so
/// piping two lines in works. Root can always set its own (or another
/// user's) password without supplying the old one first. This is what
/// lets a fresh-flash router (root has no password yet) be fully set up
/// from the app — no manual SSH/console session required.
pub fn set_password(&self, user: &str, new_password: &str) -> Result<()> {
let q = crate::uci::shell_quote(new_password);
let uq = crate::uci::shell_quote(user);
let cmd = format!("printf '%s\\n%s\\n' {q} {q} | passwd {uq} 2>&1");
self.run_ok(&cmd)?;
Ok(())
}
/// Verify the remote device is actually running OpenWrt.
pub fn verify_openwrt(&self) -> Result<String> {
let release = self

View File

@ -60,6 +60,6 @@ impl Router {
}
/// Wrap a value in single quotes, escaping any embedded single quotes.
fn shell_quote(s: &str) -> String {
pub(crate) fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}

View File

@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.115-alpha",
"version": "1.7.116-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.115-alpha",
"version": "1.7.116-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
@ -150,7 +150,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@ -1812,7 +1811,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -1836,7 +1834,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -3923,7 +3920,6 @@
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/geojson": "*"
}
@ -3973,7 +3969,6 @@
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cac": "^6.7.14",
"colorette": "^2.0.20",
@ -4474,7 +4469,6 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -4960,7 +4954,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -5979,7 +5972,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -8172,7 +8164,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@ -8222,7 +8213,6 @@
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.1.0",
"data-urls": "^5.0.0",
@ -8325,8 +8315,7 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause",
"peer": true
"license": "BSD-2-Clause"
},
"node_modules/leven": {
"version": "3.1.0",
@ -9082,7 +9071,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -9744,7 +9732,6 @@
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@ -11000,7 +10987,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -11256,7 +11242,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -11498,7 +11483,6 @@
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -11661,7 +11645,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -11675,7 +11658,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@ -11768,7 +11750,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.30",

View File

@ -87,6 +87,15 @@ const detecting = ref(false)
const detectError = ref('')
const detectedCandidates = ref<string[]>([])
// Set/change router password lets a fresh-flash router (root has no
// password yet) get fully connected without the user ever opening a
// terminal or LuCI themselves.
const showSetPassword = ref(false)
const newPassword = ref('')
const confirmPassword = ref('')
const settingPassword = ref(false)
const setPasswordError = ref('')
const provisioning = ref(false)
const provisionError = ref('')
const provisionSuccess = ref(false)
@ -148,6 +157,39 @@ async function connect() {
}
}
async function setPasswordAndConnect() {
if (!host.value.trim() || !newPassword.value) return
setPasswordError.value = ''
if (newPassword.value !== confirmPassword.value) {
setPasswordError.value = 'Passwords do not match.'
return
}
settingPassword.value = true
try {
await rpcClient.call({
method: 'openwrt.set-password',
params: {
host: host.value.trim(),
ssh_user: sshUser.value,
current_password: sshPassword.value,
new_password: newPassword.value,
},
timeout: 20000,
})
// Router accepted the new password log in with it right away so the
// user never has to separately "Connect" after this.
sshPassword.value = newPassword.value
showSetPassword.value = false
newPassword.value = ''
confirmPassword.value = ''
await connect()
} catch (e) {
setPasswordError.value = e instanceof Error ? e.message : String(e)
} finally {
settingPassword.value = false
}
}
interface WiredInterface { name: string; type: string; state: string; ipv4: string[] }
async function detectRouter() {
@ -464,6 +506,45 @@ onMounted(() => load())
>
{{ connecting ? 'Connecting…' : 'Connect' }}
</button>
<button
class="w-full text-xs text-white/40 hover:text-white/70 transition-colors text-center"
@click="showSetPassword = !showSetPassword"
>
First login, or don't know the password? Set one
</button>
<div v-if="showSetPassword" class="space-y-3 pt-2 border-t border-white/10">
<p class="text-xs text-white/40">
Sets the router's SSH password directly no need to SSH in yourself first.
Leave "Password" above blank if this is a fresh flash (no password set yet).
</p>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-white/40 mb-1">New password</label>
<input
v-model="newPassword"
type="password"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
/>
</div>
<div>
<label class="block text-xs text-white/40 mb-1">Confirm password</label>
<input
v-model="confirmPassword"
type="password"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
/>
</div>
</div>
<button
:disabled="settingPassword || !host.trim() || !newPassword"
class="glass-button w-full text-sm font-medium"
:class="settingPassword || !host.trim() || !newPassword ? 'opacity-40 cursor-not-allowed' : ''"
@click="setPasswordAndConnect"
>
{{ settingPassword ? 'Setting password…' : 'Set Password & Connect' }}
</button>
<p v-if="setPasswordError" class="text-xs text-red-400">{{ setPasswordError }}</p>
</div>
</div>
<p v-if="error" class="mt-3 text-xs text-red-400">{{ error }}</p>
</div>