diff --git a/src/app.jsx b/src/app.jsx index c87b16a..0c34497 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -9,6 +9,7 @@ import ProxyList from "./screens/ProxyList.jsx"; import PlanPreview from "./screens/PlanPreview.jsx"; import RunLog from "./screens/RunLog.jsx"; import FrpConfigForm from "./screens/FrpConfigForm.jsx"; +import SshKeyPrompt from "./screens/SshKeyPrompt.jsx"; function App() { const { exit } = useApp(); @@ -67,6 +68,8 @@ function renderScreen(screen, nav) { return ; case "proxy-list": return ; + case "ssh-key": + return ; case "plan": return ; case "run": diff --git a/src/lib/runner.js b/src/lib/runner.js index b2fc2b9..c0b6ac3 100644 --- a/src/lib/runner.js +++ b/src/lib/runner.js @@ -95,10 +95,36 @@ function runStep(step, index, emitter, state) { if (step.kind === "zshrc-plugins") { return updateZshrcPlugins(step, index, emitter); } + if (step.kind === "ssh-authorized-key") { + return appendAuthorizedKey(step, index, emitter); + } return Promise.reject(new Error(`Unknown step kind: ${step.kind}`)); } +function appendAuthorizedKey(step, index, emitter) { + try { + fs.mkdirSync(step.sshDir, { recursive: true, mode: 0o700 }); + try { fs.chmodSync(step.sshDir, 0o700); } catch { /* ignore */ } + + const existing = fs.existsSync(step.path) ? fs.readFileSync(step.path, "utf8") : ""; + const lines = existing.split(/\r?\n/); + if (lines.some((line) => line.trim() === step.key)) { + emitter.emit("event", { type: "log", index, stream: "stdout", text: `key already present in ${step.path}` }); + return Promise.resolve(); + } + const next = existing.endsWith("\n") || existing.length === 0 + ? `${existing}${step.key}\n` + : `${existing}\n${step.key}\n`; + fs.writeFileSync(step.path, next, { mode: 0o600 }); + try { fs.chmodSync(step.path, 0o600); } catch { /* ignore */ } + emitter.emit("event", { type: "log", index, stream: "stdout", text: `appended key to ${step.path}` }); + return Promise.resolve(); + } catch (error) { + return Promise.reject(error); + } +} + function runCommand(step, index, emitter, state) { return new Promise((resolve, reject) => { const child = spawn(step.command, step.args, { @@ -240,6 +266,9 @@ function formatStep(step) { if (step.kind === "zshrc-plugins") { return `update plugins in ${step.path}`; } + if (step.kind === "ssh-authorized-key") { + return `append public key to ${step.path}`; + } return step.label || step.kind; } diff --git a/src/lib/tasks.js b/src/lib/tasks.js index f62c1c9..8e508de 100644 --- a/src/lib/tasks.js +++ b/src/lib/tasks.js @@ -50,6 +50,7 @@ function zshInstallPlan() { run("apt update", "sudo", ["apt", "update"]), run("apt upgrade", "sudo", ["apt", "upgrade", "-y"]), run("install zsh & friends", "sudo", ["apt", "install", "zsh", "git", "curl", "wget", "-y"]), + run("git credential helper = store", "git", ["config", "--global", "credential.helper", "store"]), run("change shell to zsh", "sudo", ["chsh", "-s", "/bin/zsh", os.userInfo().username]), run("install oh-my-zsh", "sh", ["-c", OH_MY_ZSH_COMMAND]), run("install nvm + node LTS", "bash", ["-c", NVM_INSTALL_COMMAND]), @@ -77,6 +78,33 @@ function gitPluginStep(name, repo, destination) { return run(`clone ${name}`, "git", ["clone", repo, destination]); } +function sshAuthorizedKeyPlan(rawKey) { + const key = (rawKey || "").trim(); + if (!key) { + throw new Error("Public key is required."); + } + if (/\r|\n/.test(key)) { + throw new Error("Public key must be a single line."); + } + if (!/^(ssh-(rsa|dss|ed25519)|ecdsa-sha2-\S+|sk-(ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com)\s+\S+/.test(key)) { + throw new Error("That does not look like an OpenSSH public key."); + } + + const home = os.homedir(); + return { + title: "Append public key to ~/.ssh/authorized_keys", + steps: [ + { + kind: "ssh-authorized-key", + label: "append key to authorized_keys", + sshDir: path.join(home, ".ssh"), + path: path.join(home, ".ssh", "authorized_keys"), + key, + }, + ], + }; +} + function sshInstallPlan() { return { title: "Install OpenSSH server", @@ -318,6 +346,7 @@ function writeFile(label, filePath, content) { module.exports = { zshInstallPlan, sshInstallPlan, + sshAuthorizedKeyPlan, frpInstallPlan, frpInitConfigPlan, frpAddProxyPlan, diff --git a/src/screens/MainMenu.jsx b/src/screens/MainMenu.jsx index 44ed020..9ea650b 100644 --- a/src/screens/MainMenu.jsx +++ b/src/screens/MainMenu.jsx @@ -8,6 +8,7 @@ export default function MainMenu({ nav }) { const items = [ { label: "Install zsh + oh-my-zsh + nvm", value: "zsh" }, { label: "Install OpenSSH server", value: "ssh" }, + { label: "Add SSH public key to authorized_keys", value: "ssh-key" }, { label: "FRP setup ▸", value: "frp" }, { label: "Bootstrap (zsh + ssh + frp)", value: "bootstrap" }, { label: "Quit", value: "quit" }, @@ -21,6 +22,9 @@ export default function MainMenu({ nav }) { case "ssh": nav.push({ name: "plan", props: { plan: sshInstallPlan(), origin: "main" } }); return; + case "ssh-key": + nav.push({ name: "ssh-key" }); + return; case "frp": nav.push({ name: "frp" }); return; diff --git a/src/screens/SshKeyPrompt.jsx b/src/screens/SshKeyPrompt.jsx new file mode 100644 index 0000000..6109a78 --- /dev/null +++ b/src/screens/SshKeyPrompt.jsx @@ -0,0 +1,40 @@ +import React, { useState } from "react"; +import { Box, Text, useInput } from "ink"; +import TextInput from "ink-text-input"; + +import { sshAuthorizedKeyPlan } from "../lib/tasks.js"; + +export default function SshKeyPrompt({ nav }) { + const [key, setKey] = useState(""); + const [error, setError] = useState(null); + + useInput((input, k) => { + if (k.return && key.trim().length > 0) { + try { + const plan = sshAuthorizedKeyPlan(key); + nav.replace({ name: "plan", props: { plan, origin: "main" } }); + } catch (err) { + setError(err.message); + } + } + }); + + return ( + + Paste the contents of your .pub file (single line): + It will be appended to ~/.ssh/authorized_keys (dedup'd). + + key › + { setKey(v); if (error) setError(null); }} /> + + {error ? ( + + {error} + + ) : null} + + Press Enter to continue · Esc to cancel + + + ); +}