From 2d786a02703cfa46da96646913e5752637885891 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Mon, 5 Oct 2026 13:31:25 +0200 Subject: [PATCH] first commit --- README.md | 295 ++++++ __pycache__/svc-proxycpython-311.pyc | Bin 0 -> 90928 bytes svc-proxy | 1451 ++++++++++++++++++++++++++ 3 files changed, 1746 insertions(+) create mode 100644 README.md create mode 100644 __pycache__/svc-proxycpython-311.pyc create mode 100755 svc-proxy diff --git a/README.md b/README.md new file mode 100644 index 0000000..65ee86f --- /dev/null +++ b/README.md @@ -0,0 +1,295 @@ +# svc-proxy – Proxy für systemd-Dienste + +`svc-proxy` legt fest, über welchen Proxy ein systemd-Dienst seine Verbindungen +aufbaut. Bedienen lässt es sich auf zwei Wegen: + +- **ncurses-Menü**: listet alle Dienste auf. Man wählt einen aus und trägt die Daten bequem ein. +- **Kommandozeile**: für Skripte, Ansible, Provisionierung usw. + +Es gibt einen **globalen Proxy**. Alle Felder, die man bei einem Dienst leer lässt, +übernehmen den globalen Wert. Explizit gesetzte Werte beim Dienst gelten vorrangig. +Benutzername und Kennwort sind optional; ohne sie wird der Proxy ohne Anmeldung genutzt. + +Abhängigkeiten: nur `python3` (≥ 3.6) mit den Standardmodulen. `curses` ist bei +Debian/Ubuntu/RHEL im Python-Paket enthalten. + +--- + +## Installation + +```bash +sudo install -m 0755 svc-proxy /usr/local/sbin/svc-proxy +``` + +Danach `sudo svc-proxy` (Menü) oder `svc-proxy --help` (Hilfe). + +--- + +## Funktionsweise + +Für jeden konfigurierten Dienst werden zwei Dateien erzeugt: + +| Datei | Inhalt | Rechte | +|-------|--------|--------| +| `/etc/systemd/system/.d/50-svc-proxy.conf` | Drop-in mit `EnvironmentFile=` | 0644 | +| `/etc/svc-proxy/env/.env` | die eigentlichen Proxy-Variablen | 0600 | + +Die Konfiguration selbst (global + alle Dienste) liegt in +`/etc/svc-proxy/config.json` (0600). Die Drop-ins und Env-Dateien werden daraus +generiert und sollten nicht von Hand bearbeitet werden. + +Gesetzt werden die Variablen + +``` +http_proxy https_proxy ftp_proxy all_proxy no_proxy +HTTP_PROXY HTTPS_PROXY FTP_PROXY ALL_PROXY NO_PROXY +``` + +> **Warum `EnvironmentFile=` und nicht `Environment=`?** +> Werte aus `Environment=` kann jeder Benutzer mit `systemctl show ` +> auslesen, also auch ein Proxy-Kennwort. Die Env-Datei ist nur für root lesbar. + +Sonderzeichen in Benutzername/Kennwort (z.B. `@`, `:`, `/`) werden automatisch +URL-kodiert. + +### Wichtige Einschränkung + +Der Proxy wirkt nur bei Programmen, die die Proxy-Umgebungsvariablen auswerten. +Das tun die meisten (curl, wget, apt, pip, Python `requests`/`urllib`, Go, Node +mit passenden Libs, Docker-Daemon, containerd, Git, ...). Programme, die die +Variablen ignorieren (z.B. viele Java-Anwendungen ohne `-Dhttp.proxyHost`), +müssen anders konfiguriert werden. Eine Netzwerk-Sperre ist das nicht: Ein +Programm *kann* den Proxy umgehen. + +SOCKS-Proxys (`socks5://`, `socks5h://`) werden ebenfalls in alle Variablen +geschrieben. Das funktioniert z.B. mit curl und Go. Manche Programme verstehen in +`http_proxy` aber nur `http://`. + +--- + +## Vererbung: Dienst ↔ global + +Felder: **Typ, Host, Port, Benutzer, Kennwort, No-Proxy** + +| Feld beim Dienst | Ergebnis | +|---|---| +| Typ / Host / Port / No-Proxy leer | globaler Wert | +| Typ / Host / Port / No-Proxy gesetzt | Wert des Dienstes | +| Benutzer leer **und** Host leer | globale Zugangsdaten | +| Benutzer leer, **eigener Host** | Proxy **ohne** Anmeldung | +| Benutzer gesetzt | Benutzer + Kennwort des Dienstes | + +Die Sonderregel für Benutzer/Kennwort verhindert, dass globale Zugangsdaten an +einen fremden Proxy geschickt werden. + +Ändert man den globalen Proxy, werden alle Dienste, die ihn ganz oder teilweise +erben, automatisch neu geschrieben. Wird der globale Proxy gelöscht, verlieren +Dienste ohne eigenen Host ihren Proxy. Ihre Konfiguration bleibt aber gespeichert +und wird wieder aktiv, sobald ein globaler Proxy gesetzt ist. + +--- + +## ncurses-Menü + +```bash +sudo svc-proxy +``` + +``` + svc-proxy 1.0.0 – Proxy für systemd-Dienste + Global: http://max:****@proxy.firma.local:3128 No-Proxy: localhost,127.0.0.1,::1 + 220 Dienste + Dienst Status Proxy + G cron.service active global → http://max:****@proxy.firma.local:3128 + E* docker.service active eigen → socks5h://10.1.1.1:1080 + nginx.service inactive + ... + ↑↓ Wählen Enter Bearbeiten g Global Entf Proxy entfernen / Filter c Nur konfigurierte ... +``` + +Markierungen: **G** = nutzt globalen Proxy, **E** = eigene Werte, +**\*** = in dieser Sitzung geändert (Neustart noch offen). + +### Tasten in der Liste + +| Taste | Funktion | +|---|---| +| ↑ ↓ / Bild↑ Bild↓ / Pos1 Ende | Dienst wählen | +| Enter | Proxy des Dienstes bearbeiten | +| g | globalen Proxy bearbeiten | +| Entf / d | Proxy vom Dienst entfernen | +| / | Filter (Name oder Beschreibung), Esc löscht den Filter | +| c | nur konfigurierte Dienste anzeigen (an/aus) | +| r | gewählten Dienst sofort neu starten | +| F5 | Dienstliste neu einlesen | +| F1 / ? | Hilfe | +| q / F10 | Beenden | + +### Tasten im Formular + +| Taste | Funktion | +|---|---| +| Tab / ↑ ↓ | Feld wechseln | +| ← → / Leertaste | Proxy-Typ wählen (beim Dienst gibt es zusätzlich „global“) | +| Enter | nächstes Feld bzw. Schaltfläche auslösen | +| F2 | Speichern | +| Strg+U | Feld leeren | +| Esc | Abbrechen | + +Leere Felder zeigen grau den globalen Wert an. Unter dem Formular steht live die +Proxy-URL, die sich ergibt. Ungültige Eingaben werden nicht gespeichert. + +Nach jedem Speichern läuft `systemctl daemon-reload`. Beim Beenden erscheint eine +Liste der geänderten Dienste: Diese kann man an- und abwählen und neu starten lassen. + +--- + +## Kommandozeile + +``` +svc-proxy [BEFEHL] [OPTIONEN] +``` + +| Befehl | Beschreibung | +|---|---| +| *(keiner)* / `tui` | Menü starten | +| `list [--all]` | konfigurierte Dienste (mit `--all`: alle Dienste) | +| `show [DIENST]` | effektive Werte eines Dienstes inkl. Herkunft; ohne DIENST: global | +| `global [FELDER]` | globalen Proxy setzen/ändern; ohne FELDER: anzeigen | +| `global --clear` | globalen Proxy löschen | +| `set DIENST... [FELDER] [--reset]` | Proxy für Dienst(e) setzen/ändern | +| `unset DIENST...` / `unset --all` | Proxy entfernen | +| `apply` | alle Drop-ins aus der Konfiguration neu erzeugen | +| `help`, `-h`, `--help` | Hilfe mit Beispielen | + +### Felder + +| Option | Bedeutung | +|---|---| +| `--proxy URL` | komplette URL, z.B. `http://user:pass@proxy:3128`; setzt Typ, Host, Port, Benutzer, Kennwort | +| `--type TYP` | `http`, `https`, `socks5`, `socks5h`, `socks4` | +| `--host HOST` | Hostname / IP | +| `--port PORT` | 1–65535 | +| `--user NAME` | Benutzername | +| `--password PW` | Kennwort (in `ps` sichtbar!) | +| `--password-stdin` | Kennwort aus der Standardeingabe lesen (empfohlen) | +| `--no-proxy LISTE` | Ausnahmen, kommagetrennt | + +- Bei `set` und `global` werden **nur die angegebenen Felder** geändert, der Rest bleibt. +- Leerer Wert leert ein Feld: `--user ""`. +- `set DIENST --reset` leert vorher alle Felder, der Dienst nutzt danach komplett den globalen Proxy. +- Ein neuer Dienst ohne Felder (`set DIENST`) nutzt den globalen Proxy. +- Dienstnamen ohne Endung bekommen automatisch `.service` angehängt. +- Beim ersten Setzen des globalen Proxys ist No-Proxy standardmäßig `localhost,127.0.0.1,::1`. + +### Neustart und Reload + +| Option | Wirkung | +|---|---| +| *(nichts)* | im Terminal Nachfrage `[j/N]`; ohne Terminal (Cron, Skript) kein Neustart | +| `--restart` | geänderte Dienste ohne Nachfrage neu starten | +| `--no-restart` | nicht neu starten, nicht fragen | +| `--no-reload` | kein `systemctl daemon-reload` | + +Neu gestartet wird mit `systemctl try-restart`, also nur Dienste, die gerade laufen. +Gestoppte Dienste werden nicht gestartet. + +### Weitere Optionen + +| Option | Bedeutung | +|---|---| +| `--config-dir DIR` | anderes Konfigurationsverzeichnis (Standard `/etc/svc-proxy`) | +| `--systemd-dir DIR` | anderes Ziel für Drop-ins (Standard `/etc/systemd/system`) | +| `-V`, `--version` | Version | + +Mit `--config-dir`/`--systemd-dir` läuft das Skript auch ohne root. Das ist +praktisch zum Testen, z.B. zusammen mit `--no-reload`. + +--- + +## Beispiele + +```bash +# Menü +sudo svc-proxy + +# Globaler Proxy ohne Anmeldung +sudo svc-proxy global --proxy http://proxy.firma.local:3128 + +# Globaler Proxy mit Anmeldung, Kennwort nicht in der Prozessliste +echo 'geheim' | sudo svc-proxy global --proxy http://proxy:3128 --user max --password-stdin +# oder aus einer Datei / einem Secret-Store +sudo svc-proxy global --user max --password-stdin < /root/proxy.pw + +# Ausnahmen für den globalen Proxy +sudo svc-proxy global --no-proxy localhost,127.0.0.1,::1,.firma.local,10.0.0.0/8 + +# Mehrere Dienste nutzen den globalen Proxy, sofort neu starten +sudo svc-proxy set docker containerd nginx --restart + +# Eigener SOCKS-Proxy ohne Anmeldung für einen Dienst +sudo svc-proxy set myapp --proxy socks5h://10.1.1.1:1080 + +# Nur anderer Host, Port/Typ/No-Proxy kommen vom globalen Proxy, +# keine Anmeldung (eigener Host ohne Benutzer) +sudo svc-proxy set backup --host proxy2.firma.local + +# Nur den Port ändern +sudo svc-proxy set myapp --port 1081 --no-restart + +# Zurück auf den globalen Proxy +sudo svc-proxy set myapp --reset + +# Entfernen +sudo svc-proxy unset myapp --restart +sudo svc-proxy unset --all --no-restart + +# Anzeigen +sudo svc-proxy list +sudo svc-proxy list --all +sudo svc-proxy show docker +sudo svc-proxy show # globaler Proxy +``` + +Beispielausgabe von `show`: + +``` +Dienst: ssh.service +Status: active +Modus: eigene Werte (leere Felder = global) + Proxy-Typ http (global) + Host other.proxy (dienst) + Port 3128 (global) + Benutzer - (dienst) + Kennwort - (dienst) + No-Proxy localhost,127.0.0.1,::1 (global) +Proxy-URL: http://other.proxy:3128 +``` + +### Automatisierung (z.B. Ansible) + +```yaml +- name: Proxy für Docker setzen + ansible.builtin.shell: > + svc-proxy global --proxy http://proxy.firma.local:3128 --no-restart && + svc-proxy set docker.service --restart +``` + +Rückgabewert: `0` bei Erfolg, `1` bei Fehler (ungültige Eingabe, fehlende Rechte, +fehlgeschlagener Neustart). + +--- + +## Prüfen, ob es wirkt + +```bash +systemctl cat docker.service # zeigt das Drop-in +sudo cat /proc/$(systemctl show -p MainPID --value docker)/environ | tr '\0' '\n' | grep -i proxy +``` + +## Deinstallation + +```bash +sudo svc-proxy unset --all --restart +sudo rm -rf /etc/svc-proxy /usr/local/sbin/svc-proxy +``` diff --git a/__pycache__/svc-proxycpython-311.pyc b/__pycache__/svc-proxycpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a9b30d8595d1b7be3c3ee55ee5e2f1247b28ace4 GIT binary patch literal 90928 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­6uF!$8KG#O);DNUX!k&ETu;z+ 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 literal 0 HcmV?d00001 diff --git a/svc-proxy b/svc-proxy new file mode 100755 index 0000000..9141a37 --- /dev/null +++ b/svc-proxy @@ -0,0 +1,1451 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +svc-proxy – Proxy-Einstellungen für systemd-Dienste verwalten. + +Bedienung per ncurses-Menü (ohne Parameter) oder per Kommandozeile. +Ausführliche Hilfe: svc-proxy --help +""" + +import argparse +import curses +import json +import locale +import os +import re +import subprocess +import sys +import tempfile +from urllib.parse import quote, unquote, urlsplit + +VERSION = "1.0.0" +PROG = "svc-proxy" + +DEFAULT_CONFIG_DIR = "/etc/svc-proxy" +DEFAULT_SYSTEMD_DIR = "/etc/systemd/system" +DROPIN_NAME = "50-svc-proxy.conf" + +PROXY_TYPES = ["http", "https", "socks5", "socks5h", "socks4"] +DEFAULT_NO_PROXY = "localhost,127.0.0.1,::1" +FIELDS = ["type", "host", "port", "user", "password", "no_proxy"] +FIELD_LABELS = { + "type": "Proxy-Typ", + "host": "Host", + "port": "Port", + "user": "Benutzer", + "password": "Kennwort", + "no_proxy": "No-Proxy", +} +PROXY_ENV_VARS = ["http_proxy", "https_proxy", "ftp_proxy", "all_proxy"] + +UNIT_RE = re.compile(r"^[A-Za-z0-9:_.@\\-]+$") +HOST_RE = re.compile(r"^[A-Za-z0-9._:-]+$") +NO_PROXY_RE = re.compile(r"^[A-Za-z0-9.,:*/_\[\]-]*$") + +HELP_TEXT = """\ +{prog} {version} – Proxy für systemd-Dienste setzen + +Hängt an beliebige systemd-Dienste ein Drop-in an, das die Umgebungsvariablen +http_proxy, https_proxy, ftp_proxy, all_proxy und no_proxy (jeweils auch in +GROSSBUCHSTABEN) setzt. Alle Programme, die diese Variablen beachten (curl, +wget, apt, Python, Go, Node, ...), laufen dann über den angegebenen Proxy. + +AUFRUF + {prog} ncurses-Menü starten (wie '{prog} tui') + {prog} BEFEHL [OPTIONEN] Kommandozeilen-Modus (für Automatisierung) + +BEFEHLE + tui Menü starten: alle Dienste auflisten, auswählen, bearbeiten + list [--all] Dienste mit Proxy-Konfiguration anzeigen + (--all: alle Dienste des Systems) + show [DIENST] Effektive Einstellungen eines Dienstes anzeigen + (ohne DIENST: den globalen Proxy) + global [FELDER] Globalen Proxy setzen/ändern (ohne FELDER: anzeigen) + global --clear Globalen Proxy löschen + set DIENST... [FELDER] Proxy für einen oder mehrere Dienste setzen/ändern + unset DIENST... Proxy von Diensten entfernen + unset --all Proxy von allen Diensten entfernen + apply Alle Drop-ins aus der Konfiguration neu schreiben + help Diese Hilfe anzeigen + +FELDER (für 'global' und 'set') + --proxy URL Komplette Proxy-URL, z.B. http://user:pass@proxy:3128 + Setzt Typ, Host, Port, Benutzer und Kennwort auf einmal. + Einzelne Felder dahinter überschreiben die URL-Werte. + --type TYP http | https | socks5 | socks5h | socks4 + --host HOST Hostname oder IP des Proxys + --port PORT Port des Proxys (1-65535) + --user NAME Benutzername (weglassen = Proxy ohne Anmeldung) + --password KENNWORT Kennwort (Achtung: in der Prozessliste sichtbar!) + --password-stdin Kennwort von der Standardeingabe lesen (empfohlen) + --no-proxy LISTE Kommagetrennte Ausnahmen, z.B. localhost,10.0.0.0/8,.intern + + Ein Feld mit leerem Wert (z.B. --user "") wird geleert. Bei 'set' werden nur + die angegebenen Felder geändert, die übrigen bleiben erhalten. + + --reset (nur 'set') Vorher alle Felder des Dienstes leeren. + 'set DIENST --reset' = Dienst nutzt komplett den globalen Proxy. + +VERERBUNG (Dienst <-> global) + Jedes leere Feld eines Dienstes übernimmt den globalen Wert: + Typ, Host, Port, No-Proxy leer -> globaler Wert + Benutzer/Kennwort leer -> globale Zugangsdaten, ABER nur wenn auch + der Host vom globalen Proxy kommt. Hat der Dienst + einen eigenen Host und keinen Benutzer, wird der + Proxy ohne Anmeldung verwendet. + Ändert man den globalen Proxy, werden alle Dienste, die ihn (teilweise) erben, + automatisch mit angepasst. + +NEUSTART / RELOAD + Nach jeder Änderung wird 'systemctl daemon-reload' ausgeführt. Die neuen + Werte gelten aber erst nach einem Neustart des Dienstes. + --restart Geänderte Dienste ohne Nachfrage neu starten + (nur laufende Dienste, per 'systemctl try-restart') + --no-restart Nicht neu starten und nicht nachfragen + (keins von beiden) Im Terminal wird nachgefragt; ohne Terminal kein Neustart. + Im Menü erscheint beim Beenden eine Auswahlliste. + --no-reload Kein 'systemctl daemon-reload' ausführen + +WEITERE OPTIONEN + --config-dir DIR Konfigurationsverzeichnis (Standard: {cfg}) + --systemd-dir DIR Ziel für Drop-ins (Standard: {sysd}) + -h, --help Diese Hilfe anzeigen + -V, --version Version anzeigen + +BEISPIELE + # Menü starten + sudo {prog} + + # Globalen Proxy ohne Anmeldung setzen + sudo {prog} global --proxy http://proxy.firma.local:3128 + + # Globalen Proxy mit Anmeldung, Kennwort sicher über stdin + echo 'geheim' | sudo {prog} global --proxy http://proxy:3128 --user max --password-stdin + + # Ausnahmen für den globalen Proxy festlegen + sudo {prog} global --no-proxy localhost,127.0.0.1,::1,.firma.local,10.0.0.0/8 + + # Dienste den globalen Proxy nutzen lassen und gleich neu starten + sudo {prog} set docker nginx.service --restart + + # Eigener SOCKS-Proxy ohne Anmeldung für einen Dienst + sudo {prog} set myapp --proxy socks5h://10.1.1.1:1080 + + # Nur den Port eines Dienstes ändern (Rest bleibt) + sudo {prog} set myapp --port 1081 --no-restart + + # Dienst wieder komplett auf den globalen Proxy zurücksetzen + sudo {prog} set myapp --reset + + # Proxy von einem Dienst entfernen + sudo {prog} unset myapp --restart + + # Übersicht und Details + sudo {prog} list + sudo {prog} show docker + +DATEIEN + {cfg}/config.json Konfiguration (global + Dienste, 0600) + {cfg}/env/DIENST.env erzeugte Umgebungsdatei (0600) + {sysd}/DIENST.d/{dropin} erzeugtes Drop-in + + Dienstnamen ohne Endung erhalten automatisch '.service'. +""".format(prog=PROG, version=VERSION, cfg=DEFAULT_CONFIG_DIR, + sysd=DEFAULT_SYSTEMD_DIR, dropin=DROPIN_NAME) + + +class ProxyError(Exception): + pass + + +# -------------------------------------------------------------------------- +# Hilfsfunktionen +# -------------------------------------------------------------------------- + +def empty_settings(): + return {k: "" for k in FIELDS} + + +def normalize_unit(name): + name = name.strip() + if not name or not UNIT_RE.match(name): + raise ProxyError("Ungültiger Dienstname: %r" % name) + if "." not in name: + name += ".service" + if name.endswith("@.service"): + raise ProxyError("%s ist eine Vorlage – bitte eine Instanz angeben (z.B. name@xyz.service)" % name) + return name + + +def atomic_write(path, content, mode): + directory = os.path.dirname(path) + os.makedirs(directory, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=directory, prefix=".tmp-svc-proxy-") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + os.chmod(tmp, mode) + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def write_if_different(path, content, mode): + try: + with open(path, encoding="utf-8") as f: + if f.read() == content: + return False + except OSError: + pass + atomic_write(path, content, mode) + return True + + +def remove_file(path): + try: + os.unlink(path) + return True + except FileNotFoundError: + return False + + +def validate_settings(raw, is_global): + """Prüft und bereinigt ein Feld-Dict. Wirft ProxyError.""" + s = empty_settings() + for k in FIELDS: + v = raw.get(k) or "" + if any(ord(c) < 32 for c in v): + raise ProxyError("%s enthält Steuerzeichen" % FIELD_LABELS[k]) + s[k] = v if k == "password" else v.strip() + + s["type"] = s["type"].lower() + if s["type"] and s["type"] not in PROXY_TYPES: + raise ProxyError("Unbekannter Proxy-Typ '%s' (erlaubt: %s)" % (s["type"], ", ".join(PROXY_TYPES))) + + host = s["host"] + if host.startswith("[") and host.endswith("]"): + host = host[1:-1] + if host and not HOST_RE.match(host): + raise ProxyError("Ungültiger Host: %s" % s["host"]) + s["host"] = host + + if s["port"]: + if not s["port"].isdigit() or not 1 <= int(s["port"]) <= 65535: + raise ProxyError("Ungültiger Port: %s (erlaubt: 1-65535)" % s["port"]) + s["port"] = str(int(s["port"])) + + if s["password"] and not s["user"]: + raise ProxyError("Kennwort angegeben, aber kein Benutzername") + + s["no_proxy"] = re.sub(r"\s+", "", s["no_proxy"]) + if not NO_PROXY_RE.match(s["no_proxy"]): + raise ProxyError("Ungültige No-Proxy-Liste: %s" % s["no_proxy"]) + + if is_global: + if any(s[k] for k in ("host", "port", "user", "password")): + if not s["host"]: + raise ProxyError("Globaler Proxy: Host fehlt") + if not s["port"]: + raise ProxyError("Globaler Proxy: Port fehlt") + if s["host"] and not s["type"]: + s["type"] = "http" + return s + + +def resolve(svc, glob): + """Effektive Werte eines Dienstes. Liefert (werte, quellen) oder wirft ProxyError. + quellen[feld] ist 'dienst', 'global' oder 'standard'.""" + eff, src = {}, {} + + def take(key, default=""): + if svc[key]: + eff[key], src[key] = svc[key], "dienst" + elif glob[key]: + eff[key], src[key] = glob[key], "global" + else: + eff[key], src[key] = default, "standard" + + take("type", "http") + take("host") + take("port") + take("no_proxy") + if svc["user"]: + eff["user"], eff["password"] = svc["user"], svc["password"] + src["user"] = src["password"] = "dienst" + elif svc["host"]: + eff["user"] = eff["password"] = "" + src["user"] = src["password"] = "dienst" + else: + eff["user"], eff["password"] = glob["user"], glob["password"] + src["user"] = src["password"] = "global" + + if not eff["host"]: + raise ProxyError("kein Proxy-Host (weder beim Dienst noch global gesetzt)") + if not eff["port"]: + raise ProxyError("kein Proxy-Port (weder beim Dienst noch global gesetzt)") + return eff, src + + +def proxy_url(eff, mask=False): + auth = "" + if eff.get("user"): + auth = quote(eff["user"], safe="") + if eff.get("password"): + auth += ":" + ("****" if mask else quote(eff["password"], safe="")) + auth += "@" + host = eff["host"] + if ":" in host: + host = "[%s]" % host + return "%s://%s%s:%s" % (eff["type"] or "http", auth, host, eff["port"]) + + +def parse_proxy_url(url): + url = url.strip() + if "://" not in url: + url = "http://" + url + try: + p = urlsplit(url) + port = p.port + except ValueError as e: + raise ProxyError("Ungültige Proxy-URL %r: %s" % (url, e)) + scheme = p.scheme.lower() + if scheme not in PROXY_TYPES: + raise ProxyError("Unbekannter Proxy-Typ '%s' in URL (erlaubt: %s)" % (scheme, ", ".join(PROXY_TYPES))) + if not p.hostname: + raise ProxyError("Proxy-URL ohne Host: %r" % url) + return { + "type": scheme, + "host": p.hostname, + "port": str(port) if port else "", + "user": unquote(p.username or ""), + "password": unquote(p.password or ""), + } + + +def is_inheriting(svc): + return not any(svc[k] for k in FIELDS) + + +# -------------------------------------------------------------------------- +# Konfiguration und Erzeugen der Dateien +# -------------------------------------------------------------------------- + +class Config: + def __init__(self, config_dir, systemd_dir): + self.config_dir = config_dir + self.config_file = os.path.join(config_dir, "config.json") + self.env_dir = os.path.join(config_dir, "env") + self.systemd_dir = systemd_dir + self.global_ = empty_settings() + self.services = {} + + # Pfade + def env_file(self, unit): + return os.path.join(self.env_dir, re.sub(r"[^A-Za-z0-9:_.@-]", "_", unit) + ".env") + + def dropin_dir(self, unit): + return os.path.join(self.systemd_dir, unit + ".d") + + def dropin_file(self, unit): + return os.path.join(self.dropin_dir(unit), DROPIN_NAME) + + # Laden / Speichern + def load(self): + try: + with open(self.config_file, encoding="utf-8") as f: + data = json.load(f) + except FileNotFoundError: + return + except PermissionError: + raise ProxyError("Keine Berechtigung für %s – bitte als root ausführen" % self.config_file) + except (OSError, ValueError) as e: + raise ProxyError("Konfiguration %s nicht lesbar: %s" % (self.config_file, e)) + + def clean(d): + s = empty_settings() + for k in FIELDS: + if isinstance(d, dict) and isinstance(d.get(k), str): + s[k] = d[k] + return s + + self.global_ = clean(data.get("global")) + self.services = {} + for unit, s in (data.get("services") or {}).items(): + if UNIT_RE.match(unit): + self.services[unit] = clean(s) + + def save(self): + os.makedirs(self.config_dir, mode=0o700, exist_ok=True) + data = { + "version": 1, + "global": self.global_, + "services": {u: self.services[u] for u in sorted(self.services)}, + } + atomic_write(self.config_file, json.dumps(data, indent=2, ensure_ascii=False) + "\n", 0o600) + + # Drop-ins + def render_env(self, unit, eff): + url = proxy_url(eff) + lines = [ + "# Erzeugt von %s fuer %s - Aenderungen werden ueberschrieben." % (PROG, unit), + "# Bearbeiten mit: %s set %s ..." % (PROG, unit), + ] + for var in PROXY_ENV_VARS: + lines.append('%s="%s"' % (var, url)) + lines.append('%s="%s"' % (var.upper(), url)) + if eff["no_proxy"]: + lines.append('no_proxy="%s"' % eff["no_proxy"]) + lines.append('NO_PROXY="%s"' % eff["no_proxy"]) + return "\n".join(lines) + "\n" + + def render_dropin(self, unit): + return ( + "# Erzeugt von %s - nicht manuell bearbeiten.\n" + "# Entfernen mit: %s unset %s\n" + "[Service]\n" + "EnvironmentFile=%s\n" % (PROG, PROG, unit, self.env_file(unit)) + ) + + def remove_unit_files(self, unit): + changed = remove_file(self.dropin_file(unit)) + changed |= remove_file(self.env_file(unit)) + try: + os.rmdir(self.dropin_dir(unit)) + except OSError: + pass + return changed + + def apply_unit(self, unit): + """Schreibt/entfernt die Dateien eines Dienstes. Liefert (geändert, warnung).""" + svc = self.services.get(unit) + if svc is None: + return self.remove_unit_files(unit), None + try: + eff, _ = resolve(svc, self.global_) + except ProxyError as e: + return self.remove_unit_files(unit), "%s: %s – Proxy für diesen Dienst deaktiviert" % (unit, e) + os.makedirs(self.env_dir, mode=0o700, exist_ok=True) + changed = write_if_different(self.env_file(unit), self.render_env(unit, eff), 0o600) + changed |= write_if_different(self.dropin_file(unit), self.render_dropin(unit), 0o644) + return changed, None + + def managed_dropins(self): + """Units, für die ein Drop-in von svc-proxy existiert.""" + found = set() + try: + entries = os.listdir(self.systemd_dir) + except OSError: + return found + for entry in entries: + if entry.endswith(".d") and os.path.isfile(os.path.join(self.systemd_dir, entry, DROPIN_NAME)): + found.add(entry[:-2]) + return found + + def apply_all(self): + changed, warnings = set(), [] + for unit in sorted(set(self.services) | self.managed_dropins()): + ch, warn = self.apply_unit(unit) + if ch: + changed.add(unit) + if warn: + warnings.append(warn) + return changed, warnings + + +# -------------------------------------------------------------------------- +# systemd +# -------------------------------------------------------------------------- + +def run_cmd(cmd): + try: + r = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + universal_newlines=True) + except FileNotFoundError: + return 127, "", "%s nicht gefunden" % cmd[0] + return r.returncode, r.stdout, r.stderr + + +def daemon_reload(): + rc, _, err = run_cmd(["systemctl", "daemon-reload"]) + if rc != 0: + raise ProxyError("systemctl daemon-reload fehlgeschlagen: %s" % err.strip()) + + +def unit_active(unit): + rc, out, _ = run_cmd(["systemctl", "is-active", unit]) + return out.strip() or "unbekannt" + + +def restart_units(units): + """Startet laufende Dienste neu. Liefert Liste von (unit, ok, text).""" + results = [] + for unit in sorted(units): + state = unit_active(unit) + if state not in ("active", "reloading", "activating"): + results.append((unit, True, "nicht aktiv (%s) – kein Neustart nötig" % state)) + continue + rc, _, err = run_cmd(["systemctl", "try-restart", unit]) + if rc == 0: + results.append((unit, True, "neu gestartet")) + else: + results.append((unit, False, "Neustart fehlgeschlagen: %s" % err.strip())) + return results + + +def list_system_services(): + """Alle Dienste: {name: {'active':..., 'sub':..., 'file':..., 'desc':...}}""" + units = {} + rc, out, _ = run_cmd(["systemctl", "list-unit-files", "--type=service", + "--no-legend", "--no-pager", "--plain"]) + if rc == 0: + for line in out.splitlines(): + parts = line.split() + if not parts or parts[0].endswith("@.service"): + continue + units[parts[0]] = {"active": "", "sub": "", "file": parts[1] if len(parts) > 1 else "", "desc": ""} + rc, out, _ = run_cmd(["systemctl", "list-units", "--type=service", "--all", + "--no-legend", "--no-pager", "--plain"]) + if rc == 0: + for line in out.splitlines(): + parts = line.lstrip("●* ").split(None, 4) + if len(parts) < 4: + continue + name, load, active, sub = parts[:4] + info = units.setdefault(name, {"active": "", "sub": "", "file": "", "desc": ""}) + info["active"], info["sub"] = active, sub + if load == "not-found": + info["file"] = "not-found" + info["desc"] = parts[4] if len(parts) > 4 else "" + return units + + +# -------------------------------------------------------------------------- +# ncurses-Oberfläche +# -------------------------------------------------------------------------- + +def put(win, y, x, text, attr=0, width=None): + h, w = win.getmaxyx() + if y < 0 or y >= h or x < 0 or x >= w: + return + maxw = w - x if width is None else min(width, w - x) + if maxw <= 0: + return + try: + win.addnstr(y, x, text, maxw, attr) + except curses.error: + pass + + +def is_enter(k): + return k in ("\n", "\r", curses.KEY_ENTER) + + +def is_backspace(k): + return k in ("\x7f", "\x08", curses.KEY_BACKSPACE) + + +class FormField: + def __init__(self, key, label, value, kind="text", choices=None, secret=False, placeholder=""): + self.key = key + self.label = label + self.kind = kind # text | choice | button + self.value = value + self.choices = choices or [] + self.secret = secret + self.placeholder = placeholder + self.pos = len(value) + self.off = 0 + + +class TUI: + def __init__(self, scr, cfg, args): + self.scr = scr + self.cfg = cfg + self.args = args + self.changed = set() + self.filter = "" + self.filter_mode = False + self.only_configured = False + self.sel = 0 + self.top = 0 + self.msg = "" + self.msg_attr = 0 + self.units = {} + self.visible = [] + self.init_colors() + self.refresh_units() + + # ---------- Grundlagen ---------- + def init_colors(self): + self.c_title = curses.A_REVERSE | curses.A_BOLD + self.c_sel = curses.A_REVERSE + self.c_dim = curses.A_DIM + self.c_err = curses.A_BOLD + self.c_ok = curses.A_BOLD + self.c_global = curses.A_NORMAL + self.c_own = curses.A_BOLD + if curses.has_colors(): + try: + curses.start_color() + curses.use_default_colors() + curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE) + curses.init_pair(2, curses.COLOR_RED, -1) + curses.init_pair(3, curses.COLOR_GREEN, -1) + curses.init_pair(4, curses.COLOR_CYAN, -1) + curses.init_pair(5, curses.COLOR_YELLOW, -1) + curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_CYAN) + self.c_title = curses.color_pair(1) | curses.A_BOLD + self.c_err = curses.color_pair(2) | curses.A_BOLD + self.c_ok = curses.color_pair(3) | curses.A_BOLD + self.c_global = curses.color_pair(4) + self.c_own = curses.color_pair(5) | curses.A_BOLD + self.c_sel = curses.color_pair(6) + except curses.error: + pass + + def cursor(self, visible): + try: + curses.curs_set(1 if visible else 0) + except curses.error: + pass + + def set_msg(self, text, attr=None): + self.msg = text + self.msg_attr = self.c_ok if attr is None else attr + + def title_bar(self, text): + h, w = self.scr.getmaxyx() + put(self.scr, 0, 0, (" " + text).ljust(w), self.c_title) + + def key_bar(self, text): + h, w = self.scr.getmaxyx() + put(self.scr, h - 1, 0, (" " + text).ljust(w - 1), self.c_title) + + def refresh_units(self): + self.units = list_system_services() + for unit in self.cfg.services: + self.units.setdefault(unit, {"active": "", "sub": "", "file": "?", "desc": ""}) + self.apply_filter() + + def apply_filter(self): + f = self.filter.lower() + names = sorted(self.units) + if self.only_configured: + names = [n for n in names if n in self.cfg.services] + if f: + names = [n for n in names if f in n.lower() or f in self.units[n]["desc"].lower()] + self.visible = names + self.sel = max(0, min(self.sel, len(names) - 1)) + + def global_summary(self): + g = self.cfg.global_ + if not g["host"]: + return "(nicht gesetzt)" + text = proxy_url(g, mask=True) + if g["no_proxy"]: + text += " No-Proxy: " + g["no_proxy"] + return text + + def service_summary(self, unit): + svc = self.cfg.services.get(unit) + if svc is None: + return "", 0 + try: + eff, _ = resolve(svc, self.cfg.global_) + except ProxyError as e: + return "FEHLER: " + str(e), self.c_err + if is_inheriting(svc): + return "global → " + proxy_url(eff, mask=True), self.c_global + return "eigen → " + proxy_url(eff, mask=True), self.c_own + + # ---------- Hauptliste ---------- + def draw_main(self): + scr = self.scr + scr.erase() + h, w = scr.getmaxyx() + self.title_bar("%s %s – Proxy für systemd-Dienste" % (PROG, VERSION)) + put(scr, 1, 1, "Global: ", curses.A_BOLD) + put(scr, 1, 9, self.global_summary(), self.c_global if self.cfg.global_["host"] else self.c_dim) + + info = "%d Dienste" % len(self.visible) + if self.only_configured: + info += " (nur konfigurierte)" + if self.filter or self.filter_mode: + info += " Filter: " + self.filter + ("_" if self.filter_mode else "") + put(scr, 2, 1, info, self.c_dim if not self.filter_mode else curses.A_BOLD) + + name_w = max([len(n) for n in self.visible] + [10]) + name_w = min(name_w, max(20, w // 2 - 14)) + state_w = 10 + put(scr, 3, 0, " %-*s %-*s %s" % (name_w, "Dienst", state_w, "Status", "Proxy"), curses.A_UNDERLINE) + + list_top, list_h = 4, max(1, h - 6) + if self.sel < self.top: + self.top = self.sel + elif self.sel >= self.top + list_h: + self.top = self.sel - list_h + 1 + for i in range(list_h): + idx = self.top + i + if idx >= len(self.visible): + break + unit = self.visible[idx] + info = self.units.get(unit, {}) + svc = self.cfg.services.get(unit) + mark = " " + if svc is not None: + mark = "G" if is_inheriting(svc) else "E" + star = "*" if unit in self.changed else " " + state = info.get("active") or info.get("file") or "" + name = unit if len(unit) <= name_w else unit[:name_w - 1] + "…" + summary, sattr = self.service_summary(unit) + y = list_top + i + line = " %s%s %-*s %-*s " % (mark, star, name_w, name, state_w, state[:state_w]) + if idx == self.sel: + put(scr, y, 0, (line + summary).ljust(w), self.c_sel) + else: + put(scr, y, 0, line) + put(scr, y, len(line), summary, sattr) + if not self.visible: + put(scr, list_top, 4, "Keine Dienste gefunden.", self.c_dim) + + put(scr, h - 2, 1, self.msg, self.msg_attr) + if self.filter_mode: + self.key_bar("Filter eingeben Enter übernehmen Esc Filter löschen") + else: + self.key_bar("↑↓ Wählen Enter Bearbeiten g Global Entf Proxy entfernen " + "/ Filter c Nur konfigurierte r Neustart F1 Hilfe q Ende") + scr.refresh() + + def run(self): + self.cursor(False) + if not self.cfg.global_["host"]: + self.set_msg("Tipp: Mit 'g' zuerst einen globalen Proxy festlegen.", self.c_dim) + while True: + self.draw_main() + try: + k = self.scr.get_wch() + except curses.error: + continue + if k == curses.KEY_RESIZE: + continue + if self.filter_mode: + self.handle_filter_key(k) + continue + self.msg = "" + n = len(self.visible) + h, _ = self.scr.getmaxyx() + page = max(1, h - 7) + if k in (curses.KEY_UP, "k"): + self.sel = max(0, self.sel - 1) + elif k in (curses.KEY_DOWN, "j"): + self.sel = min(n - 1, self.sel + 1) + elif k == curses.KEY_PPAGE: + self.sel = max(0, self.sel - page) + elif k == curses.KEY_NPAGE: + self.sel = min(n - 1, self.sel + page) + elif k == curses.KEY_HOME: + self.sel = 0 + elif k == curses.KEY_END: + self.sel = max(0, n - 1) + elif is_enter(k) and n: + self.edit_service(self.visible[self.sel]) + elif k in ("g", "G"): + self.edit_global() + elif k in (curses.KEY_DC, "d") and n: + self.remove_service(self.visible[self.sel]) + elif k == "/": + self.filter_mode = True + elif k in ("c", "C"): + self.only_configured = not self.only_configured + self.apply_filter() + elif k == "r" and n: + self.restart_now([self.visible[self.sel]]) + elif k == curses.KEY_F5: + self.refresh_units() + self.set_msg("Dienstliste aktualisiert.") + elif k in (curses.KEY_F1, "?", "h"): + self.show_help() + elif k in ("q", "Q", curses.KEY_F10): + return self.ask_restart_on_exit() + self.sel = max(0, min(self.sel, len(self.visible) - 1)) + + def handle_filter_key(self, k): + if is_enter(k): + self.filter_mode = False + elif k == "\x1b": + self.filter_mode = False + self.filter = "" + elif is_backspace(k): + self.filter = self.filter[:-1] + elif isinstance(k, str) and k.isprintable(): + self.filter += k + self.sel = 0 + self.top = 0 + self.apply_filter() + + # ---------- Aktionen ---------- + def after_change(self, changed, warnings=()): + self.changed |= changed + if changed and not self.args.no_reload: + try: + daemon_reload() + except ProxyError as e: + self.message("Fehler", [str(e)], error=True) + return + if warnings: + self.message("Hinweis", list(warnings), error=True) + + def edit_service(self, unit): + svc = self.cfg.services.get(unit) + result = self.form(unit, svc or empty_settings(), is_global=False, exists=svc is not None) + if result is None: + return + if result == "remove": + self.remove_service(unit, confirm=False) + return + self.cfg.services[unit] = result + self.cfg.save() + ch, warn = self.cfg.apply_unit(unit) + self.after_change({unit} if ch else set(), [warn] if warn else []) + self.set_msg("%s gespeichert%s." % (unit, "" if ch else " (keine Änderung)")) + + def edit_global(self): + g = dict(self.cfg.global_) + if not g["host"] and not g["no_proxy"]: + g["no_proxy"] = DEFAULT_NO_PROXY + result = self.form(None, g, is_global=True, exists=bool(self.cfg.global_["host"])) + if result is None: + return + if result == "remove": + if not self.confirm("Globalen Proxy wirklich löschen? Dienste, die ihn erben, verlieren ihren Proxy."): + return + result = empty_settings() + self.cfg.global_ = result + self.cfg.save() + changed, warnings = self.cfg.apply_all() + self.after_change(changed, warnings) + self.set_msg("Globaler Proxy gespeichert, %d Dienst(e) angepasst." % len(changed)) + + def remove_service(self, unit, confirm=True): + if unit not in self.cfg.services: + self.set_msg("%s hat keinen Proxy." % unit, self.c_dim) + return + if confirm and not self.confirm("Proxy von %s entfernen?" % unit): + return + del self.cfg.services[unit] + self.cfg.save() + ch, _ = self.cfg.apply_unit(unit) + self.after_change({unit} if ch else set()) + self.apply_filter() + self.set_msg("Proxy von %s entfernt." % unit) + + def restart_now(self, units): + if not self.confirm("%s jetzt neu starten?" % ", ".join(units)): + return + self.set_msg("Starte neu ...", self.c_dim) + self.draw_main() + results = restart_units(units) + self.changed -= {u for u, ok, _ in results if ok} + self.refresh_units() + self.message("Neustart", ["%s: %s" % (u, t) for u, ok, t in results], + error=not all(ok for _, ok, _ in results)) + + def ask_restart_on_exit(self): + """Liefert die Liste der neu zu startenden Dienste.""" + changed = sorted(self.changed) + if not changed or self.args.restart is False: + return [] + if self.args.restart is True: + return changed + chosen = self.checklist( + "Geänderte Dienste neu starten?", + ["Die neuen Proxy-Einstellungen gelten erst nach einem Neustart.", + "Leertaste: an/aus a: alle/keine Enter: neu starten Esc: nicht neu starten"], + changed) + return chosen or [] + + # ---------- Dialoge ---------- + def box(self, title, lines, height_extra=2): + h, w = self.scr.getmaxyx() + bw = min(w - 4, max([len(title) + 6] + [len(l) + 4 for l in lines] + [40])) + bh = min(h - 2, len(lines) + 2 + height_extra) + y, x = max(0, (h - bh) // 2), max(0, (w - bw) // 2) + win = curses.newwin(bh, bw, y, x) + win.keypad(True) + win.erase() + try: + win.box() + except curses.error: + pass + put(win, 0, 2, " %s " % title, curses.A_BOLD) + return win, bh, bw + + def message(self, title, lines, error=False): + win, bh, bw = self.box(title, lines) + for i, l in enumerate(lines[:bh - 4]): + put(win, 1 + i, 2, l, self.c_err if error else 0, bw - 4) + put(win, bh - 2, 2, "[ OK – beliebige Taste ]", curses.A_REVERSE) + win.refresh() + win.get_wch() + del win + self.scr.touchwin() + + def confirm(self, text): + win, bh, bw = self.box("Bestätigen", [text]) + put(win, 1, 2, text, 0, bw - 4) + put(win, bh - 2, 2, "[ j = Ja ] [ n = Nein ]", curses.A_BOLD) + win.refresh() + while True: + k = win.get_wch() + if k in ("j", "J", "y", "Y"): + res = True + break + if k in ("n", "N", "\x1b", "q"): + res = False + break + del win + self.scr.touchwin() + return res + + def checklist(self, title, header, items): + selected = set(items) + cur, top = 0, 0 + while True: + h, w = self.scr.getmaxyx() + self.scr.erase() + self.title_bar(title) + for i, l in enumerate(header): + put(self.scr, 2 + i, 2, l) + list_top = 3 + len(header) + list_h = max(1, h - list_top - 2) + if cur < top: + top = cur + elif cur >= top + list_h: + top = cur - list_h + 1 + for i in range(list_h): + idx = top + i + if idx >= len(items): + break + line = " [%s] %s" % ("x" if items[idx] in selected else " ", items[idx]) + put(self.scr, list_top + i, 2, line.ljust(w - 4), self.c_sel if idx == cur else 0) + self.key_bar("↑↓ Wählen Leertaste an/aus a alle/keine Enter neu starten Esc abbrechen") + self.scr.refresh() + k = self.scr.get_wch() + if k == curses.KEY_UP: + cur = max(0, cur - 1) + elif k == curses.KEY_DOWN: + cur = min(len(items) - 1, cur + 1) + elif k == " ": + selected ^= {items[cur]} + elif k in ("a", "A"): + selected = set() if len(selected) == len(items) else set(items) + elif is_enter(k): + return [i for i in items if i in selected] + elif k in ("\x1b", "q"): + return None + + def show_help(self): + lines = [ + "Markierungen in der Liste:", + " G Dienst nutzt den globalen Proxy (alle Felder leer)", + " E Dienst hat eigene Werte (leere Felder erben global)", + " * in dieser Sitzung geändert, Neustart noch offen", + "", + "Vererbung: Leere Felder beim Dienst übernehmen den globalen Wert.", + "Benutzer/Kennwort werden nur geerbt, wenn auch der Host global ist.", + "Eigener Host ohne Benutzer = Proxy ohne Anmeldung.", + "", + "Tasten: Enter bearbeiten, g global, Entf/d entfernen, / filtern,", + "c nur konfigurierte, r Dienst neu starten, F5 Liste neu laden, q Ende.", + "Nach jeder Änderung wird 'systemctl daemon-reload' ausgeführt.", + "Beim Beenden kann man geänderte Dienste neu starten lassen.", + ] + self.message("Hilfe", lines) + + # ---------- Formular ---------- + def form(self, unit, settings, is_global, exists): + g = self.cfg.global_ + + def ph(key): + if is_global: + return "" + if key in ("user", "password"): + return "(global, falls Host leer)" if g[key] else "" + return ("global: " + g[key]) if g[key] else "" + + if is_global: + type_choices = PROXY_TYPES + tval = settings["type"] or "http" + else: + type_choices = [""] + PROXY_TYPES + tval = settings["type"] + fields = [FormField("type", "Proxy-Typ", tval, kind="choice", choices=type_choices)] + for key in ("host", "port", "user", "password", "no_proxy"): + fields.append(FormField(key, FIELD_LABELS[key], settings[key], + secret=(key == "password"), placeholder=ph(key))) + buttons = [FormField("save", "Speichern", "", kind="button"), + FormField("cancel", "Abbrechen", "", kind="button")] + if exists: + buttons.append(FormField("remove", "Proxy entfernen" if not is_global else "Global löschen", + "", kind="button")) + items = fields + buttons + cur = 1 # Host + error = "" + + def collect(): + return {f.key: f.value for f in fields} + + while True: + scr = self.scr + scr.erase() + h, w = scr.getmaxyx() + if is_global: + self.title_bar("Globaler Proxy") + put(scr, 2, 2, "Gilt für alle Dienste, deren Felder leer sind.", self.c_dim) + else: + info = self.units.get(unit, {}) + self.title_bar("Proxy für %s" % unit) + desc = info.get("desc") or "" + put(scr, 2, 2, "%s [%s]" % (desc, info.get("active") or info.get("file") or "?"), self.c_dim) + + label_w = 12 + input_x = 2 + label_w + 3 + input_w = max(10, min(w - input_x - 2, 60)) + cursor_pos = None + for i, f in enumerate(fields): + y = 4 + i * 2 + focused = (i == cur) + put(scr, y, 2, f.label.rjust(label_w), curses.A_BOLD if focused else 0) + put(scr, y, 2 + label_w, " : ") + if f.kind == "choice": + shown = f.value if f.value else "(global: %s)" % (g["type"] or "http") + text = "◀ %s ▶" % shown + put(scr, y, input_x, text.ljust(input_w), self.c_sel if focused else curses.A_UNDERLINE) + else: + val = "*" * len(f.value) if f.secret else f.value + if f.pos < f.off: + f.off = f.pos + if f.pos - f.off >= input_w: + f.off = f.pos - input_w + 1 + vis = val[f.off:f.off + input_w] + attr = self.c_sel if focused else curses.A_UNDERLINE + put(scr, y, input_x, vis.ljust(input_w), attr) + if not f.value and f.placeholder: + put(scr, y, input_x, f.placeholder[:input_w], attr | curses.A_DIM) + if focused: + cursor_pos = (y, input_x + f.pos - f.off) + + by = 4 + len(fields) * 2 + bx = input_x + for j, b in enumerate(buttons): + focused = (len(fields) + j == cur) + label = "< %s >" % b.label + put(scr, by, bx, label, self.c_sel if focused else curses.A_BOLD) + bx += len(label) + 2 + + # Vorschau + py = by + 2 + try: + cand = validate_settings(collect(), is_global) + if is_global: + preview = proxy_url(cand, mask=True) if cand["host"] else "(kein globaler Proxy)" + else: + eff, _ = resolve(cand, g) + preview = proxy_url(eff, mask=True) + if eff["no_proxy"]: + preview += " No-Proxy: " + eff["no_proxy"] + put(scr, py, 2, "Ergebnis: ", curses.A_BOLD) + put(scr, py, 12, preview, self.c_ok) + except ProxyError as e: + put(scr, py, 2, "Ergebnis: ", curses.A_BOLD) + put(scr, py, 12, str(e), self.c_err) + if error: + put(scr, py + 1, 2, error, self.c_err) + if not is_global: + put(scr, py + 3, 2, "Leere Felder = globaler Wert. Benutzer/Kennwort werden nur geerbt,", self.c_dim) + put(scr, py + 4, 2, "wenn auch der Host leer ist; eigener Host ohne Benutzer = ohne Anmeldung.", self.c_dim) + else: + put(scr, py + 3, 2, "Benutzer/Kennwort leer = Proxy ohne Anmeldung.", self.c_dim) + + self.key_bar("Tab/↑↓ Feld ←→ Typ Enter weiter F2 Speichern Strg+U Feld leeren Esc Abbrechen") + if cursor_pos and items[cur].kind == "text": + self.cursor(True) + try: + scr.move(*cursor_pos) + except curses.error: + pass + else: + self.cursor(False) + scr.refresh() + + try: + k = scr.get_wch() + except curses.error: + continue + item = items[cur] + action = None + + if k == curses.KEY_RESIZE: + continue + elif k == "\x1b": + action = "cancel" + elif k == curses.KEY_F2: + action = "save" + elif k in ("\t", curses.KEY_DOWN): + cur = (cur + 1) % len(items) + elif k in (curses.KEY_BTAB, curses.KEY_UP): + cur = (cur - 1) % len(items) + elif is_enter(k): + if item.kind == "button": + action = item.key + else: + cur = (cur + 1) % len(items) + elif item.kind == "button": + if k == curses.KEY_RIGHT: + cur = min(len(items) - 1, cur + 1) + elif k == curses.KEY_LEFT: + cur = max(len(fields), cur - 1) + elif k == " ": + action = item.key + elif item.kind == "choice": + idx = item.choices.index(item.value) if item.value in item.choices else 0 + if k in (curses.KEY_RIGHT, " ", "+"): + item.value = item.choices[(idx + 1) % len(item.choices)] + elif k in (curses.KEY_LEFT, "-"): + item.value = item.choices[(idx - 1) % len(item.choices)] + else: # Textfeld + if k == curses.KEY_LEFT: + item.pos = max(0, item.pos - 1) + elif k == curses.KEY_RIGHT: + item.pos = min(len(item.value), item.pos + 1) + elif k in (curses.KEY_HOME, "\x01"): + item.pos = 0 + elif k in (curses.KEY_END, "\x05"): + item.pos = len(item.value) + elif is_backspace(k): + if item.pos > 0: + item.value = item.value[:item.pos - 1] + item.value[item.pos:] + item.pos -= 1 + elif k == curses.KEY_DC: + item.value = item.value[:item.pos] + item.value[item.pos + 1:] + elif k == "\x15": # Strg+U + item.value, item.pos, item.off = "", 0, 0 + elif isinstance(k, str) and k.isprintable(): + item.value = item.value[:item.pos] + k + item.value[item.pos:] + item.pos += len(k) + + if action == "cancel": + self.cursor(False) + return None + if action == "remove": + self.cursor(False) + return "remove" + if action == "save": + try: + cand = validate_settings(collect(), is_global) + if not is_global: + resolve(cand, g) + except ProxyError as e: + error = "Nicht gespeichert: %s" % e + continue + self.cursor(False) + return cand + error = "" + + +def run_tui(cfg, args): + os.environ.setdefault("ESCDELAY", "25") + try: + to_restart = curses.wrapper(lambda scr: TUI(scr, cfg, args).run()) + except KeyboardInterrupt: + to_restart = [] + if to_restart: + print("Starte geänderte Dienste neu:") + return 0 if print_restart_results(restart_units(to_restart)) else 1 + return 0 + + +# -------------------------------------------------------------------------- +# Kommandozeile +# -------------------------------------------------------------------------- + +def print_restart_results(results): + ok_all = True + for unit, ok, text in results: + print(" %-40s %s" % (unit, text)) + ok_all &= ok + return ok_all + + +def finish(changed, warnings, args): + for w in warnings: + print("Warnung: " + w, file=sys.stderr) + if not changed: + print("Keine Änderungen an Diensten nötig.") + return 0 + print("Geänderte Dienste: " + ", ".join(sorted(changed))) + if not args.no_reload: + daemon_reload() + print("systemd neu geladen (daemon-reload).") + restart = args.restart + if restart is None and sys.stdin.isatty() and sys.stdout.isatty(): + try: + ans = input("Geänderte Dienste jetzt neu starten? [j/N] ").strip().lower() + except EOFError: + ans = "" + restart = ans in ("j", "ja", "y", "yes") + if restart: + return 0 if print_restart_results(restart_units(changed)) else 1 + print("Hinweis: Die Änderungen gelten erst nach einem Neustart, z.B.:") + print(" systemctl restart " + " ".join(sorted(changed))) + return 0 + + +def fields_from_args(args): + """Liefert nur die per Parameter angegebenen Felder.""" + vals = {} + if args.proxy is not None: + vals.update(parse_proxy_url(args.proxy)) + for key in ("type", "host", "port", "user", "password", "no_proxy"): + v = getattr(args, key) + if v is not None: + vals[key] = v + if args.password_stdin: + line = sys.stdin.readline() + vals["password"] = line.rstrip("\r\n") + return vals + + +def cmd_global(cfg, args): + if args.clear: + cfg.global_ = empty_settings() + else: + vals = fields_from_args(args) + if not vals: + return cmd_show(cfg, argparse.Namespace(unit=None)) + new = dict(cfg.global_) + if not new["host"] and not new["no_proxy"] and "no_proxy" not in vals: + new["no_proxy"] = DEFAULT_NO_PROXY + new.update(vals) + cfg.global_ = validate_settings(new, is_global=True) + cfg.save() + print("Globaler Proxy: " + (proxy_url(cfg.global_, mask=True) if cfg.global_["host"] else "(gelöscht)")) + changed, warnings = cfg.apply_all() + return finish(changed, warnings, args) + + +def cmd_set(cfg, args): + vals = fields_from_args(args) + units = [normalize_unit(u) for u in args.units] + staged = {} + for unit in units: + base = empty_settings() if args.reset or unit not in cfg.services else dict(cfg.services[unit]) + base.update(vals) + svc = validate_settings(base, is_global=False) + try: + eff, _ = resolve(svc, cfg.global_) + except ProxyError as e: + raise ProxyError("%s: %s" % (unit, e)) + staged[unit] = svc + print("%s -> %s%s" % (unit, proxy_url(eff, mask=True), + " (global)" if is_inheriting(svc) else "")) + cfg.services.update(staged) + cfg.save() + changed, warnings = set(), [] + for unit in units: + ch, warn = cfg.apply_unit(unit) + if ch: + changed.add(unit) + if warn: + warnings.append(warn) + return finish(changed, warnings, args) + + +def cmd_unset(cfg, args): + if args.all: + units = sorted(set(cfg.services) | cfg.managed_dropins()) + elif args.units: + units = [normalize_unit(u) for u in args.units] + else: + raise ProxyError("Bitte Dienst(e) angeben oder --all verwenden") + for unit in units: + if unit not in cfg.services and unit not in cfg.managed_dropins(): + print("%s: hat keinen Proxy" % unit) + cfg.services.pop(unit, None) + cfg.save() + changed = set() + for unit in units: + ch, _ = cfg.apply_unit(unit) + if ch: + changed.add(unit) + print("%s: Proxy entfernt" % unit) + return finish(changed, [], args) + + +def cmd_apply(cfg, args): + changed, warnings = cfg.apply_all() + return finish(changed, warnings, args) + + +def cmd_list(cfg, args): + print("Global: " + (proxy_url(cfg.global_, mask=True) if cfg.global_["host"] else "(nicht gesetzt)")) + if cfg.global_["no_proxy"]: + print(" No-Proxy: " + cfg.global_["no_proxy"]) + print() + if args.all: + units = list_system_services() + for u in cfg.services: + units.setdefault(u, {"active": "", "file": "?"}) + names = sorted(units) + else: + units = {} + names = sorted(cfg.services) + if not names: + print("Keine Dienste mit Proxy konfiguriert. (--all zeigt alle Dienste)") + return 0 + width = max([len(n) for n in names] + [6]) + print(" %-*s %-10s %s" % (width, "Dienst", "Status", "Proxy")) + for name in names: + svc = cfg.services.get(name) + state = (units.get(name) or {}).get("active") or (unit_active(name) if not args.all else "") + if svc is None: + mark, text = " ", "" + else: + mark = "G" if is_inheriting(svc) else "E" + try: + eff, _ = resolve(svc, cfg.global_) + text = proxy_url(eff, mask=True) + except ProxyError as e: + text = "FEHLER: %s" % e + print(" %s %-*s %-10s %s" % (mark, width, name, state, text)) + print("\nG = nutzt globalen Proxy, E = eigene Werte") + return 0 + + +def cmd_show(cfg, args): + if not args.unit: + g = cfg.global_ + print("Globaler Proxy") + if not g["host"]: + print(" (nicht gesetzt)") + for key in FIELDS: + val = g[key] + if key == "password" and val: + val = "****" + print(" %-10s %s" % (FIELD_LABELS[key], val or "-")) + if g["host"]: + print(" %-10s %s" % ("URL", proxy_url(g, mask=True))) + return 0 + unit = normalize_unit(args.unit) + svc = cfg.services.get(unit) + print("Dienst: %s" % unit) + print("Status: %s" % unit_active(unit)) + if svc is None: + print("Proxy: keiner") + return 0 + print("Modus: %s" % ("nutzt globalen Proxy" if is_inheriting(svc) else "eigene Werte (leere Felder = global)")) + try: + eff, src = resolve(svc, cfg.global_) + except ProxyError as e: + print("FEHLER: %s" % e) + return 1 + for key in FIELDS: + val = eff[key] + if key == "password" and val: + val = "****" + print(" %-10s %-35s (%s)" % (FIELD_LABELS[key], val or "-", src[key])) + print("Proxy-URL: %s" % proxy_url(eff, mask=True)) + print("Drop-in: %s" % cfg.dropin_file(unit)) + print("Env-Datei: %s" % cfg.env_file(unit)) + return 0 + + +def build_parser(): + def common(sub): + d = argparse.SUPPRESS if sub else None + p = argparse.ArgumentParser(add_help=False) + g = p.add_mutually_exclusive_group() + g.add_argument("--restart", dest="restart", action="store_const", const=True, + default=d) + g.add_argument("--no-restart", dest="restart", action="store_const", const=False, + default=d) + p.add_argument("--no-reload", action="store_true", default=argparse.SUPPRESS if sub else False) + p.add_argument("--config-dir", default=d) + p.add_argument("--systemd-dir", default=d) + return p + + fields = argparse.ArgumentParser(add_help=False) + fields.add_argument("--proxy") + fields.add_argument("--type", type=str.lower, choices=PROXY_TYPES) + fields.add_argument("--host") + fields.add_argument("--port") + fields.add_argument("--user") + fields.add_argument("--password") + fields.add_argument("--password-stdin", action="store_true") + fields.add_argument("--no-proxy", dest="no_proxy") + + parser = argparse.ArgumentParser(prog=PROG, add_help=False, parents=[common(False)]) + parser.add_argument("-V", "--version", action="version", version="%s %s" % (PROG, VERSION)) + sub = parser.add_subparsers(dest="command") + c = common(True) + sub.add_parser("tui", add_help=False, parents=[c]) + sub.add_parser("help", add_help=False) + p = sub.add_parser("list", add_help=False, parents=[c]) + p.add_argument("--all", action="store_true") + p = sub.add_parser("show", add_help=False, parents=[c]) + p.add_argument("unit", nargs="?") + p = sub.add_parser("global", add_help=False, parents=[c, fields]) + p.add_argument("--clear", action="store_true") + p = sub.add_parser("set", add_help=False, parents=[c, fields]) + p.add_argument("units", nargs="+", metavar="DIENST") + p.add_argument("--reset", action="store_true") + p = sub.add_parser("unset", add_help=False, parents=[c]) + p.add_argument("units", nargs="*", metavar="DIENST") + p.add_argument("--all", action="store_true") + sub.add_parser("apply", add_help=False, parents=[c]) + return parser + + +def main(argv=None): + argv = sys.argv[1:] if argv is None else argv + if "-h" in argv or "--help" in argv or (argv and argv[0] == "help"): + print(HELP_TEXT) + return 0 + args = build_parser().parse_args(argv) + command = args.command or "tui" + + custom_dirs = bool(args.config_dir or args.systemd_dir) + cfg = Config(args.config_dir or DEFAULT_CONFIG_DIR, args.systemd_dir or DEFAULT_SYSTEMD_DIR) + if os.geteuid() != 0 and not custom_dirs: + print("%s: Bitte als root ausführen (z.B. sudo %s)." % (PROG, PROG), file=sys.stderr) + return 1 + try: + locale.setlocale(locale.LC_ALL, "") + except locale.Error: + pass + + try: + cfg.load() + if command == "tui": + if not sys.stdin.isatty() or not sys.stdout.isatty(): + raise ProxyError("Das Menü braucht ein Terminal. Für Skripte die Befehle nutzen (siehe --help).") + return run_tui(cfg, args) + handler = { + "list": cmd_list, "show": cmd_show, "global": cmd_global, "set": cmd_set, + "unset": cmd_unset, "apply": cmd_apply, + }[command] + return handler(cfg, args) + except ProxyError as e: + print("%s: Fehler: %s" % (PROG, e), file=sys.stderr) + return 1 + except OSError as e: + print("%s: Fehler: %s" % (PROG, e), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main())