Add an SSH Keys page, and refuse the one control that would lie
The page shows which keys exist, what the agent is holding, and the hosts this machine has met, with a two-press forget for a host whose key has changed. Nothing here reads private key material. Fingerprints and comments come from the .pub file, and "does this key need a passphrase" is answered by asking ssh-keygen to derive the public half with an empty one -- it succeeds for an unencrypted key and fails for an encrypted one, and either way the only thing it can emit is public. The contract checks that against the payload that actually reaches the page rather than against the source, because what the code intends and what it ships are different claims. Unloading a key from the agent is refused, with its reason. On this desktop `ssh-add -d` prints "Identity removed" and the key is still offered a second later: gnome-keyring's agent lists every key it finds in ~/.ssh, so a removed one comes straight back off disk. That was measured rather than assumed -- a plain ssh-agent removes durably, this one does not -- and a button reporting success while changing nothing is worse than no button. The page says so and names the thing that does work: move the file out of ~/.ssh. SSH_AUTH_SOCK is not set in a normal shell here, so a naive check reports "no agent" while one is plainly running. The helper falls back to the keyring socket, and an agent started by hand still wins. That gap is the same one that made reaching these servers awkward in the first place. Generating a key is deliberately absent. A passphrase cannot reach ssh-keygen without going somewhere it should not -- -N puts it in argv, which every process on the machine can read -- and driving the prompt over a pty did not work. Offering to generate an unencrypted key instead would be a downgrade dressed as a feature, so the page does not offer to generate at all. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -129,6 +129,7 @@ Rectangle {
|
||||
case "firewall": return firewallPage;
|
||||
case "printers": return printersPage;
|
||||
case "containers": return containersPage;
|
||||
case "ssh-keys": return sshKeysPage;
|
||||
case "services": return healthPage;
|
||||
case "about": return aboutPage;
|
||||
default: return homePage;
|
||||
@@ -174,6 +175,7 @@ Rectangle {
|
||||
Component { id: sharingPage; SharingPage {} }
|
||||
Component { id: firewallPage; FirewallPage {} }
|
||||
Component { id: containersPage; ContainersPage {} }
|
||||
Component { id: sshKeysPage; SshKeysPage {} }
|
||||
Component { id: printersPage; PrintersPage {} }
|
||||
Component { id: accessibilityPage; AccessibilityPage {} }
|
||||
Component { id: powerPage; PowerPage {} }
|
||||
|
||||
@@ -30,6 +30,7 @@ Rectangle {
|
||||
{ page: "firewall", label: "Firewall", icon: "\u{F0483}" },
|
||||
{ page: "printers", label: "Printers", icon: "\u{F042A}" },
|
||||
{ page: "containers", label: "Containers", icon: "\u{F0868}" },
|
||||
{ page: "ssh-keys", label: "SSH Keys", icon: "\u{F0306}" },
|
||||
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" },
|
||||
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
|
||||
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// SSH keys, and what this machine can reach with them.
|
||||
//
|
||||
// Read-heavy on purpose. The genuinely useful things a person wants from a page
|
||||
// like this are "which key is this", "is the agent holding it", "copy the public
|
||||
// half", and "forget a host whose key changed" -- and all four are safe. What is
|
||||
// not here is generating a key, because a passphrase cannot be collected and
|
||||
// handed to ssh-keygen without putting it somewhere it should not be, and a
|
||||
// page offering to make an unencrypted key instead would be a downgrade
|
||||
// disguised as a feature.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "ssh-keys"
|
||||
title: "SSH Keys"
|
||||
lede: "The keys this machine signs in with, and the hosts it has met."
|
||||
|
||||
property string confirmingForget: ""
|
||||
|
||||
Component.onCompleted: if (!SshKeys.scanned) SshKeys.refresh()
|
||||
|
||||
TextRow {
|
||||
visible: SshKeys.lastError !== ""
|
||||
label: "That did not work"
|
||||
detail: SshKeys.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: SshKeys.scanned && !SshKeys.available
|
||||
label: "No SSH directory"
|
||||
detail: "Nothing has created ~/.ssh on this machine yet."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// ── Keys readable by other people ───────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: SshKeys.overexposed.length > 0
|
||||
title: SshKeys.overexposed.length === 1
|
||||
? "A private key is readable by other accounts"
|
||||
: "Private keys are readable by other accounts"
|
||||
subtitle: "ssh refuses to use a key with these permissions, so it will never be offered."
|
||||
|
||||
Repeater {
|
||||
model: SshKeys.overexposed
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: "Mode " + String(modelData.mode ?? "") + " · should be 600"
|
||||
value: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── The agent ───────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Agent"
|
||||
subtitle: SshKeys.agent?.available === true
|
||||
? (SshKeys.agent?.kind === "gnome-keyring"
|
||||
? "The login keyring is holding your keys, and offers every key it finds in ~/.ssh."
|
||||
: "An SSH agent is holding your keys for this session.")
|
||||
: String(SshKeys.agent?.detail ?? "No SSH agent is running.")
|
||||
|
||||
TextRow {
|
||||
label: "Holding"
|
||||
detail: SshKeys.agent?.available === true
|
||||
? String(SshKeys.agent?.socket ?? "")
|
||||
: "Keys will be asked for on every connection"
|
||||
value: SshKeys.loadedCount + " key" + (SshKeys.loadedCount === 1 ? "" : "s")
|
||||
divider: SshKeys.agent?.kind === "gnome-keyring"
|
||||
}
|
||||
|
||||
// Said plainly because it is measurable and surprising: ssh-add -d
|
||||
// reports success against this agent and the key is still offered a
|
||||
// moment later, because it is read back off disk.
|
||||
TextRow {
|
||||
visible: SshKeys.agent?.kind === "gnome-keyring"
|
||||
label: "Removing a key from this agent does not stick"
|
||||
detail: "It lists every key in ~/.ssh, so one removed comes straight back. Move the file out of ~/.ssh to stop it being offered."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Keys ────────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: SshKeys.keys.length === 1 ? "Your key" : "Your keys"
|
||||
subtitle: SshKeys.keys.length === 0
|
||||
? "No keys in " + SshKeys.directory
|
||||
: "Public halves are safe to share; the private half never leaves this machine."
|
||||
|
||||
Repeater {
|
||||
model: SshKeys.keys
|
||||
|
||||
delegate: SettingRow {
|
||||
id: keyRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: String(keyRow.modelData.name ?? "")
|
||||
detail: String(keyRow.modelData.type ?? "") + " · "
|
||||
+ String(keyRow.modelData.fingerprint ?? "")
|
||||
+ (String(keyRow.modelData.comment ?? "") !== ""
|
||||
? " · " + keyRow.modelData.comment : "")
|
||||
+ (keyRow.modelData.encrypted === true
|
||||
? " · passphrase protected"
|
||||
: (keyRow.modelData.encrypted === false ? " · no passphrase" : ""))
|
||||
divider: keyRow.index < SshKeys.keys.length - 1
|
||||
controlWidth: 220
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: keyRow.modelData.loaded === true
|
||||
text: "In the agent"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: keyRow.modelData.loaded !== true
|
||||
&& SshKeys.agent?.available === true
|
||||
text: "Add to agent"
|
||||
enabled: !SshKeys.busy
|
||||
onClicked: SshKeys.addToAgent(String(keyRow.modelData.path))
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Copy public key"
|
||||
onClicked: SshKeys.copyPublicKey(String(keyRow.modelData.publicPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Known hosts ─────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: SshKeys.hosts.length > 0
|
||||
title: "Known hosts"
|
||||
subtitle: "Machines this one has connected to before. Forgetting one means being asked to trust it again."
|
||||
|
||||
Repeater {
|
||||
model: SshKeys.hosts
|
||||
|
||||
delegate: SettingRow {
|
||||
id: hostRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool confirming:
|
||||
root.confirmingForget === String(hostRow.modelData.host ?? "")
|
||||
|
||||
label: hostRow.modelData.hashed === true
|
||||
? hostRow.modelData.count + " hashed entries"
|
||||
: String(hostRow.modelData.host ?? "")
|
||||
detail: hostRow.modelData.hashed === true
|
||||
? "Hashed on purpose, so the names cannot be read from the file"
|
||||
: (hostRow.confirming
|
||||
? "You will be asked to trust this host the next time you connect."
|
||||
: (hostRow.modelData.types ?? []).join(", "))
|
||||
divider: hostRow.index < SshKeys.hosts.length - 1
|
||||
controlWidth: 190
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: hostRow.confirming
|
||||
text: "Forget it"
|
||||
tone: "danger"
|
||||
enabled: !SshKeys.busy
|
||||
onClicked: {
|
||||
root.confirmingForget = "";
|
||||
SshKeys.forgetHost(String(hostRow.modelData.host));
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: hostRow.modelData.hashed !== true
|
||||
text: hostRow.confirming ? "Keep" : "Forget…"
|
||||
enabled: !SshKeys.busy
|
||||
onClicked: root.confirmingForget = hostRow.confirming
|
||||
? "" : String(hostRow.modelData.host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ AvatarPicker 1.0 AvatarPicker.qml
|
||||
ConnectivityPage 1.0 ConnectivityPage.qml
|
||||
FirewallPage 1.0 FirewallPage.qml
|
||||
ContainersPage 1.0 ContainersPage.qml
|
||||
SshKeysPage 1.0 SshKeysPage.qml
|
||||
AvatarCropper 1.0 AvatarCropper.qml
|
||||
PickerRow 1.0 PickerRow.qml
|
||||
SettingsTabs 1.0 SettingsTabs.qml
|
||||
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""SSH keys, the agent holding them, and the hosts this machine has met.
|
||||
|
||||
Nothing here ever reads a private key. Fingerprints and comments come from the
|
||||
matching .pub file, and whether a key is encrypted is answered by asking
|
||||
ssh-keygen to derive the PUBLIC key with an empty passphrase: it succeeds for an
|
||||
unencrypted key and fails for an encrypted one, and either way the only thing it
|
||||
can print is public material.
|
||||
|
||||
No passphrase passes through this tool at all. Adding an encrypted key to the
|
||||
agent lets ssh-add prompt through the system's own askpass, which is where that
|
||||
belongs -- a settings page collecting a passphrase and handing it on would be a
|
||||
worse place for it to live, and putting one in argv would publish it to every
|
||||
process on the machine.
|
||||
|
||||
panama-ssh-keys snapshot
|
||||
panama-ssh-keys agent-add PATH | agent-remove PATH
|
||||
panama-ssh-keys forget-host HOST
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SSH_DIR = Path.home() / ".ssh"
|
||||
KNOWN_HOSTS = SSH_DIR / "known_hosts"
|
||||
|
||||
# A host as it may appear in known_hosts, including [host]:port forms.
|
||||
HOST = re.compile(r"^[A-Za-z0-9._:\[\]-]{1,253}$")
|
||||
|
||||
# gnome-keyring's agent, which is what runs on this desktop. Only used when the
|
||||
# environment has not already named one, so an ssh-agent started by hand wins.
|
||||
KEYRING_SOCKET = Path(os.environ.get("XDG_RUNTIME_DIR", "/run/user/1000")) / "keyring" / "ssh"
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible validation or ssh failure."""
|
||||
|
||||
|
||||
def run(command: list[str], timeout: float = 15.0, env: dict | None = None):
|
||||
try:
|
||||
return subprocess.run(command, capture_output=True, text=True,
|
||||
timeout=timeout, env=env)
|
||||
except FileNotFoundError as error:
|
||||
raise BoundaryError(f"{command[0]} is not installed.") from error
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise BoundaryError(f"{command[0]} did not respond.") from error
|
||||
|
||||
|
||||
def agent_environment() -> dict:
|
||||
"""The environment an ssh-add call should run in.
|
||||
|
||||
A settings window inherits whatever the shell was started with, which on
|
||||
this desktop does not include SSH_AUTH_SOCK -- so without this the page
|
||||
would report "no agent" while one is plainly running.
|
||||
"""
|
||||
environment = dict(os.environ)
|
||||
if not environment.get("SSH_AUTH_SOCK") and KEYRING_SOCKET.is_socket():
|
||||
environment["SSH_AUTH_SOCK"] = str(KEYRING_SOCKET)
|
||||
return environment
|
||||
|
||||
|
||||
def agent_state() -> dict:
|
||||
environment = agent_environment()
|
||||
socket = environment.get("SSH_AUTH_SOCK", "")
|
||||
if not socket:
|
||||
return {"available": False, "socket": "", "kind": "", "durableRemoval": False,
|
||||
"fingerprints": [], "detail": "No SSH agent is running."}
|
||||
|
||||
result = run(["ssh-add", "-l"], env=environment)
|
||||
# ssh-add exits 1 for "no identities" and 2 for "cannot connect", which are
|
||||
# very different things to report.
|
||||
if result.returncode == 2:
|
||||
return {"available": False, "socket": socket, "kind": "", "durableRemoval": False,
|
||||
"fingerprints": [], "detail": "An agent socket exists but could not be reached."}
|
||||
|
||||
fingerprints = []
|
||||
for line in (result.stdout or "").splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[1].startswith("SHA256:"):
|
||||
fingerprints.append(parts[1])
|
||||
# gnome-keyring's agent enumerates whatever keys it finds in ~/.ssh, so
|
||||
# `ssh-add -d` reports "Identity removed" and the key is still listed a
|
||||
# second later -- it comes straight back from disk. A plain ssh-agent
|
||||
# removes durably. Measured on this machine rather than assumed, because an
|
||||
# Unload button that reports success and changes nothing is worse than no
|
||||
# button at all.
|
||||
keyring = "/keyring/" in socket
|
||||
return {
|
||||
"available": True,
|
||||
"socket": socket,
|
||||
"kind": "gnome-keyring" if keyring else "ssh-agent",
|
||||
"durableRemoval": not keyring,
|
||||
"fingerprints": fingerprints,
|
||||
"detail": "" if fingerprints else "The agent is running but holds no keys.",
|
||||
}
|
||||
|
||||
|
||||
def encrypted(private: Path) -> bool | None:
|
||||
"""Whether a private key needs a passphrase.
|
||||
|
||||
Asked by deriving the public key with an empty passphrase. That reads the
|
||||
file, but the only thing it can ever emit is the public half, and the answer
|
||||
is not obtainable any other way without parsing key material directly.
|
||||
"""
|
||||
result = run(["ssh-keygen", "-y", "-P", "", "-f", str(private)], timeout=10.0)
|
||||
if result.returncode == 0:
|
||||
return False
|
||||
detail = (result.stderr or "").lower()
|
||||
if "incorrect passphrase" in detail or "load failed" in detail:
|
||||
return True
|
||||
return None
|
||||
|
||||
|
||||
def keys(agent: dict) -> list[dict]:
|
||||
if not SSH_DIR.is_dir():
|
||||
return []
|
||||
|
||||
held = set(agent.get("fingerprints") or [])
|
||||
found = []
|
||||
for public in sorted(SSH_DIR.glob("*.pub")):
|
||||
private = public.with_suffix("")
|
||||
described = run(["ssh-keygen", "-l", "-f", str(public)], timeout=10.0)
|
||||
if described.returncode != 0:
|
||||
continue
|
||||
parts = (described.stdout or "").split()
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
bits, fingerprint = parts[0], parts[1]
|
||||
kind = parts[-1].strip("()")
|
||||
comment = " ".join(parts[2:-1]).strip()
|
||||
|
||||
found.append({
|
||||
"name": private.name,
|
||||
"path": str(private),
|
||||
"publicPath": str(public),
|
||||
"type": kind,
|
||||
"bits": int(bits) if bits.isdigit() else 0,
|
||||
"fingerprint": fingerprint,
|
||||
"comment": comment if comment != "no" else "",
|
||||
"hasPrivate": private.is_file(),
|
||||
"encrypted": encrypted(private) if private.is_file() else None,
|
||||
"loaded": fingerprint in held,
|
||||
# Read so the page can say when a key is readable by other people;
|
||||
# a private key must be 0600.
|
||||
"mode": oct(private.stat().st_mode & 0o777)[2:] if private.is_file() else "",
|
||||
})
|
||||
return found
|
||||
|
||||
|
||||
def hosts() -> list[dict]:
|
||||
"""Hosts in known_hosts, grouped by name.
|
||||
|
||||
A hashed known_hosts cannot be listed -- that is the entire point of hashing
|
||||
it -- so that is reported rather than shown as an empty list.
|
||||
"""
|
||||
if not KNOWN_HOSTS.is_file():
|
||||
return []
|
||||
|
||||
grouped: dict[str, dict] = {}
|
||||
try:
|
||||
lines = KNOWN_HOSTS.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError as error:
|
||||
raise BoundaryError("known_hosts could not be read.") from error
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
names, kind = parts[0], parts[1]
|
||||
if names.startswith("|1|"):
|
||||
entry = grouped.setdefault("", {"host": "", "hashed": True, "types": [], "count": 0})
|
||||
entry["count"] += 1
|
||||
if kind not in entry["types"]:
|
||||
entry["types"].append(kind)
|
||||
continue
|
||||
for name in names.split(","):
|
||||
entry = grouped.setdefault(name, {"host": name, "hashed": False, "types": [], "count": 0})
|
||||
entry["count"] += 1
|
||||
if kind not in entry["types"]:
|
||||
entry["types"].append(kind)
|
||||
|
||||
ordered = [entry for key, entry in sorted(grouped.items()) if key != ""]
|
||||
if "" in grouped:
|
||||
ordered.append(grouped[""])
|
||||
return ordered
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
agent = agent_state()
|
||||
return {
|
||||
"available": SSH_DIR.is_dir(),
|
||||
"directory": str(SSH_DIR),
|
||||
"agent": agent,
|
||||
"keys": keys(agent),
|
||||
"hosts": hosts(),
|
||||
"error": "",
|
||||
}
|
||||
|
||||
|
||||
def resolve_key(path: str) -> Path:
|
||||
"""A key path, confined to ~/.ssh.
|
||||
|
||||
Resolved and compared against the directory so that a name cannot walk out
|
||||
of it, and refused if it is not a file this tool put there.
|
||||
"""
|
||||
candidate = Path(path)
|
||||
try:
|
||||
resolved = candidate.resolve(strict=True)
|
||||
except OSError as error:
|
||||
raise BoundaryError("That key no longer exists.") from error
|
||||
if resolved.parent != SSH_DIR.resolve(strict=False):
|
||||
raise BoundaryError("That key is not in the SSH directory.")
|
||||
if not resolved.is_file():
|
||||
raise BoundaryError("That is not a key file.")
|
||||
return resolved
|
||||
|
||||
|
||||
def agent_add(path: str) -> None:
|
||||
key = resolve_key(path)
|
||||
environment = agent_environment()
|
||||
if not environment.get("SSH_AUTH_SOCK"):
|
||||
raise BoundaryError("No SSH agent is running.")
|
||||
# No passphrase is supplied here on purpose. An encrypted key makes ssh-add
|
||||
# prompt through the system's askpass, which is the right place for it.
|
||||
result = run(["ssh-add", str(key)], timeout=120.0, env=environment)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or "").strip().splitlines()
|
||||
raise BoundaryError(detail[-1] if detail else "That key could not be added.")
|
||||
|
||||
|
||||
def agent_remove(path: str) -> None:
|
||||
key = resolve_key(path)
|
||||
environment = agent_environment()
|
||||
if not environment.get("SSH_AUTH_SOCK"):
|
||||
raise BoundaryError("No SSH agent is running.")
|
||||
if not agent_state().get("durableRemoval", True):
|
||||
raise BoundaryError(
|
||||
"This desktop's agent lists every key in ~/.ssh, so removing one "
|
||||
"does not stick. Move the key out of ~/.ssh to stop it being offered.")
|
||||
result = run(["ssh-add", "-d", str(key)], env=environment)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or "").strip().splitlines()
|
||||
raise BoundaryError(detail[-1] if detail else "That key could not be removed.")
|
||||
|
||||
|
||||
def forget_host(host: str) -> None:
|
||||
"""Drop a host's keys from known_hosts.
|
||||
|
||||
The reason anyone reaches for this is a host key that changed, which is
|
||||
either a rebuilt machine or something worth being alarmed about -- so the
|
||||
page says which before offering the button. ssh-keygen -R rewrites the file
|
||||
and keeps a .old copy itself.
|
||||
"""
|
||||
if not HOST.match(host or ""):
|
||||
raise BoundaryError("That is not a host name.")
|
||||
if not KNOWN_HOSTS.is_file():
|
||||
raise BoundaryError("There is no known_hosts file.")
|
||||
result = run(["ssh-keygen", "-R", host, "-f", str(KNOWN_HOSTS)], timeout=20.0)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or "").strip().splitlines()
|
||||
raise BoundaryError(detail[-1] if detail else "That host could not be removed.")
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
try:
|
||||
if arguments == ["snapshot"]:
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
if len(arguments) == 2 and arguments[0] == "agent-add":
|
||||
agent_add(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "agent-remove":
|
||||
agent_remove(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "forget-host":
|
||||
forget_host(arguments[1])
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-ssh-keys snapshot | agent-add PATH | agent-remove PATH | "
|
||||
"forget-host HOST")
|
||||
except BoundaryError as error:
|
||||
try:
|
||||
state = snapshot()
|
||||
except BoundaryError:
|
||||
state = {"available": False, "directory": str(SSH_DIR),
|
||||
"agent": {"available": False, "socket": "", "fingerprints": [], "detail": ""},
|
||||
"keys": [], "hosts": []}
|
||||
state["error"] = str(error)
|
||||
print(json.dumps(state, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -80,6 +80,10 @@ Singleton {
|
||||
{ label: "Firewall zones", detail: "Which rules apply to each network connection", page: "firewall" },
|
||||
{ label: "Exposed services", detail: "What is listening and reachable from the network", page: "firewall" },
|
||||
{ label: "Containers", detail: "Rootless podman stacks you run for development", page: "containers" },
|
||||
{ label: "SSH keys", detail: "The keys this machine signs in with", page: "ssh-keys" },
|
||||
{ label: "SSH agent", detail: "Which keys are held for this session", page: "ssh-keys" },
|
||||
{ label: "Known hosts", detail: "Machines this one has connected to before", page: "ssh-keys" },
|
||||
{ label: "Public key", detail: "Copy the half you paste into a server", page: "ssh-keys" },
|
||||
{ label: "Podman", detail: "Running containers, images and volumes", page: "containers" },
|
||||
{ label: "Container logs", detail: "Follow what a container is printing", page: "containers" },
|
||||
{ label: "Reclaim container space", detail: "Remove images and volumes nothing uses", page: "containers" },
|
||||
|
||||
@@ -116,7 +116,7 @@ Singleton {
|
||||
}
|
||||
|
||||
function showSettings(page: string): void {
|
||||
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "gaming", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "updates", "storage", "snapshots", "users", "sharing", "firewall", "printers", "containers", "services", "about"];
|
||||
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "gaming", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "updates", "storage", "snapshots", "users", "sharing", "firewall", "printers", "containers", "ssh-keys", "services", "about"];
|
||||
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
||||
DesktopPreferences.set("lastPage", root.settingsPage);
|
||||
root.settingsOpen = true;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
pragma Singleton
|
||||
|
||||
// SSH keys, the agent holding them, and the hosts this machine has met.
|
||||
//
|
||||
// Nothing here ever sees a private key or a passphrase. Adding an encrypted key
|
||||
// makes ssh-add prompt through the system's own askpass, which is where a
|
||||
// passphrase belongs -- a settings page collecting one and passing it along
|
||||
// would be a worse place for it to live.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-ssh-keys"
|
||||
|
||||
property bool available: false
|
||||
property string directory: ""
|
||||
property var agent: ({})
|
||||
property var keys: []
|
||||
property var hosts: []
|
||||
property bool scanned: false
|
||||
property string lastError: ""
|
||||
|
||||
readonly property bool busy: query.running || mutation.running
|
||||
|
||||
readonly property int loadedCount: root.keys.filter(key => key.loaded === true).length
|
||||
|
||||
// Keys readable by anyone but their owner. ssh refuses to use these, so a
|
||||
// page that stayed quiet about it would leave someone wondering why a key
|
||||
// that plainly exists is never offered.
|
||||
readonly property var overexposed: root.keys.filter(key =>
|
||||
key.mode !== "" && key.mode !== "600" && key.mode !== "400")
|
||||
|
||||
function refresh(): void {
|
||||
if (query.running)
|
||||
return;
|
||||
query.command = [root.helperPath, "snapshot"];
|
||||
query.running = true;
|
||||
}
|
||||
|
||||
function absorb(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
root.available = parsed.available === true;
|
||||
root.directory = String(parsed.directory ?? "");
|
||||
root.agent = parsed.agent ?? ({});
|
||||
root.keys = Array.isArray(parsed.keys) ? parsed.keys : [];
|
||||
root.hosts = Array.isArray(parsed.hosts) ? parsed.hosts : [];
|
||||
root.lastError = String(parsed.error ?? "");
|
||||
} catch (error) {
|
||||
root.lastError = "Could not read the SSH configuration.";
|
||||
console.warn("SshKeys: could not parse helper output:", error);
|
||||
}
|
||||
root.scanned = true;
|
||||
}
|
||||
|
||||
function run(arguments: var): void {
|
||||
if (mutation.running)
|
||||
return;
|
||||
root.lastError = "";
|
||||
mutation.command = [root.helperPath].concat(arguments);
|
||||
mutation.running = true;
|
||||
}
|
||||
|
||||
// Adding an encrypted key prompts, and the prompt is the system's, so this
|
||||
// is allowed a long time before it is considered stuck.
|
||||
function addToAgent(path: string): void { root.run(["agent-add", path]); }
|
||||
|
||||
// The public half, onto the clipboard. Safe to copy by definition -- it is
|
||||
// the thing you paste into a server. The path arrives as $1 rather than
|
||||
// being spliced into shell source, so a name with a space or a quote in it
|
||||
// cannot become part of the command.
|
||||
function copyPublicKey(publicPath: string): void {
|
||||
if (publicPath === "" || copier.running)
|
||||
return;
|
||||
copier.command = ["sh", "-c", 'exec wl-copy < "$1"', "qs-ssh-keys", publicPath];
|
||||
copier.running = true;
|
||||
}
|
||||
function forgetHost(host: string): void { root.run(["forget-host", host]); }
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
|
||||
Process { id: copier }
|
||||
|
||||
Process {
|
||||
id: query
|
||||
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: mutation
|
||||
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generated by scripts/panama-settings-commands -- do not edit by hand.
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Settings: SSH Keys
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Open SSH Keys in Settings.
|
||||
# @vicinae.keywords ["settings", "ssh keys", "ssh agent", "known hosts", "public key"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page ssh-keys
|
||||
Reference in New Issue
Block a user