Listing zones and services is what firewall-cmd already does. The question it does not answer needs both halves at once: a port is reachable only when something is LISTENING on a network address AND the firewall permits it. On this machine that crossing is the whole story. The rules look unremarkable -- one zone, three services, a port range -- and what they mean is that PostgreSQL and Redis, published by rootless containers on every interface, are reachable by anyone on the network. Neither half says that alone, which is exactly how a tidy rules list coexists with an open database. Nothing was misconfigured: Fedora's default zone met podman's default publish behaviour. Ephemeral client sockets are excluded. A browser's outbound UDP port is indistinguishable from a service in ss, and listing twenty of them buried the two rows that mattered. Closing the port range names what it would cut off, by service, before doing it, and removing ssh says so when someone is connected over it. Rich rules are shown and never edited: a syntax is not a setting, but hiding it would misrepresent the configuration. The contract needed a recorded firewall, and the reason is worth keeping. The rule this page exists for cannot be tested against this machine -- its zone permits everything above 1024, so "listening" and "listening and permitted" give identical answers, and a blocked listener needs a port below 1024, which needs root. With the crossing deleted, the contract passed. It now runs against a fixture where two listeners are blocked, and catches it. Also here: polkit response files are written 0600 rather than at the default mask, the agent sweeps requests left by an instance that did not exit cleanly, and the write sweep waits for its harness to be ready instead of reporting the startup race as settings that failed. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
197 lines
6.9 KiB
QML
197 lines
6.9 KiB
QML
pragma Singleton
|
|
|
|
// Panama's authentication prompt -- the half that handles the password.
|
|
//
|
|
// The split with scripts/panama-polkit-agent is the security design, not an
|
|
// accident of implementation:
|
|
//
|
|
// * The agent talks to polkitd and hands this a request FILE containing the
|
|
// action, the message, who may answer, and a one-time cookie. No password
|
|
// ever reaches it.
|
|
// * This draws the prompt, and on submit spawns the setuid
|
|
// polkit-agent-helper-1 and writes the password to that helper's stdin. The
|
|
// helper performs the PAM conversation and reports to polkitd itself.
|
|
//
|
|
// So the password lives in this process and the helper's stdin, and nowhere
|
|
// else. It is never an argument -- argv is world-readable through /proc -- and
|
|
// it is cleared the moment it has been handed over.
|
|
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
readonly property string helperBinary: "/usr/lib/polkit-1/polkit-agent-helper-1"
|
|
|
|
// The request currently on screen, or null.
|
|
property var request: null
|
|
property string requestPath: ""
|
|
property string message: ""
|
|
property string actionId: ""
|
|
property var users: []
|
|
property string chosenUser: ""
|
|
|
|
property bool authenticating: false
|
|
property string failureText: ""
|
|
// How many times a wrong password has been offered for this request.
|
|
property int attempts: 0
|
|
|
|
readonly property bool active: root.request !== null
|
|
|
|
// Held only between pressing Enter and the helper accepting it on stdin.
|
|
property string pendingSecret: ""
|
|
|
|
function begin(path: string): void {
|
|
// A second request while one is open would leave the first
|
|
// unanswerable; polkit serializes these in practice, and refusing is
|
|
// safer than stacking prompts.
|
|
if (root.active) {
|
|
console.warn("Polkit: a prompt is already open; ignoring", path);
|
|
return;
|
|
}
|
|
root.requestPath = path;
|
|
requestFile.path = path;
|
|
requestFile.reload();
|
|
}
|
|
|
|
function adopt(text: string): void {
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
root.request = parsed;
|
|
root.actionId = String(parsed.actionId ?? "");
|
|
root.message = String(parsed.message ?? "Authentication is required");
|
|
root.users = Array.isArray(parsed.users) ? parsed.users : [];
|
|
root.chosenUser = root.users.includes(String(parsed.preferred ?? ""))
|
|
? String(parsed.preferred)
|
|
: (root.users.length > 0 ? String(root.users[0]) : "");
|
|
root.attempts = 0;
|
|
root.failureText = "";
|
|
} catch (error) {
|
|
console.warn("Polkit: could not read the request:", error);
|
|
root.dismiss("failed");
|
|
}
|
|
}
|
|
|
|
function submit(password: string): void {
|
|
if (!root.active || root.authenticating || root.chosenUser === "")
|
|
return;
|
|
root.authenticating = true;
|
|
root.failureText = "";
|
|
root.pendingSecret = password;
|
|
helper.command = [root.helperBinary, root.chosenUser];
|
|
helper.running = true;
|
|
}
|
|
|
|
function cancel(): void {
|
|
root.dismiss("cancelled");
|
|
}
|
|
|
|
// Writes the outcome where the agent is watching for it, then forgets
|
|
// everything about the request.
|
|
function dismiss(result: string): void {
|
|
if (root.requestPath !== "") {
|
|
// umask first: everything in this directory concerns one
|
|
// authentication attempt, and a file written with the default mask
|
|
// would be world-readable in a directory whose whole point is that
|
|
// it is not.
|
|
answer.command = ["sh", "-c",
|
|
"umask 077; printf '%s' " + JSON.stringify(JSON.stringify({ result: result }))
|
|
+ " > " + JSON.stringify(root.responsePathFor(root.requestPath))];
|
|
answer.running = true;
|
|
}
|
|
root.request = null;
|
|
root.requestPath = "";
|
|
root.message = "";
|
|
root.actionId = "";
|
|
root.users = [];
|
|
root.chosenUser = "";
|
|
root.attempts = 0;
|
|
root.failureText = "";
|
|
root.pendingSecret = "";
|
|
root.authenticating = false;
|
|
}
|
|
|
|
function responsePathFor(path: string): string {
|
|
return String(path).replace(/\.json$/, "") + ".response";
|
|
}
|
|
|
|
FileView {
|
|
id: requestFile
|
|
onLoaded: root.adopt(this.text())
|
|
onLoadFailed: {
|
|
console.warn("Polkit: the request file could not be read");
|
|
root.dismiss("failed");
|
|
}
|
|
}
|
|
|
|
// Watches for polkit withdrawing the request while the prompt is open --
|
|
// the caller gave up, or another agent answered it.
|
|
Timer {
|
|
running: root.active
|
|
interval: 500
|
|
repeat: true
|
|
onTriggered: cancelledCheck.running = true
|
|
}
|
|
|
|
Process {
|
|
id: cancelledCheck
|
|
command: ["test", "-e", root.requestPath.replace(/\.json$/, "") + ".cancelled"]
|
|
onExited: (code) => { if (code === 0 && root.active) root.dismiss("cancelled"); }
|
|
}
|
|
|
|
Process {
|
|
id: helper
|
|
stdinEnabled: true
|
|
|
|
onStarted: {
|
|
// The cookie first, then the password when the helper asks for it.
|
|
// Both go to stdin; neither is ever an argument.
|
|
helper.write(String(root.request?.cookie ?? "") + "\n");
|
|
}
|
|
|
|
stdout: SplitParser {
|
|
splitMarker: "\n"
|
|
onRead: line => {
|
|
const text = String(line);
|
|
if (text.startsWith("PAM_PROMPT_ECHO_OFF")
|
|
|| text.startsWith("PAM_PROMPT_ECHO_ON")) {
|
|
helper.write(root.pendingSecret + "\n");
|
|
// Gone from this process the instant it has been handed
|
|
// over; the helper owns it from here.
|
|
root.pendingSecret = "";
|
|
return;
|
|
}
|
|
if (text.startsWith("PAM_ERROR_MSG")) {
|
|
const detail = text.slice("PAM_ERROR_MSG".length).trim();
|
|
if (detail !== "")
|
|
root.failureText = detail;
|
|
return;
|
|
}
|
|
if (text.startsWith("SUCCESS")) {
|
|
root.dismiss("ok");
|
|
return;
|
|
}
|
|
if (text.startsWith("FAILURE")) {
|
|
root.attempts += 1;
|
|
if (root.failureText === "")
|
|
root.failureText = "That password was not accepted.";
|
|
// Three tries, then the request is failed rather than left
|
|
// open forever -- polkit's own agents behave the same way.
|
|
if (root.attempts >= 3)
|
|
root.dismiss("failed");
|
|
}
|
|
}
|
|
}
|
|
|
|
onExited: {
|
|
root.authenticating = false;
|
|
root.pendingSecret = "";
|
|
helper.stdinEnabled = true;
|
|
}
|
|
}
|
|
|
|
Process { id: answer }
|
|
}
|