ssh-server-manager 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ssh_server_manager/__init__.py +4 -0
- ssh_server_manager/askpass.py +63 -0
- ssh_server_manager/assets/ui/app.js +452 -0
- ssh_server_manager/assets/ui/index.html +188 -0
- ssh_server_manager/assets/ui/styles.css +104 -0
- ssh_server_manager/auth.py +189 -0
- ssh_server_manager/cli.py +406 -0
- ssh_server_manager/db.py +477 -0
- ssh_server_manager/importer.py +149 -0
- ssh_server_manager/paths.py +80 -0
- ssh_server_manager/service.py +102 -0
- ssh_server_manager/ssh_config.py +162 -0
- ssh_server_manager/ssh_runner.py +262 -0
- ssh_server_manager/validation.py +89 -0
- ssh_server_manager/vault.py +113 -0
- ssh_server_manager/webapp.py +377 -0
- ssh_server_manager-0.2.0.dist-info/METADATA +173 -0
- ssh_server_manager-0.2.0.dist-info/RECORD +21 -0
- ssh_server_manager-0.2.0.dist-info/WHEEL +5 -0
- ssh_server_manager-0.2.0.dist-info/entry_points.txt +3 -0
- ssh_server_manager-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_descriptors() -> list[dict[str, Any]]:
|
|
11
|
+
try:
|
|
12
|
+
value = json.loads(os.environ.get("SSM_ASKPASS_MAP", "[]"))
|
|
13
|
+
except json.JSONDecodeError:
|
|
14
|
+
return []
|
|
15
|
+
return value if isinstance(value, list) else []
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def choose_descriptor(prompt: str, descriptors: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
19
|
+
lowered = prompt.casefold()
|
|
20
|
+
if "yes/no" in lowered or ("fingerprint" in lowered and "continue connecting" in lowered):
|
|
21
|
+
return None
|
|
22
|
+
passphrases = [item for item in descriptors if item.get("slot") == "passphrase"]
|
|
23
|
+
if "passphrase" in lowered:
|
|
24
|
+
for item in passphrases:
|
|
25
|
+
key_path = str(item.get("key_path", ""))
|
|
26
|
+
if key_path.casefold() in lowered or Path(key_path).name.casefold() in lowered:
|
|
27
|
+
return item
|
|
28
|
+
return passphrases[0] if len(passphrases) == 1 else None
|
|
29
|
+
passwords = [item for item in descriptors if item.get("slot") == "password"]
|
|
30
|
+
if "password" in lowered:
|
|
31
|
+
scored: list[tuple[int, dict[str, Any]]] = []
|
|
32
|
+
for item in passwords:
|
|
33
|
+
score = sum(
|
|
34
|
+
bool(value and str(value).casefold() in lowered)
|
|
35
|
+
for value in (item.get("username"), item.get("hostname"), item.get("alias"))
|
|
36
|
+
)
|
|
37
|
+
scored.append((score, item))
|
|
38
|
+
scored.sort(key=lambda pair: pair[0], reverse=True)
|
|
39
|
+
if scored and (scored[0][0] > 0 or len(scored) == 1):
|
|
40
|
+
return scored[0][1]
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main() -> int:
|
|
45
|
+
prompt = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
46
|
+
item = choose_descriptor(prompt, load_descriptors())
|
|
47
|
+
if not item:
|
|
48
|
+
return 1
|
|
49
|
+
try:
|
|
50
|
+
from .vault import get_vault
|
|
51
|
+
|
|
52
|
+
secret = get_vault().get_secret(item["credential_id"], item["slot"])
|
|
53
|
+
except Exception:
|
|
54
|
+
return 1
|
|
55
|
+
if not secret:
|
|
56
|
+
return 1
|
|
57
|
+
sys.stdout.write(secret)
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
raise SystemExit(main())
|
|
63
|
+
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
const state = { csrf: "", servers: [], credentials: [], auth: {} };
|
|
2
|
+
let toastTimer;
|
|
3
|
+
let revealTimer;
|
|
4
|
+
let masterResolver = null;
|
|
5
|
+
let masterRejecter = null;
|
|
6
|
+
|
|
7
|
+
const $ = (selector) => document.querySelector(selector);
|
|
8
|
+
|
|
9
|
+
function toast(message, error = false) {
|
|
10
|
+
const node = $("#toast");
|
|
11
|
+
node.textContent = message;
|
|
12
|
+
node.className = error ? "show error" : "show";
|
|
13
|
+
clearTimeout(toastTimer);
|
|
14
|
+
toastTimer = setTimeout(() => { node.className = ""; }, 4500);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function api(path, options = {}) {
|
|
18
|
+
const method = options.method || "GET";
|
|
19
|
+
const headers = { ...(options.headers || {}) };
|
|
20
|
+
if (options.body && typeof options.body !== "string") {
|
|
21
|
+
headers["Content-Type"] = "application/json";
|
|
22
|
+
options.body = JSON.stringify(options.body);
|
|
23
|
+
}
|
|
24
|
+
if (method !== "GET") headers["X-CSRF-Token"] = state.csrf;
|
|
25
|
+
const response = await fetch(path, { ...options, method, headers, credentials: "same-origin" });
|
|
26
|
+
const type = response.headers.get("content-type") || "";
|
|
27
|
+
const data = type.includes("json") ? await response.json() : await response.text();
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
const message = data.message || data.detail || data.error || `Request failed (${response.status})`;
|
|
30
|
+
throw new Error(message);
|
|
31
|
+
}
|
|
32
|
+
return data;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function escapeHtml(value) {
|
|
36
|
+
return String(value ?? "").replace(/[&<>'"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[char]));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function statusMarkup(server) {
|
|
40
|
+
if (!server.last_test_status) return '<span class="subtle">Not tested</span>';
|
|
41
|
+
const ok = server.last_test_status === "ok";
|
|
42
|
+
const detail = server.last_test_latency_ms == null ? "" : ` · ${server.last_test_latency_ms} ms`;
|
|
43
|
+
return `<span class="status-pill ${ok ? "ok" : "failed"}">${ok ? "Online" : escapeHtml(server.last_test_error_code || "Failed")}${detail}</span>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function render() {
|
|
47
|
+
$("#serverCount").textContent = state.servers.length;
|
|
48
|
+
$("#credentialCount").textContent = state.credentials.length;
|
|
49
|
+
$("#emptyServers").hidden = state.servers.length > 0;
|
|
50
|
+
$("#emptyCredentials").hidden = state.credentials.length > 0;
|
|
51
|
+
|
|
52
|
+
$("#serverRows").innerHTML = state.servers.map((server) => `
|
|
53
|
+
<tr>
|
|
54
|
+
<td><span class="alias">${escapeHtml(server.alias)}</span><br><span class="subtle">${escapeHtml(server.notes || "")}</span></td>
|
|
55
|
+
<td><code>${escapeHtml(server.username)}@${escapeHtml(server.hostname)}:${server.port}</code></td>
|
|
56
|
+
<td>${escapeHtml(server.credential_label || "OpenSSH default")}</td>
|
|
57
|
+
<td>${escapeHtml(server.proxy_jumps.join(", ") || "—")}</td>
|
|
58
|
+
<td>${statusMarkup(server)}</td>
|
|
59
|
+
<td>
|
|
60
|
+
<button class="small secondary test-server" data-id="${server.id}">Test</button>
|
|
61
|
+
<button class="small secondary copy-command" data-alias="${escapeHtml(server.alias)}">Copy command</button>
|
|
62
|
+
<button class="small secondary edit-server" data-id="${server.id}">Edit</button>
|
|
63
|
+
<button class="small danger delete-server" data-id="${server.id}">Delete</button>
|
|
64
|
+
</td>
|
|
65
|
+
</tr>`).join("");
|
|
66
|
+
|
|
67
|
+
$("#credentialRows").innerHTML = state.credentials.map((credential) => {
|
|
68
|
+
const secretState = credential.kind === "password"
|
|
69
|
+
? (credential.has_secret ? "Password stored" : "Missing password")
|
|
70
|
+
: credential.kind === "key"
|
|
71
|
+
? (credential.has_passphrase ? "Passphrase stored" : "No saved passphrase")
|
|
72
|
+
: "No stored secret";
|
|
73
|
+
const revealable = credential.has_secret || credential.has_passphrase;
|
|
74
|
+
return `<tr>
|
|
75
|
+
<td><span class="alias">${escapeHtml(credential.label)}</span></td>
|
|
76
|
+
<td>${escapeHtml(credential.kind)}</td>
|
|
77
|
+
<td>${secretState}</td>
|
|
78
|
+
<td><code>${escapeHtml(credential.key_path || "—")}</code></td>
|
|
79
|
+
<td>
|
|
80
|
+
${revealable ? `<button class="small secondary reveal-credential" data-id="${credential.id}">Reveal</button>` : ""}
|
|
81
|
+
<button class="small secondary edit-credential" data-id="${credential.id}">Edit</button>
|
|
82
|
+
<button class="small danger delete-credential" data-id="${credential.id}">Delete</button>
|
|
83
|
+
</td>
|
|
84
|
+
</tr>`;
|
|
85
|
+
}).join("");
|
|
86
|
+
|
|
87
|
+
const options = ['<option value="">OpenSSH default / agent</option>']
|
|
88
|
+
.concat(state.credentials.map((item) => `<option value="${item.id}">${escapeHtml(item.label)} (${item.kind})</option>`));
|
|
89
|
+
$("#serverCredential").innerHTML = options.join("");
|
|
90
|
+
const authText = [];
|
|
91
|
+
authText.push(`${state.auth.passkeys || 0} passkey${state.auth.passkeys === 1 ? "" : "s"} enrolled`);
|
|
92
|
+
authText.push(state.auth.master_password_enrolled ? "fallback master password enrolled" : "no fallback master password");
|
|
93
|
+
$("#authDescription").textContent = authText.join(" · ");
|
|
94
|
+
$("#enrollPasskeyButton").disabled = !state.auth.webauthn_available || !window.PublicKeyCredential;
|
|
95
|
+
$("#enrollMasterButton").disabled = state.auth.master_password_enrolled;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function refresh() {
|
|
99
|
+
const data = await api("/api/bootstrap");
|
|
100
|
+
state.csrf = data.csrf;
|
|
101
|
+
state.servers = data.servers;
|
|
102
|
+
state.credentials = data.credentials;
|
|
103
|
+
state.auth = data.auth;
|
|
104
|
+
$("#configPath").textContent = data.managed_config;
|
|
105
|
+
$("#systemStatus").textContent = "Local session active";
|
|
106
|
+
$("#systemStatus").className = "status-pill ok";
|
|
107
|
+
render();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function openServer(server = null) {
|
|
111
|
+
$("#serverDialogTitle").textContent = server ? "Edit server" : "Add server";
|
|
112
|
+
$("#serverId").value = server?.id || "";
|
|
113
|
+
$("#serverAlias").value = server?.alias || "";
|
|
114
|
+
$("#serverHostname").value = server?.hostname || "";
|
|
115
|
+
$("#serverPort").value = server?.port || 22;
|
|
116
|
+
$("#serverUsername").value = server?.username || "";
|
|
117
|
+
$("#serverCredential").value = server?.credential_id || "";
|
|
118
|
+
$("#serverProxy").value = server?.proxy_jumps.join(", ") || "";
|
|
119
|
+
$("#serverNotes").value = server?.notes || "";
|
|
120
|
+
hideServerCredentialComposer();
|
|
121
|
+
$("#serverDialog").showModal();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function updateServerCredentialFields() {
|
|
125
|
+
const kind = $("#serverCredentialNewKind").value;
|
|
126
|
+
const password = kind === "password";
|
|
127
|
+
const key = kind === "key";
|
|
128
|
+
$("#serverCredentialNewSecretField").hidden = !password;
|
|
129
|
+
$("#serverCredentialNewKeyPathField").hidden = !key;
|
|
130
|
+
$("#serverCredentialNewPassphraseField").hidden = !key;
|
|
131
|
+
$("#serverCredentialNewSecret").required = password;
|
|
132
|
+
$("#serverCredentialNewKeyPath").required = key;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function clearServerCredentialComposer() {
|
|
136
|
+
$("#serverCredentialNewLabel").value = "";
|
|
137
|
+
$("#serverCredentialNewKind").value = "password";
|
|
138
|
+
$("#serverCredentialNewSecret").value = "";
|
|
139
|
+
$("#serverCredentialNewKeyPath").value = "";
|
|
140
|
+
$("#serverCredentialNewPassphrase").value = "";
|
|
141
|
+
updateServerCredentialFields();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function hideServerCredentialComposer() {
|
|
145
|
+
$("#serverCredentialComposer").hidden = true;
|
|
146
|
+
clearServerCredentialComposer();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function showServerCredentialComposer() {
|
|
150
|
+
$("#serverCredentialComposer").hidden = false;
|
|
151
|
+
updateServerCredentialFields();
|
|
152
|
+
$("#serverCredentialNewLabel").focus();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function createServerCredential() {
|
|
156
|
+
const kind = $("#serverCredentialNewKind").value;
|
|
157
|
+
const payload = { label: $("#serverCredentialNewLabel").value.trim(), kind };
|
|
158
|
+
if (!payload.label) throw new Error("Credential label is required");
|
|
159
|
+
if (kind === "password") {
|
|
160
|
+
payload.secret = $("#serverCredentialNewSecret").value;
|
|
161
|
+
if (!payload.secret) throw new Error("Password is required");
|
|
162
|
+
}
|
|
163
|
+
if (kind === "key") {
|
|
164
|
+
payload.key_path = $("#serverCredentialNewKeyPath").value.trim();
|
|
165
|
+
if (!payload.key_path) throw new Error("Private-key path is required");
|
|
166
|
+
if ($("#serverCredentialNewPassphrase").value) payload.passphrase = $("#serverCredentialNewPassphrase").value;
|
|
167
|
+
}
|
|
168
|
+
const created = await api("/api/credentials", { method: "POST", body: payload });
|
|
169
|
+
await refresh();
|
|
170
|
+
$("#serverCredential").value = created.id;
|
|
171
|
+
hideServerCredentialComposer();
|
|
172
|
+
toast("Credential created and selected. Save the server to finish.");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function updateCredentialFields() {
|
|
176
|
+
const kind = $("#credentialKind").value;
|
|
177
|
+
$("#secretField").hidden = kind !== "password";
|
|
178
|
+
$("#keyPathField").hidden = kind !== "key";
|
|
179
|
+
$("#passphraseField").hidden = kind !== "key";
|
|
180
|
+
$("#clearPassphraseField").hidden = kind !== "key" || !$("#credentialId").value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function openCredential(credential = null) {
|
|
184
|
+
const editing = Boolean(credential);
|
|
185
|
+
$("#credentialDialogTitle").textContent = editing ? "Edit credential" : "Add credential";
|
|
186
|
+
$("#credentialId").value = credential?.id || "";
|
|
187
|
+
$("#credentialLabel").value = credential?.label || "";
|
|
188
|
+
$("#credentialKind").value = credential?.kind || "password";
|
|
189
|
+
$("#credentialKind").disabled = editing;
|
|
190
|
+
$("#credentialSecret").value = "";
|
|
191
|
+
$("#credentialSecret").required = !editing && $("#credentialKind").value === "password";
|
|
192
|
+
$("#credentialKeyPath").value = credential?.key_path || "";
|
|
193
|
+
$("#credentialPassphrase").value = "";
|
|
194
|
+
$("#credentialClearPassphrase").checked = false;
|
|
195
|
+
$("#credentialEditHint").hidden = !editing;
|
|
196
|
+
updateCredentialFields();
|
|
197
|
+
$("#credentialDialog").showModal();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function bytesFromBase64(value) {
|
|
201
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
202
|
+
const binary = atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="));
|
|
203
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function base64FromBytes(value) {
|
|
207
|
+
const bytes = new Uint8Array(value);
|
|
208
|
+
let binary = "";
|
|
209
|
+
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
|
|
210
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function preparePublicKey(options) {
|
|
214
|
+
options.challenge = bytesFromBase64(options.challenge);
|
|
215
|
+
if (options.user?.id) options.user.id = bytesFromBase64(options.user.id);
|
|
216
|
+
if (options.excludeCredentials) options.excludeCredentials.forEach((item) => { item.id = bytesFromBase64(item.id); });
|
|
217
|
+
if (options.allowCredentials) options.allowCredentials.forEach((item) => { item.id = bytesFromBase64(item.id); });
|
|
218
|
+
return options;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function serializeCredential(credential) {
|
|
222
|
+
const response = {};
|
|
223
|
+
for (const key of ["clientDataJSON", "attestationObject", "authenticatorData", "signature", "userHandle"]) {
|
|
224
|
+
if (credential.response[key]) response[key] = base64FromBytes(credential.response[key]);
|
|
225
|
+
}
|
|
226
|
+
if (credential.response.getTransports) response.transports = credential.response.getTransports();
|
|
227
|
+
return { id: credential.id, rawId: base64FromBytes(credential.rawId), type: credential.type, response };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function enrollPasskey() {
|
|
231
|
+
const options = preparePublicKey(await api("/api/auth/passkey/register/options", { method: "POST" }));
|
|
232
|
+
const credential = await navigator.credentials.create({ publicKey: options });
|
|
233
|
+
await api("/api/auth/passkey/register/verify", { method: "POST", body: serializeCredential(credential) });
|
|
234
|
+
toast("Passkey enrolled");
|
|
235
|
+
await refresh();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function authorizeReveal(credentialId) {
|
|
239
|
+
if (state.auth.passkeys > 0 && window.PublicKeyCredential) {
|
|
240
|
+
try {
|
|
241
|
+
const options = preparePublicKey(await api("/api/auth/reveal/options", { method: "POST", body: { credential_id: credentialId } }));
|
|
242
|
+
const assertion = await navigator.credentials.get({ publicKey: options });
|
|
243
|
+
await api("/api/auth/reveal/verify", {
|
|
244
|
+
method: "POST",
|
|
245
|
+
body: { credential_id: credentialId, response: serializeCredential(assertion) },
|
|
246
|
+
});
|
|
247
|
+
return;
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (!state.auth.master_password_enrolled) throw error;
|
|
250
|
+
toast("Passkey was unavailable; use the fallback master password", true);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (!state.auth.master_password_enrolled) throw new Error("Enroll a passkey or fallback master password first");
|
|
254
|
+
const password = await requestMasterPassword(false);
|
|
255
|
+
await api("/api/auth/master/verify", { method: "POST", body: { credential_id: credentialId, password } });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function requestMasterPassword(enrolling) {
|
|
259
|
+
return new Promise((resolve, reject) => {
|
|
260
|
+
masterResolver = resolve;
|
|
261
|
+
masterRejecter = reject;
|
|
262
|
+
$("#masterDialogTitle").textContent = enrolling ? "Set fallback master password" : "Authenticate to reveal";
|
|
263
|
+
$("#masterConfirmField").hidden = !enrolling;
|
|
264
|
+
$("#masterPasswordConfirm").required = enrolling;
|
|
265
|
+
$("#masterPassword").autocomplete = enrolling ? "new-password" : "current-password";
|
|
266
|
+
$("#masterPassword").value = "";
|
|
267
|
+
$("#masterPasswordConfirm").value = "";
|
|
268
|
+
$("#masterDialog").dataset.enrolling = enrolling ? "true" : "false";
|
|
269
|
+
$("#masterDialog").showModal();
|
|
270
|
+
$("#masterPassword").focus();
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function cancelMaster() {
|
|
275
|
+
if ($("#masterDialog").open) $("#masterDialog").close();
|
|
276
|
+
if (masterRejecter) masterRejecter(new Error("Authentication cancelled"));
|
|
277
|
+
masterResolver = null;
|
|
278
|
+
masterRejecter = null;
|
|
279
|
+
$("#masterPassword").value = "";
|
|
280
|
+
$("#masterPasswordConfirm").value = "";
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function showSecret(value) {
|
|
284
|
+
clearInterval(revealTimer);
|
|
285
|
+
let seconds = 15;
|
|
286
|
+
$("#revealedSecret").textContent = value;
|
|
287
|
+
$("#revealCountdown").textContent = seconds;
|
|
288
|
+
$("#revealDialog").showModal();
|
|
289
|
+
revealTimer = setInterval(() => {
|
|
290
|
+
seconds -= 1;
|
|
291
|
+
$("#revealCountdown").textContent = seconds;
|
|
292
|
+
if (seconds <= 0) closeReveal();
|
|
293
|
+
}, 1000);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function closeReveal() {
|
|
297
|
+
clearInterval(revealTimer);
|
|
298
|
+
$("#revealedSecret").textContent = "";
|
|
299
|
+
if ($("#revealDialog").open) $("#revealDialog").close();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function guarded(action) {
|
|
303
|
+
try { await action(); }
|
|
304
|
+
catch (error) { toast(error.message || String(error), true); }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
308
|
+
history.replaceState({}, "", "/");
|
|
309
|
+
guarded(refresh);
|
|
310
|
+
|
|
311
|
+
$("#refreshButton").addEventListener("click", () => guarded(refresh));
|
|
312
|
+
$("#addServerButton").addEventListener("click", () => openServer());
|
|
313
|
+
$("#addCredentialButton").addEventListener("click", () => openCredential());
|
|
314
|
+
$("#newServerCredentialButton").addEventListener("click", showServerCredentialComposer);
|
|
315
|
+
$("#cancelServerCredentialButton").addEventListener("click", hideServerCredentialComposer);
|
|
316
|
+
$("#serverCredentialNewKind").addEventListener("change", updateServerCredentialFields);
|
|
317
|
+
$("#saveServerCredentialButton").addEventListener("click", () => guarded(createServerCredential));
|
|
318
|
+
$("#credentialKind").addEventListener("change", () => {
|
|
319
|
+
updateCredentialFields();
|
|
320
|
+
$("#credentialSecret").required = !$("#credentialId").value && $("#credentialKind").value === "password";
|
|
321
|
+
});
|
|
322
|
+
document.querySelectorAll(".close-dialog").forEach((button) => button.addEventListener("click", () => button.closest("dialog").close()));
|
|
323
|
+
document.querySelectorAll(".close-reveal").forEach((button) => button.addEventListener("click", closeReveal));
|
|
324
|
+
document.querySelectorAll(".cancel-master").forEach((button) => button.addEventListener("click", cancelMaster));
|
|
325
|
+
$("#masterDialog").addEventListener("cancel", (event) => {
|
|
326
|
+
event.preventDefault();
|
|
327
|
+
cancelMaster();
|
|
328
|
+
});
|
|
329
|
+
$("#masterForm").addEventListener("submit", (event) => {
|
|
330
|
+
event.preventDefault();
|
|
331
|
+
const password = $("#masterPassword").value;
|
|
332
|
+
const enrolling = $("#masterDialog").dataset.enrolling === "true";
|
|
333
|
+
if (enrolling && password !== $("#masterPasswordConfirm").value) {
|
|
334
|
+
toast("Master passwords do not match", true);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
$("#masterDialog").close();
|
|
338
|
+
$("#masterPassword").value = "";
|
|
339
|
+
$("#masterPasswordConfirm").value = "";
|
|
340
|
+
const resolve = masterResolver;
|
|
341
|
+
masterResolver = null;
|
|
342
|
+
masterRejecter = null;
|
|
343
|
+
resolve(password);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
$("#serverForm").addEventListener("submit", (event) => {
|
|
347
|
+
event.preventDefault();
|
|
348
|
+
guarded(async () => {
|
|
349
|
+
const id = $("#serverId").value;
|
|
350
|
+
const payload = {
|
|
351
|
+
alias: $("#serverAlias").value,
|
|
352
|
+
hostname: $("#serverHostname").value,
|
|
353
|
+
port: Number($("#serverPort").value),
|
|
354
|
+
username: $("#serverUsername").value,
|
|
355
|
+
credential_id: $("#serverCredential").value || null,
|
|
356
|
+
proxy_jumps: $("#serverProxy").value.split(",").map((item) => item.trim()).filter(Boolean),
|
|
357
|
+
notes: $("#serverNotes").value,
|
|
358
|
+
};
|
|
359
|
+
await api(id ? `/api/servers/${id}` : "/api/servers", { method: id ? "PUT" : "POST", body: payload });
|
|
360
|
+
$("#serverDialog").close();
|
|
361
|
+
toast(id ? "Server updated" : "Server added");
|
|
362
|
+
await refresh();
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
$("#credentialForm").addEventListener("submit", (event) => {
|
|
367
|
+
event.preventDefault();
|
|
368
|
+
guarded(async () => {
|
|
369
|
+
const id = $("#credentialId").value;
|
|
370
|
+
const kind = $("#credentialKind").value;
|
|
371
|
+
const payload = { label: $("#credentialLabel").value, kind };
|
|
372
|
+
if (kind === "password" && $("#credentialSecret").value) payload.secret = $("#credentialSecret").value;
|
|
373
|
+
if (kind === "key") {
|
|
374
|
+
payload.key_path = $("#credentialKeyPath").value;
|
|
375
|
+
if ($("#credentialPassphrase").value) payload.passphrase = $("#credentialPassphrase").value;
|
|
376
|
+
if (id && $("#credentialClearPassphrase").checked) payload.clear_passphrase = true;
|
|
377
|
+
}
|
|
378
|
+
await api(id ? `/api/credentials/${id}` : "/api/credentials", { method: id ? "PUT" : "POST", body: payload });
|
|
379
|
+
$("#credentialSecret").value = "";
|
|
380
|
+
$("#credentialPassphrase").value = "";
|
|
381
|
+
$("#credentialDialog").close();
|
|
382
|
+
toast(id ? "Credential updated" : "Credential added");
|
|
383
|
+
await refresh();
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
$("#serverRows").addEventListener("click", (event) => guarded(async () => {
|
|
388
|
+
const button = event.target.closest("button");
|
|
389
|
+
if (!button) return;
|
|
390
|
+
if (button.classList.contains("test-server")) {
|
|
391
|
+
button.disabled = true;
|
|
392
|
+
const result = await api(`/api/servers/${button.dataset.id}/test`, { method: "POST" });
|
|
393
|
+
toast(result.ok ? `Connected in ${result.latency_ms} ms` : result.message, !result.ok);
|
|
394
|
+
await refresh();
|
|
395
|
+
} else if (button.classList.contains("copy-command")) {
|
|
396
|
+
await navigator.clipboard.writeText(`serverctl connect ${button.dataset.alias}`);
|
|
397
|
+
toast("Connection command copied");
|
|
398
|
+
} else if (button.classList.contains("edit-server")) {
|
|
399
|
+
openServer(state.servers.find((item) => item.id === button.dataset.id));
|
|
400
|
+
} else if (button.classList.contains("delete-server")) {
|
|
401
|
+
const server = state.servers.find((item) => item.id === button.dataset.id);
|
|
402
|
+
if (confirm(`Delete server ${server.alias}?`)) {
|
|
403
|
+
await api(`/api/servers/${button.dataset.id}`, { method: "DELETE" });
|
|
404
|
+
toast("Server deleted");
|
|
405
|
+
await refresh();
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}));
|
|
409
|
+
|
|
410
|
+
$("#credentialRows").addEventListener("click", (event) => guarded(async () => {
|
|
411
|
+
const button = event.target.closest("button");
|
|
412
|
+
if (!button) return;
|
|
413
|
+
if (button.classList.contains("reveal-credential")) {
|
|
414
|
+
await authorizeReveal(button.dataset.id);
|
|
415
|
+
const result = await api(`/api/credentials/${button.dataset.id}/reveal`, { method: "POST" });
|
|
416
|
+
showSecret(result.value);
|
|
417
|
+
} else if (button.classList.contains("edit-credential")) {
|
|
418
|
+
openCredential(state.credentials.find((item) => item.id === button.dataset.id));
|
|
419
|
+
} else if (button.classList.contains("delete-credential")) {
|
|
420
|
+
const credential = state.credentials.find((item) => item.id === button.dataset.id);
|
|
421
|
+
if (confirm(`Delete credential ${credential.label}? Stored secrets will also be removed.`)) {
|
|
422
|
+
await api(`/api/credentials/${button.dataset.id}`, { method: "DELETE" });
|
|
423
|
+
toast("Credential deleted");
|
|
424
|
+
await refresh();
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}));
|
|
428
|
+
|
|
429
|
+
$("#importButton").addEventListener("click", () => guarded(async () => {
|
|
430
|
+
const preview = await api("/api/import/preview", { method: "POST", body: {} });
|
|
431
|
+
const adds = preview.items.filter((item) => item.action === "add").length;
|
|
432
|
+
const conflicts = preview.items.filter((item) => item.action === "conflict").length;
|
|
433
|
+
const message = `Import ${adds} new host(s)? ${conflicts} conflict(s) will be skipped.`;
|
|
434
|
+
if (confirm(message)) {
|
|
435
|
+
const result = await api("/api/import/apply", { method: "POST", body: { overwrite: false } });
|
|
436
|
+
toast(`Imported ${result.added.length} host(s); skipped ${result.skipped.length}`);
|
|
437
|
+
await refresh();
|
|
438
|
+
}
|
|
439
|
+
}));
|
|
440
|
+
|
|
441
|
+
$("#enrollPasskeyButton").addEventListener("click", () => guarded(enrollPasskey));
|
|
442
|
+
$("#enrollMasterButton").addEventListener("click", () => guarded(async () => {
|
|
443
|
+
const password = await requestMasterPassword(true);
|
|
444
|
+
await api("/api/auth/master/enroll", { method: "POST", body: { password } });
|
|
445
|
+
toast("Fallback master password enrolled");
|
|
446
|
+
await refresh();
|
|
447
|
+
}));
|
|
448
|
+
$("#copySecretButton").addEventListener("click", () => guarded(async () => {
|
|
449
|
+
await navigator.clipboard.writeText($("#revealedSecret").textContent);
|
|
450
|
+
toast("Secret copied to clipboard");
|
|
451
|
+
}));
|
|
452
|
+
});
|