feat: edit ssh key

This commit is contained in:
2024-12-29 19:15:04 +00:00
parent 1510b16ceb
commit f4227fa121
16 changed files with 385 additions and 226 deletions

View File

@ -103,11 +103,15 @@ export interface VmPayment {
is_paid: boolean;
}
export interface PathVm {
ssh_key_id?: number;
}
export class LNVpsApi {
constructor(
readonly url: string,
readonly publisher: EventPublisher | undefined,
) { }
) {}
async listVms() {
const { data } = await this.#handleResponse<ApiResponse<Array<VmInstance>>>(
@ -123,6 +127,13 @@ export class LNVpsApi {
return data;
}
async patchVm(id: number, req: PathVm) {
const { data } = await this.#handleResponse<ApiResponse<void>>(
await this.#req(`/api/v1/vm/${id}`, "PATCH", req),
);
return data;
}
async startVm(id: number) {
const { data } = await this.#handleResponse<ApiResponse<VmInstance>>(
await this.#req(`/api/v1/vm/${id}/start`, "PATCH"),
@ -196,20 +207,21 @@ export class LNVpsApi {
async connect_terminal(id: number) {
const u = `${this.url}/api/v1/console/${id}`;
const auth = await this.#auth_event(u, "GET");
const ws = new WebSocket(`${u}?auth=${base64.encode(
new TextEncoder().encode(JSON.stringify(auth)),
)}`);
const ws = new WebSocket(
`${u}?auth=${base64.encode(
new TextEncoder().encode(JSON.stringify(auth)),
)}`,
);
return await new Promise<WebSocket>((resolve, reject) => {
ws.onopen = () => {
resolve(ws);
}
};
ws.onerror = (e) => {
reject(e)
}
})
reject(e);
};
});
}
async #handleResponse<T extends ApiResponseBase>(rsp: Response) {
if (rsp.ok) {
return (await rsp.json()) as T;
@ -230,7 +242,7 @@ export class LNVpsApi {
.kind(EventKind.HttpAuthentication)
.tag(["u", url])
.tag(["method", method]);
})
});
}
async #auth(url: string, method: string) {

View File

