Provisioning halfway :P

This commit is contained in:
litoral05
2026-05-08 17:30:08 +01:00
parent 8075104243
commit ec2727927b
5 changed files with 659 additions and 215 deletions
+57
View File
@@ -1669,6 +1669,32 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "libssh2-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9"
dependencies = [
"cc",
"libc",
"libz-sys",
"openssl-sys",
"pkg-config",
"vcpkg",
]
[[package]]
name = "libz-sys"
version = "1.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.2" version = "0.8.2"
@@ -1696,6 +1722,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"ssh2",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
@@ -2045,6 +2072,18 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl-sys"
version = "0.9.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -2837,6 +2876,18 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "ssh2"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f84d13b3b8a0d4e91a2629911e951db1bb8671512f5c09d7d4ba34500ba68c8"
dependencies = [
"bitflags 2.11.1",
"libc",
"libssh2-sys",
"parking_lot",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@@ -3716,6 +3767,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
+1
View File
@@ -17,3 +17,4 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tauri-plugin-dialog = "2.7.1" tauri-plugin-dialog = "2.7.1"
tauri-plugin-fs = "2.5.1" tauri-plugin-fs = "2.5.1"
ssh2 = "0.9"
+200 -5
View File
@@ -1,16 +1,211 @@
use std::process::Command;
#[tauri::command] #[tauri::command]
pub async fn remove_known_host( pub async fn remove_known_host(
ip: String, ip: String,
) -> Result<String, String> { ) -> Result<String, String> {
if ip.trim().is_empty() { if ip.trim().is_empty() {
return Err( return Err(
"ip address cannot be empty" "ip address cannot be empty".into(),
.into(),
); );
} }
Ok(format!( let output = Command::new("ssh-keygen")
"removed stale known_hosts entry for {}", .args(["-R", &ip])
ip, .output()
.map_err(|error| {
format!(
"failed to run ssh-keygen: {}",
error,
)
})?;
if output.status.success() {
Ok(format!(
"removed stale known_hosts entry for {}",
ip,
))
} else {
Err(String::from_utf8_lossy(&output.stderr)
.to_string())
}
}
#[tauri::command]
pub async fn probe_router_ssh(
ip: String,
) -> Result<String, String> {
if ip.trim().is_empty() {
return Err(
"router IP is required".into(),
);
}
let output = Command::new("ssh")
.args([
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=5",
"-o",
"StrictHostKeyChecking=accept-new",
&format!("root@{}", ip),
"ubus call system board",
])
.output()
.map_err(|error| {
format!(
"failed to run ssh command: {}",
error,
)
})?;
let stdout =
String::from_utf8_lossy(&output.stdout)
.to_string();
let stderr =
String::from_utf8_lossy(&output.stderr)
.to_string();
if output.status.success() {
return Ok(stdout);
}
if stderr.contains(
"REMOTE HOST IDENTIFICATION HAS CHANGED",
) {
return Err(format!(
"STALE_HOST_KEY: SSH host key mismatch for {}. Remove known_hosts entry and retry.",
ip,
));
}
if stderr.contains("Permission denied")
|| stderr.contains("publickey")
|| stderr.contains("password")
{
return Err(format!(
"AUTH_REQUIRED: Router is reachable at {}, but SSH requires password login. Try manually: ssh root@{} then run: ubus call system board",
ip, ip,
));
}
Err(format!(
"SSH_FAILED: {}\n{}",
stderr, stdout,
)) ))
}
use ssh2::Session;
use std::{
io::Read,
net::{TcpStream, ToSocketAddrs},
time::Duration,
};
#[tauri::command]
pub async fn inspect_router_with_password(
ip: String,
password: String,
) -> Result<String, String> {
if ip.trim().is_empty() {
return Err("router IP is required".into());
}
if password.trim().is_empty() {
return Err("router password is required".into());
}
let address = format!("{}:22", ip);
let socket_address = address
.to_socket_addrs()
.map_err(|error| {
format!("failed to resolve router address: {}", error)
})?
.next()
.ok_or_else(|| {
format!("failed to resolve router address {}", address)
})?;
let tcp = TcpStream::connect_timeout(
&socket_address,
Duration::from_secs(5),
)
.map_err(|error| {
format!("failed to connect to SSH on {}: {}", ip, error)
})?;
tcp.set_read_timeout(Some(Duration::from_secs(10)))
.map_err(|error| {
format!("failed to set SSH read timeout: {}", error)
})?;
tcp.set_write_timeout(Some(Duration::from_secs(10)))
.map_err(|error| {
format!("failed to set SSH write timeout: {}", error)
})?;
let mut session = Session::new()
.map_err(|error| {
format!("failed to create SSH session: {}", error)
})?;
session.set_tcp_stream(tcp);
session.handshake().map_err(|error| {
format!("SSH handshake failed for {}: {}", ip, error)
})?;
session
.userauth_password("root", &password)
.map_err(|error| {
format!(
"SSH authentication failed for root@{}: {}",
ip, error
)
})?;
if !session.authenticated() {
return Err(format!(
"SSH authentication failed for root@{}",
ip
));
}
let mut channel = session.channel_session().map_err(|error| {
format!("failed to open SSH channel: {}", error)
})?;
channel
.exec("ubus call system board")
.map_err(|error| {
format!("failed to run router inspection command: {}", error)
})?;
let mut stdout = String::new();
channel
.read_to_string(&mut stdout)
.map_err(|error| {
format!("failed to read router inspection output: {}", error)
})?;
channel.wait_close().map_err(|error| {
format!("failed to close SSH channel: {}", error)
})?;
let exit_status = channel.exit_status().map_err(|error| {
format!("failed to read SSH command exit status: {}", error)
})?;
if exit_status != 0 {
return Err(format!(
"router inspection command failed with exit code {}",
exit_status
));
}
Ok(stdout)
} }
+7 -1
View File
@@ -13,7 +13,11 @@ use commands::{
verify_router, verify_router,
wait_for_ssh, wait_for_ssh,
}, },
ssh::remove_known_host, ssh::{
inspect_router_with_password,
probe_router_ssh,
remove_known_host,
},
}; };
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -25,6 +29,8 @@ pub fn run() {
read_text_file, read_text_file,
ping_host, ping_host,
remove_known_host, remove_known_host,
probe_router_ssh,
inspect_router_with_password,
detect_router, detect_router,
upload_firmware, upload_firmware,
flash_router, flash_router,
+394 -209
View File
@@ -1,277 +1,462 @@
import { useMemo, useState } from 'react'; import { useState } from 'react';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import {
AlertTriangle,
CheckCircle2,
KeyRound,
Network,
Search,
ShieldAlert,
Terminal,
Wifi,
} from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { ProvisioningStepCard } from './ProvisioningStepCard';
import { addActivityLog } from '@/services/activityLogService'; import { addActivityLog } from '@/services/activityLogService';
import { vpnApi } from '@/services/vpnApi'; type DetectionStatus =
| 'idle'
| 'checking'
| 'reachable'
| 'ssh_ok'
| 'auth_required'
| 'stale_host_key'
| 'failed';
import type { const ROUTER_IPS = [
ProvisioningState, '192.168.1.1',
ProvisionMode, '198.51.100.1',
} from '@/types/provisioning';
const states: ProvisioningState[] = [
'IDLE',
'DETECT_ROUTER',
'UPLOAD_FIRMWARE',
'FLASHING',
'WAITING_FOR_REBOOT',
'WAITING_FOR_RECONNECT',
'UPLOAD_PROVISIONING_BUNDLE',
'RUN_PROVISIONING',
'CAPTURE_PUBLIC_KEY',
'REGISTER_PEER',
'VERIFY',
'COMPLETE',
]; ];
const waitMessage = function statusTone(status: DetectionStatus) {
'Router rebooting or network changed. Please reconnect/replug the Ethernet cable if needed. Waiting for router at 198.51.100.1...'; if (
status === 'reachable' ||
status === 'ssh_ok'
) {
return 'green';
}
function buildRouterEnv(vpnIp: string) { if (
const routerIdParts = vpnIp.split('.'); status === 'auth_required' ||
const routerId = routerIdParts[routerIdParts.length - 1]; status === 'stale_host_key'
) {
return 'purple';
}
return [ if (status === 'failed') {
`ROUTER_ID=${routerId}`, return 'red';
`ROUTER_HOSTNAME=Litoral_Regas_${routerId}`, }
`ROUTER_WG_IP=${vpnIp}`,
'LAN_SUBNET=198.51.100.0/24', return 'blue';
'ROUTER_LAN_IP=198.51.100.1',
'CONTROLLER_IP=198.51.100.10',
'PLC_IP=198.51.100.50',
'OVERLAY_ROUTE=198.19.0.0/16',
'ROUTER_PASSWORD=litoralr',
'',
].join('\n');
} }
export function ProvisioningWizard() { export function ProvisioningWizard() {
const [mode, setMode] = const [selectedIp, setSelectedIp] =
useState<ProvisionMode>('new'); useState(ROUTER_IPS[0]);
const [state, setState] = const [status, setStatus] =
useState<ProvisioningState>('IDLE'); useState<DetectionStatus>('idle');
const [routerInfo, setRouterInfo] =
useState('');
const [vpnIp, setVpnIp] = useState('');
const [log, setLog] = useState<string[]>([]); const [log, setLog] = useState<string[]>([]);
const activeIndex = useMemo( function addLog(
() => states.indexOf(state), message: string,
[state], level:
); | 'info'
| 'success'
function addLog(message: string) { | 'warning'
| 'error' = 'info',
) {
setLog((currentLog) => [ setLog((currentLog) => [
`${new Date().toLocaleTimeString()} ${message}`, `${new Date().toLocaleTimeString()} ${message}`,
...currentLog, ...currentLog,
]); ]);
addActivityLog({ addActivityLog({
level: 'info', level,
source: 'desktop', source: 'desktop',
action: state, action: 'ROUTER_DETECTION',
message, message,
routerIp: routerIp: selectedIp,
state.includes('RECONNECT') ||
state.includes('PROVISION')
? '198.51.100.1'
: '192.168.1.1',
vpnIp: vpnIp || undefined,
}); });
} }
async function runWorkflow() {
async function detectRouter() {
setStatus('checking');
setRouterInfo('');
addLog(
`Starting safe router detection at ${selectedIp}`,
);
try { try {
setState('DETECT_ROUTER'); await invoke<boolean>('ping_host', {
ip: selectedIp,
});
addLog( addLog(
'Removing stale known_hosts entries for 192.168.1.1 and 198.51.100.1', `Router responded at ${selectedIp}`,
'success',
); );
await invoke('detect_router', { try {
ip: '192.168.1.1', const info = await invoke<string>(
}); 'probe_router_ssh',
{
ip: selectedIp,
},
);
const selectedVpnIp = setRouterInfo(info);
mode === 'new'
? (await vpnApi.availableIp()).vpnIp
: vpnIp;
setVpnIp(selectedVpnIp); setStatus('ssh_ok');
addLog(`Using WireGuard IP ${selectedVpnIp}`);
setState('UPLOAD_FIRMWARE'); addLog(
`SSH key authentication succeeded at ${selectedIp}`,
'success',
);
await invoke('upload_firmware', { return;
ip: '192.168.1.1', } catch (sshError) {
firmwarePath: const sshMessage = String(sshError);
'./firmware/openwrt-23.05-zbt-we826-16m.bin',
});
setState('FLASHING'); if (
sshMessage.includes(
'STALE_HOST_KEY',
)
) {
setStatus('stale_host_key');
await invoke('flash_router', { addLog(
ip: '192.168.1.1', `Stale SSH host key detected for ${selectedIp}`,
remoteFirmwarePath: '/tmp/firmware.bin', 'warning',
}); );
setState('WAITING_FOR_REBOOT'); return;
addLog(waitMessage); }
setState('WAITING_FOR_RECONNECT'); if (
sshMessage.includes(
'AUTH_REQUIRED',
)
) {
addLog(
`Router requires password authentication. Attempting automatic inspection...`,
'warning',
);
await invoke('wait_for_ssh', { try {
ip: '198.51.100.1', const passwordInfo =
}); await invoke<string>(
'inspect_router_with_password',
{
ip: selectedIp,
password: 'litoralr',
},
);
setState('UPLOAD_PROVISIONING_BUNDLE'); setRouterInfo(passwordInfo);
await invoke('upload_provisioning_bundle', { setStatus('ssh_ok');
ip: '198.51.100.1',
envContent: buildRouterEnv(selectedVpnIp),
scriptContent:
'#!/bin/sh\n# future production provision.sh\n',
});
setState('RUN_PROVISIONING'); addLog(
`Password SSH inspection succeeded at ${selectedIp}`,
'success',
);
await invoke('run_provisioning', { return;
ip: '198.51.100.1', } catch (passwordError) {
}); setStatus('auth_required');
setState('CAPTURE_PUBLIC_KEY'); addLog(
`Password authentication failed: ${String(
passwordError,
)}`,
'error',
);
const publicKey = await invoke<string>( return;
'capture_wireguard_public_key', }
}
throw sshError;
}
} catch (error) {
const message = String(error);
setStatus('failed');
addLog(
`Router detection failed: ${message}`,
'error',
);
}
}
async function fixKnownHost() {
try {
addLog(
`Removing known_hosts entry for ${selectedIp}`,
);
const result = await invoke<string>(
'remove_known_host',
{ {
ip: '198.51.100.1', ip: selectedIp,
}, },
); );
addLog( addLog(result, 'success');
`Captured public key ${publicKey.slice(
0,
12,
)}...`,
);
setState('REGISTER_PEER'); setStatus('idle');
await vpnApi.registerPeer({
vpnIp: selectedVpnIp,
publicKey,
});
setState('VERIFY');
await invoke('verify_router', {
ip: '198.51.100.1',
});
setState('COMPLETE');
addLog(
'Provisioning complete. LuCI is available over WireGuard.',
);
} catch (error) { } catch (error) {
setState('ERROR'); addLog(String(error), 'error');
addLog(String(error));
} }
} }
return ( return (
<div className="space-y-5"> <div className="flex h-full flex-col overflow-hidden">
<Card> <div className="mb-6 flex items-start justify-between">
<div className="flex flex-wrap items-end gap-4"> <div>
<div> <h1 className="text-3xl font-bold tracking-tight text-white">
<h3 className="text-xl font-bold text-white"> Provisioning
Router Provisioning Wizard </h1>
</h3>
<p className="text-sm text-slate-400"> <p className="mt-1 text-slate-400">
Validated baseline: OpenWrt Safe router detection and SSH
23.05, ZBT-WE826 16M, inspection. No flashing or
fw4/nftables, no opkg upgrade. configuration changes are performed.
</p> </p>
</div>
<select
className="rounded-xl border border-white/10 bg-ink-950 p-2 text-sm text-white"
value={mode}
onChange={(event) =>
setMode(
event.target.value as ProvisionMode,
)
}
>
<option value="new">
New Router
</option>
<option value="reprovision">
Reprovision existing VPN IP
</option>
</select>
{mode === 'reprovision' && (
<input
className="rounded-xl border border-white/10 bg-ink-950 p-2 text-sm text-white"
placeholder="198.19.1.203"
value={vpnIp}
onChange={(event) =>
setVpnIp(event.target.value)
}
/>
)}
<Button onClick={runWorkflow}>
Start Workflow
</Button>
<Badge
tone={
state === 'ERROR'
? 'red'
: state === 'COMPLETE'
? 'green'
: 'blue'
}
>
{state}
</Badge>
</div> </div>
{(state === 'WAITING_FOR_REBOOT' || <Badge tone={statusTone(status)}>
state === {status.replace(/_/g, ' ')}
'WAITING_FOR_RECONNECT') && ( </Badge>
<p className="mt-4 rounded-xl bg-purple-500/10 p-4 text-purple-200">
{waitMessage}
</p>
)}
</Card>
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
{states.map((provisioningState, index) => (
<ProvisioningStepCard
key={provisioningState}
state={provisioningState}
active={index === activeIndex}
/>
))}
</div> </div>
<Card> <div className="grid min-h-0 flex-1 grid-cols-12 gap-5 overflow-hidden">
<h4 className="mb-3 font-semibold text-white"> <Card className="col-span-7 flex flex-col">
Technician Log <div className="mb-6 flex items-center gap-4">
</h4> <div className="rounded-2xl bg-blue-500/10 p-3 text-blue-300">
<Search size={24} />
</div>
<pre className="max-h-72 overflow-auto whitespace-pre-wrap text-sm text-slate-300"> <div>
{log.join('\n')} <h2 className="text-xl font-semibold text-white">
Safe Router Detection
</h2>
<p className="text-sm text-slate-400">
Check reachability and attempt
read-only SSH inspection.
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{ROUTER_IPS.map((ip) => {
const active = selectedIp === ip;
return (
<button
key={ip}
type="button"
onClick={() => setSelectedIp(ip)}
className={`rounded-2xl border p-4 text-left transition ${active
? 'border-blue-500/40 bg-blue-500/10'
: 'border-white/10 bg-white/[0.02] hover:border-blue-500/20 hover:bg-white/[0.04]'
}`}
>
<div className="flex items-center gap-3">
<Network
size={18}
className={
active
? 'text-blue-300'
: 'text-slate-500'
}
/>
<div>
<p className="font-mono text-sm font-semibold text-white">
{ip}
</p>
<p className="mt-1 text-xs text-slate-500">
{ip === '192.168.1.1'
? 'Factory/default OpenWrt'
: 'Provisioned Litoral Regas LAN'}
</p>
</div>
</div>
</button>
);
})}
</div>
<div className="mt-6 rounded-2xl border border-white/10 bg-slate-950/70 p-5">
<div className="flex items-start gap-4">
<div className="rounded-2xl bg-green-500/10 p-3 text-green-300">
<Wifi size={22} />
</div>
<div className="flex-1">
<p className="font-semibold text-white">
Detection Target
</p>
<p className="mt-1 font-mono text-sm text-slate-400">
root@{selectedIp}
</p>
<p className="mt-3 text-sm text-slate-400">
Already provisioned routers
use password{' '}
<span className="font-mono text-slate-200">
litoralr
</span>
.
</p>
</div>
<Badge tone={statusTone(status)}>
{status.replace(/_/g, ' ')}
</Badge>
</div>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<Button
type="button"
onClick={detectRouter}
disabled={status === 'checking'}
>
<Search size={16} />
Detect Router
</Button>
<Button
type="button"
variant="secondary"
onClick={fixKnownHost}
>
<ShieldAlert size={16} />
Fix Known Host
</Button>
</div>
{(status === 'auth_required' ||
status === 'stale_host_key') && (
<div className="mt-6 rounded-2xl border border-purple-500/20 bg-purple-500/10 p-5">
<div className="flex gap-3">
<AlertTriangle
size={20}
className="mt-0.5 text-purple-300"
/>
<div>
<p className="font-semibold text-purple-200">
Manual SSH may be required
</p>
<p className="mt-2 text-sm text-purple-100/80">
Open a terminal and run:
</p>
<pre className="mt-3 rounded-xl bg-slate-950 p-3 font-mono text-sm text-slate-200">
ssh root@{selectedIp}
</pre>
<p className="mt-3 text-sm text-purple-100/80">
Password for provisioned
routers:
</p>
<pre className="mt-3 rounded-xl bg-slate-950 p-3 font-mono text-sm text-slate-200">
litoralr
</pre>
<p className="mt-3 text-sm text-purple-100/80">
Once connected, run this
read-only command:
</p>
<pre className="mt-3 rounded-xl bg-slate-950 p-3 font-mono text-sm text-slate-200">
ubus call system board
</pre>
</div>
</div>
</div>
)}
</Card>
<div className="col-span-5 flex min-h-0 flex-col gap-5">
<Card>
<div className="mb-4 flex items-center gap-3">
<div className="rounded-2xl bg-green-500/10 p-3 text-green-300">
<CheckCircle2 size={22} />
</div>
<div>
<h2 className="text-lg font-semibold text-white">
Current Safety Scope
</h2>
<p className="text-sm text-slate-400">
This screen is read-only.
</p>
</div>
</div>
<div className="space-y-3 text-sm text-slate-300">
<p> Ping reachability check</p>
<p> SSH host key detection</p>
<p> Read-only router inspection</p>
<p> No firmware flashing</p>
<p> No config upload</p>
<p> No password changes</p>
</div>
</Card>
<Card className="min-h-0 flex-1 overflow-hidden">
<div className="mb-4 flex items-center gap-3">
<div className="rounded-2xl bg-blue-500/10 p-3 text-blue-300">
<Terminal size={22} />
</div>
<div>
<h2 className="text-lg font-semibold text-white">
Router Info
</h2>
<p className="text-sm text-slate-400">
Output from ubus system board.
</p>
</div>
</div>
<pre className="min-h-[180px] overflow-auto whitespace-pre-wrap rounded-2xl border border-white/10 bg-slate-950 p-4 font-mono text-xs text-slate-300">
{routerInfo ||
'No router information captured yet.'}
</pre>
</Card>
</div>
</div>
<Card className="mt-5 max-h-44 overflow-hidden">
<h3 className="mb-3 font-semibold text-white">
Technician Log
</h3>
<pre className="max-h-28 overflow-auto whitespace-pre-wrap text-sm text-slate-300">
{log.join('\n') ||
'No provisioning activity yet.'}
</pre> </pre>
</Card> </Card>
</div> </div>