@ -1,3 +1,4 @@
import classNames from "classnames";
import { MouseEventHandler } from "react";
type Props = {
@ -15,7 +16,7 @@ export function Icon(props: Props) {
<svg
width={size}
height={size}
className={props.className}
className={classNames(props.className, "cursor-pointer")}
onClick={props.onClick}
>
<use href={href} />

View File

@ -1,32 +1,24 @@
import { SnortContext } from "@snort/system-react";
import { useContext } from "react";
import { AsyncButton } from "./button";
import { loginNip7 } from "../login";
import useLogin from "../hooks/login";
import Profile from "./profile";
import { NostrLink } from "@snort/system";
import { Link, useNavigate } from "react-router-dom";
export default function LoginButton() {
const system = useContext(SnortContext);
const login = useLogin();
const navigate = useNavigate();
return !login ? (
<AsyncButton
onClick={async () => {
if (window.nostr) {
await loginNip7(system);
} else {
navigate("/new-account");
}
navigate("/login");
}}
>
Sign In
</AsyncButton>
) : (
<Link to="/account">
<Profile link={NostrLink.publicKey(login.pubkey)} />
<Profile link={NostrLink.publicKey(login.publicKey)} />
</Link>
);
}

View File

@ -51,7 +51,7 @@ export default function Modal(props: ModalProps) {
className={
props.bodyClassName ??
classNames(
"relative bg-layer-1 p-8 transition max-xl:rounded-t-3xl xl:rounded-3xl max-xl:mt-auto xl:my-auto max-lg:w-full",
"relative bg-neutral-700 p-8 transition max-xl:rounded-t-3xl xl:rounded-3xl max-xl:mt-auto xl:my-auto max-lg:w-full",
{
"max-xl:-translate-y-[calc(100vh-100dvh)]": props.ready ?? true,
"max-xl:translate-y-[50vh]": !(props.ready ?? true),

104
src/components/ssh-keys.tsx Normal file
View File

@ -0,0 +1,104 @@
import { useEffect, useMemo, useState } from "react";
import { LNVpsApi, UserSshKey } from "../api";
import useLogin from "../hooks/login";
import { ApiUrl } from "../const";
import { AsyncButton } from "./button";
export default function SSHKeySelector({
selectedKey,
setSelectedKey,
}: {
selectedKey: UserSshKey["id"];
setSelectedKey: (k: UserSshKey["id"]) => void;
}) {
const login = useLogin();
const [newKey, setNewKey] = useState("");
const [newKeyError, setNewKeyError] = useState("");
const [newKeyName, setNewKeyName] = useState("");
const [showAddKey, setShowAddKey] = useState(false);
const [sshKeys, setSshKeys] = useState<Array<UserSshKey>>([]);
const api = useMemo(() => {
if (!login?.builder) return;
const api = new LNVpsApi(ApiUrl, login.builder);
return api;
}, [login]);
async function addNewKey() {
if (!api) return;
setNewKeyError("");
try {
const nk = await api.addSshKey(newKeyName, newKey);
setNewKey("");
setNewKeyName("");
setSelectedKey(nk.id);
setShowAddKey(false);
api.listSshKeys().then((a) => setSshKeys(a));
} catch (e) {
if (e instanceof Error) {
setNewKeyError(e.message);
}
}
}
useEffect(() => {
if (!api) return;
api.listSshKeys().then((a) => {
setSshKeys(a);
if (a.length > 0) {
setSelectedKey(a[0].id);
} else {
setShowAddKey(true);
}
});
}, []);
return (
<div className="flex flex-col gap-2">
{sshKeys.length > 0 && (
<>
<b>Select SSH Key:</b>
<select
className="bg-neutral-900 p-2 rounded-xl"
value={selectedKey}
onChange={(e) => setSelectedKey(Number(e.target.value))}
>
{sshKeys.map((a) => (
<option value={a.id}>{a.name}</option>
))}
</select>
</>
)}
{!showAddKey && sshKeys.length > 0 && (
<AsyncButton onClick={() => setShowAddKey(true)}>
Add new SSH key
</AsyncButton>
)}
{(showAddKey || sshKeys.length === 0) && (
<>
<b>Add SSH Key:</b>
<textarea
rows={5}
placeholder="ssh-[rsa|ed25519] AA== id"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
/>
<input
type="text"
placeholder="Key name"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
/>
<AsyncButton
disabled={newKey.length < 10 || newKeyName.length < 2}
onClick={addNewKey}
>
Add Key
</AsyncButton>
{newKeyError && <b className="text-red-500">{newKeyError}</b>}
</>
)}
</div>
);
}

View File

@ -1,9 +1,19 @@
import { useSyncExternalStore } from "react";
import { Login } from "../login";
import { useContext, useSyncExternalStore } from "react";
import { LoginState } from "../login";
import { SnortContext } from "@snort/system-react";
export default function useLogin() {
return useSyncExternalStore(
(c) => Login.hook(c),
() => Login.snapshot(),
const session = useSyncExternalStore(
(c) => LoginState.hook(c),
() => LoginState.snapshot(),
);
const system = useContext(SnortContext);
return session
? {
type: session.type,
publicKey: session.publicKey,
builder: LoginState.getSigner(),
system,
}
: undefined;
}

View File

@ -1,53 +1,112 @@
import { ExternalStore } from "@snort/shared";
import {
EventSigner,
EventPublisher,
Nip46Signer,
Nip7Signer,
PrivateKeySigner,
SystemInterface,
UserState,
} from "@snort/system";
class LoginShell extends ExternalStore<UserState<void> | undefined> {
#state?: UserState<void>;
export interface LoginSession {
type: "nip7" | "nsec" | "nip46";
publicKey: string;
privateKey?: string;
bunker?: string;
}
class LoginStore extends ExternalStore<LoginSession | undefined> {
#session?: LoginSession;
#signer?: EventPublisher;
async login(signer: EventSigner, system: SystemInterface) {
if (this.#state !== undefined) {
throw new Error("Already logged in");
constructor() {
super();
const s = window.localStorage.getItem("session");
if (s) {
this.#session = JSON.parse(s);
// patch session
if (this.#session) {
this.#session.type ??= "nip7";
}
}
const pubkey = await signer.getPubKey();
this.#state = new UserState<void>(pubkey);
await this.#state.init(signer, system);
this.#state.on("change", () => this.notifyChange());
this.notifyChange();
}
takeSnapshot() {
return this.#state;
return this.#session ? { ...this.#session } : undefined;
}
logout() {
this.#session = undefined;
this.#signer = undefined;
this.#save();
}
login(pubkey: string, type: LoginSession["type"] = "nip7") {
this.#session = {
type: type ?? "nip7",
publicKey: pubkey,
};
this.#save();
}
loginPrivateKey(key: string) {
const s = new PrivateKeySigner(key);
this.#session = {
type: "nsec",
publicKey: s.getPubKey(),
privateKey: key,
};
this.#save();
}
loginBunker(url: string, localKey: string, remotePubkey: string) {
this.#session = {
type: "nip46",
publicKey: remotePubkey,
privateKey: localKey,
bunker: url,
};
this.#save();
}
getSigner() {
if (!this.#signer && this.#session) {
switch (this.#session.type) {
case "nsec":
this.#signer = new EventPublisher(
new PrivateKeySigner(this.#session.privateKey!),
this.#session.publicKey,
);
break;
case "nip46":
this.#signer = new EventPublisher(
new Nip46Signer(
this.#session.bunker!,
new PrivateKeySigner(this.#session.privateKey!),
),
this.#session.publicKey,
);
break;
case "nip7":
this.#signer = new EventPublisher(
new Nip7Signer(),
this.#session.publicKey,
);
break;
}
}
if (this.#signer) {
return this.#signer;
}
throw "Signer not setup!";
}
#save() {
if (this.#session) {
window.localStorage.setItem("session", JSON.stringify(this.#session));
} else {
window.localStorage.removeItem("session");
}
this.notifyChange();
}
}
export const Login = new LoginShell();
export async function loginNip7(system: SystemInterface) {
const signer = new Nip7Signer();
const pubkey = await signer.getPubKey();
if (pubkey) {
await Login.login(signer, system);
} else {
throw new Error("No nostr extension found");
}
}
export async function loginPrivateKey(
system: SystemInterface,
key: string | Uint8Array | PrivateKeySigner,
) {
const signer =
key instanceof PrivateKeySigner ? key : new PrivateKeySigner(key);
const pubkey = signer.getPubKey();
if (pubkey) {
await Login.login(signer, system);
} else {
throw new Error("No nostr extension found");
}
}
export const LoginState = new LoginStore();

View File

@ -31,7 +31,7 @@ const router = createBrowserRouter([
element: <HomePage />,
},
{
path: "/new-account",
path: "/login",
element: <SignUpPage />,
},
{

View File

@ -1,7 +1,6 @@
import { useEffect, useState } from "react";
import { LNVpsApi, VmInstance } from "../api";
import useLogin from "../hooks/login";
import { EventPublisher } from "@snort/system";
import { ApiUrl } from "../const";
import VpsInstanceRow from "../components/vps-instance";
@ -10,11 +9,8 @@ export default function AccountPage() {
const [vms, setVms] = useState<Array<VmInstance>>([]);
async function loadVms() {
if (!login?.signer) return;
const api = new LNVpsApi(
ApiUrl,
new EventPublisher(login.signer, login.pubkey),
);
if (!login?.builder) return;
const api = new LNVpsApi(ApiUrl, login.builder);
const vms = await api.listVms();
setVms(vms);
}

View File

@ -1,75 +1,34 @@
import { useLocation, useNavigate } from "react-router-dom";
import { LNVpsApi, UserSshKey, VmOsImage, VmTemplate } from "../api";
import { LNVpsApi, VmOsImage, VmTemplate } from "../api";
import { useEffect, useState } from "react";
import CostLabel from "../components/cost";
import { ApiUrl } from "../const";
import { EventPublisher } from "@snort/system";
import useLogin from "../hooks/login";
import { AsyncButton } from "../components/button";
import classNames from "classnames";
import VpsResources from "../components/vps-resources";
import OsImageName from "../components/os-image-name";
import SSHKeySelector from "../components/ssh-keys";
export default function OrderPage() {
const { state } = useLocation();
const login = useLogin();
const navigate = useNavigate();
const template = state as VmTemplate | undefined;
const [newKey, setNewKey] = useState("");
const [newKeyError, setNewKeyError] = useState("");
const [newKeyName, setNewKeyName] = useState("");
const [useImage, setUseImage] = useState(-1);
const [useSshKey, setUseSshKey] = useState(-1);
const [showAddKey, setShowAddKey] = useState(false);
const [images, setImages] = useState<Array<VmOsImage>>([]);
const [sshKeys, setSshKeys] = useState<Array<UserSshKey>>([]);
const [orderError, setOrderError] = useState("");
useEffect(() => {
if (!login?.signer) return;
const api = new LNVpsApi(
ApiUrl,
new EventPublisher(login.signer, login.pubkey),
);
if (!login?.builder) return;
const api = new LNVpsApi(ApiUrl, login.builder);
api.listOsImages().then((a) => setImages(a));
api.listSshKeys().then((a) => {
setSshKeys(a);
if (a.length > 0) {
setUseSshKey(a[0].id);
} else {
setShowAddKey(true);
}
});
}, [login]);
async function addNewKey() {
if (!login?.signer) return;
setNewKeyError("");
const api = new LNVpsApi(
ApiUrl,
new EventPublisher(login.signer, login.pubkey),
);
try {
const nk = await api.addSshKey(newKeyName, newKey);
setNewKey("");
setNewKeyName("");
setUseSshKey(nk.id);
setShowAddKey(false);
api.listSshKeys().then((a) => setSshKeys(a));
} catch (e) {
if (e instanceof Error) {
setNewKeyError(e.message);
}
}
}
async function createOrder() {
if (!login?.signer || !template) return;
const api = new LNVpsApi(
ApiUrl,
new EventPublisher(login.signer, login.pubkey),
);
if (!login?.builder || !template) return;
const api = new LNVpsApi(ApiUrl, login.builder);
setOrderError("");
try {
@ -122,51 +81,7 @@ export default function OrderPage() {
))}
</div>
<hr />
<div className="flex flex-col gap-2">
{sshKeys.length > 0 && (
<>
<b>Select SSH Key:</b>
<select
className="bg-neutral-900 p-2 rounded-xl"
value={useSshKey}
onChange={(e) => setUseSshKey(Number(e.target.value))}
>
{sshKeys.map((a) => (
<option value={a.id}>{a.name}</option>
))}
</select>
</>
)}
{!showAddKey && sshKeys.length > 0 && (
<AsyncButton onClick={() => setShowAddKey(true)}>
Add new SSH key
</AsyncButton>
)}
{(showAddKey || sshKeys.length === 0) && (
<>
<b>Add SSH Key:</b>
<textarea
rows={5}
placeholder="ssh-[rsa|ed25519] AA== id"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
/>
<input
type="text"
placeholder="Key name"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
/>
<AsyncButton
disabled={newKey.length < 10 || newKeyName.length < 2}
onClick={addNewKey}
>
Add Key
</AsyncButton>
{newKeyError && <b className="text-red-500">{newKeyError}</b>}
</>
)}
</div>
<SSHKeySelector selectedKey={useSshKey} setSelectedKey={setUseSshKey} />
<AsyncButton
disabled={useSshKey === -1 || useImage === -1}
onClick={createOrder}

View File

@ -1,12 +1,17 @@
import { EventPublisher, PrivateKeySigner } from "@snort/system";
import {
EventPublisher,
Nip46Signer,
Nip7Signer,
PrivateKeySigner,
} from "@snort/system";
import { AsyncButton } from "../components/button";
import { useContext, useState } from "react";
import { bech32ToHex, hexToBech32 } from "@snort/shared";
import { openFile } from "../utils";
import { SnortContext } from "@snort/system-react";
import { Blossom } from "../blossom";
import { loginPrivateKey } from "../login";
import { useNavigate } from "react-router-dom";
import { LoginState } from "../login";
export default function SignUpPage() {
const [name, setName] = useState("");
@ -44,16 +49,23 @@ export default function SignUpPage() {
picture: pic,
});
system.BroadcastEvent(ev);
await loginPrivateKey(system, key);
LoginState.loginPrivateKey(key.privateKey);
navigate("/");
}
async function loginKey() {
setError("");
try {
const key = bech32ToHex(keyIn);
await loginPrivateKey(system, new PrivateKeySigner(key));
navigate("/");
if (keyIn.startsWith("nsec1")) {
LoginState.loginPrivateKey(bech32ToHex(keyIn));
navigate("/");
} else if (keyIn.startsWith("bunker://")) {
const signer = new Nip46Signer(keyIn);
await signer.init();
const pubkey = await signer.getPubKey();
LoginState.loginBunker(keyIn, signer.privateKey!, pubkey);
navigate("/");
}
} catch (e) {
if (e instanceof Error) {
setError(e.message);
@ -71,9 +83,26 @@ export default function SignUpPage() {
value={keyIn}
onChange={(e) => setKeyIn(e.target.value)}
/>
<AsyncButton onClick={loginKey} disabled={!keyIn.startsWith("nsec")}>
<AsyncButton
onClick={loginKey}
disabled={!keyIn.startsWith("nsec") && !keyIn.startsWith("bunker://")}
>
Login
</AsyncButton>
{window.nostr && (
<div className="flex flex-col gap-4">
Browser Extension:
<AsyncButton
onClick={async () => {
const pk = await new Nip7Signer().getPubKey();
LoginState.login(pk);
navigate("/");
}}
>
Nostr Extension
</AsyncButton>
</div>
)}
<div className="flex gap-4 items-center my-6">
<div className="text-xl">OR</div>
<div className="h-[1px] bg-neutral-800 w-full"></div>

View File

@ -1,10 +1,11 @@
import "@xterm/xterm/css/xterm.css";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { LNVpsApi, VmInstance, VmPayment } from "../api";
import VpsInstanceRow from "../components/vps-instance";
import useLogin from "../hooks/login";
import { ApiUrl } from "../const";
import { EventPublisher } from "@snort/system";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import VpsPayment from "../components/vps-payment";
import CostLabel from "../components/cost";
import { AsyncButton } from "../components/button";
@ -12,8 +13,10 @@ import { AttachAddon } from "@xterm/addon-attach";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
import "@xterm/xterm/css/xterm.css";
import { toEui64 } from "../utils";
import { Icon } from "../components/icon";
import Modal from "../components/modal";
import SSHKeySelector from "../components/ssh-keys";
const fit = new FitAddon();
@ -23,28 +26,28 @@ export default function VmPage() {
const login = useLogin();
const navigate = useNavigate();
const [payment, setPayment] = useState<VmPayment>();
const [term, setTerm] = useState<Terminal>()
const [term, setTerm] = useState<Terminal>();
const termRef = useRef<HTMLDivElement | null>(null);
const [editKey, setEditKey] = useState(false);
const [key, setKey] = useState(state?.ssh_key_id ?? -1);
const api = useMemo(() => {
if (!login?.builder) return;
return new LNVpsApi(ApiUrl, login.builder);
}, [login]);
const renew = useCallback(
async function () {
if (!login?.signer || !state) return;
const api = new LNVpsApi(
ApiUrl,
new EventPublisher(login.signer, login.pubkey),
);
if (!api || !state) return;
const p = await api.renewVm(state.id);
setPayment(p);
},
[login, state],
[api, state],
);
async function openTerminal() {
if (!login?.signer || !state) return;
const api = new LNVpsApi(
ApiUrl,
new EventPublisher(login.signer, login.pubkey),
);
if (!login?.builder || !state) return;
const api = new LNVpsApi(ApiUrl, login.builder);
const ws = await api.connect_terminal(state.id);
const te = new Terminal();
const webgl = new WebglAddon();
@ -55,7 +58,7 @@ export default function VmPage() {
te.loadAddon(fit);
te.onResize(({ cols, rows }) => {
ws.send(`${cols}:${rows}`);
})
});
const attach = new AttachAddon(ws);
te.loadAddon(attach);
setTerm(te);
@ -81,21 +84,9 @@ export default function VmPage() {
}
return (
<div className="flex flex-col gap-4">
<VpsInstanceRow vm={state} actions={false} />
<VpsInstanceRow vm={state} actions={true} />
{action === undefined && (
<>
<div className="text-xl">Renewal</div>
<div className="flex justify-between items-center">
<div>{new Date(state.expires).toDateString()}</div>
{state.template?.cost_plan && (
<div>
<CostLabel cost={state.template?.cost_plan} />
</div>
)}
</div>
<AsyncButton onClick={() => navigate("/vm/renew", { state })}>
Extend Now
</AsyncButton>
<div className="flex gap-4 items-center">
<div className="text-xl">Network:</div>
{(state.ip_assignments?.length ?? 0) === 0 && (
@ -118,7 +109,21 @@ export default function VmPage() {
<div className="text-sm bg-neutral-900 px-3 py-1 rounded-lg">
{state.ssh_key?.name}
</div>
<Icon name="pencil" onClick={() => setEditKey(true)} />
</div>
<hr />
<div className="text-xl">Renewal</div>
<div className="flex justify-between items-center">
<div>{new Date(state.expires).toDateString()}</div>
{state.template?.cost_plan && (
<div>
<CostLabel cost={state.template?.cost_plan} />
</div>
)}
</div>
<AsyncButton onClick={() => navigate("/vm/renew", { state })}>
Extend Now
</AsyncButton>
{/*
{!term && <AsyncButton onClick={openTerminal}>Connect Terminal</AsyncButton>}
{term && <div className="border p-2" ref={termRef}></div>}*/}
@ -131,11 +136,8 @@ export default function VmPage() {
<VpsPayment
payment={payment}
onPaid={async () => {
if (!login?.signer || !state) return;
const api = new LNVpsApi(
ApiUrl,
new EventPublisher(login.signer, login.pubkey),
);
if (!login?.builder || !state) return;
const api = new LNVpsApi(ApiUrl, login.builder);
const newState = await api.getVm(state.id);
navigate("/vm", {
state: newState,
@ -146,6 +148,30 @@ export default function VmPage() {
)}
</>
)}
{editKey && (
<Modal id="edit-ssh-key" onClose={() => setEditKey(false)}>
<SSHKeySelector selectedKey={key} setSelectedKey={setKey} />
<div className="flex flex-col gap-4 mt-8">
<small>After selecting a new key, please restart the VM.</small>
<AsyncButton
onClick={async () => {
if (!state) return;
await api?.patchVm(state.id, {
ssh_key_id: key,
});
const ns = await api?.getVm(state?.id);
navigate(".", {
state: ns,
replace: true,
});
setEditKey(false);
}}
>
Save
</AsyncButton>
</div>
</Modal>
)}
</div>
);
}

View File

@ -31,13 +31,23 @@ export async function openFile(): Promise<File | undefined> {
});
}
export function toEui64(prefix: string, mac: string) {
const macData = base16.decode(mac.replace(/:/g, "").toUpperCase());
const macExtended = new Uint8Array([...macData.subarray(0, 3), 0xff, 0xfe, ...macData.subarray(3, 6)])
const macExtended = new Uint8Array([
...macData.subarray(0, 3),
0xff,
0xfe,
...macData.subarray(3, 6),
]);
macExtended[0] |= 0x02;
return (prefix + base16.encode(macExtended.subarray(0, 2)) + ":"
+ base16.encode(macExtended.subarray(2, 4)) + ":"
+ base16.encode(macExtended.subarray(4, 6)) + ":"
+ base16.encode(macExtended.subarray(6, 8))).toLowerCase();
}
return (
prefix +
base16.encode(macExtended.subarray(0, 2)) +
":" +
base16.encode(macExtended.subarray(2, 4)) +
":" +
base16.encode(macExtended.subarray(4, 6)) +
":" +
base16.encode(macExtended.subarray(6, 8))
).toLowerCase();
}