Files
webproxyserver-serviceclient/svc-proxy
T
duffyduckandClaude Opus 5.5 3968c1a7b8 Modus proxychains pro Dienst hinzufügen
Neben den Proxy-Variablen (env) kann pro Dienst jetzt der Modus
proxychains gewählt werden: proxychains-ng wird per LD_PRELOAD geladen
und leitet alle ausgehenden TCP-Verbindungen über den Proxy, auch bei
Programmen ohne Proxy-Unterstützung (z.B. Kerio Connect SMTP/POP3).

- eigene proxychains-Konfiguration je Dienst unter /etc/svc-proxy/proxychains/
- localhost, private Netze, DNS und IP-Einträge aus No-Proxy gehen direkt
- Proxy-Hostname wird zu IPv4 aufgelöst, Kennwort-Datei an User= des Dienstes
- Modus-Auswahl im ncurses-Formular, --mode für set, Anzeige in list/show
- README und --help ergänzt
- __pycache__ aus dem Repo entfernt, .gitignore angelegt

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 14:11:32 +02:00

1690 lines
64 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 ipaddress
import json
import locale
import os
import pwd
import re
import socket
import subprocess
import sys
import tempfile
from glob import glob as glob_paths
from urllib.parse import quote, unquote, urlsplit
VERSION = "1.1.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"]
# Modus pro Dienst: Proxy-Variablen oder proxychains (LD_PRELOAD, alle TCP-Verbindungen)
MODES = ["env", "proxychains"]
MODE_LABELS = {
"env": "Proxy-Variablen",
"proxychains": "proxychains (alle TCP-Verbindungen)",
}
PROXYCHAINS_TYPES = {"http": "http", "socks4": "socks4", "socks5": "socks5", "socks5h": "socks5"}
PROXYCHAINS_LIB_GLOBS = [
"/usr/lib/*/libproxychains.so.4",
"/usr/lib/libproxychains.so.4",
"/usr/lib*/libproxychains4.so",
"/usr/lib*/proxychains-ng/libproxychains4.so",
"/usr/local/lib/libproxychains4.so",
]
# Ziele, die im proxychains-Modus immer direkt (ohne Proxy) erreicht werden
PROXYCHAINS_LOCALNETS = [
"127.0.0.0/255.0.0.0",
"::1/128",
"10.0.0.0/255.0.0.0",
"172.16.0.0/255.240.0.0",
"192.168.0.0/255.255.0.0",
"169.254.0.0/255.255.0.0",
"fc00::/7",
"fe80::/10",
"0.0.0.0:53/0.0.0.0",
"[::]:53/0",
]
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 den Dienst über einen
Proxy laufen lässt. Pro Dienst gibt es zwei Modi:
env (Standard) Setzt http_proxy, https_proxy, ftp_proxy, all_proxy und
no_proxy (auch in GROSSBUCHSTABEN). Wirkt bei allen Programmen, die
diese Variablen beachten (curl, wget, apt, Python, Go, Docker, ...).
proxychains Lädt proxychains-ng per LD_PRELOAD in den Dienst. Dadurch laufen ALLE
ausgehenden TCP-Verbindungen über den Proxy, auch bei Programmen, die
keine Proxy-Variablen kennen (z.B. Kerio Connect: SMTP, POP3, IMAP).
Direkt (ohne Proxy) bleiben: localhost, private Netze (10/8, 172.16/12,
192.168/16, fc00::/7, Link-Local), DNS (Port 53) und alle IP-/Netz-
Einträge aus der No-Proxy-Liste. Benötigt: apt install proxychains4
Proxy-Typen: http, socks4, socks5 (socks5h wird wie socks5 behandelt).
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
--mode MODUS (nur 'set') env | proxychains (Standard: env, wird nicht
vom globalen Proxy geerbt)
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
# Kerio Connect: alle Mailverbindungen über SOCKS5, Proxy-Daten vom globalen Proxy
sudo apt install proxychains4
sudo {prog} set kerio-connect --mode proxychains --restart
# Kerio mit eigenem SOCKS5-Proxy inkl. Anmeldung, LAN-Netz 192.0.2.0/24 direkt
sudo {prog} set kerio-connect --mode proxychains --proxy socks5://192.0.2.10:1080 \\
--user max --password-stdin --no-proxy 192.0.2.0/24
# Zurück zu den Proxy-Variablen
sudo {prog} set kerio-connect --mode env
# 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)
{cfg}/proxychains/DIENST.conf proxychains-Konfiguration (Modus proxychains)
{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():
s = {k: "" for k in FIELDS}
s["mode"] = "" # nur bei Diensten benutzt, wird nicht vererbt
return s
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"])
s["mode"] = (raw.get("mode") or "").strip().lower()
if s["mode"] and s["mode"] not in MODES:
raise ProxyError("Unbekannter Modus '%s' (erlaubt: %s)" % (s["mode"], ", ".join(MODES)))
if is_global:
if s["mode"]:
raise ProxyError("Den Modus gibt es nur pro Dienst, nicht 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"
eff["mode"] = svc.get("mode") or "env"
src["mode"] = "dienst" if svc.get("mode") else "standard"
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)")
if eff["mode"] == "proxychains":
if eff["type"] not in PROXYCHAINS_TYPES:
raise ProxyError("proxychains kann keinen '%s'-Proxy (nur http, socks4, socks5)" % eff["type"])
if re.search(r"\s", eff["user"] + eff["password"]):
raise ProxyError("proxychains: Benutzer/Kennwort dürfen keine Leerzeichen enthalten")
return eff, src
def find_proxychains_lib():
override = os.environ.get("SVC_PROXY_PROXYCHAINS_LIB")
if override:
return override
for pattern in PROXYCHAINS_LIB_GLOBS:
for path in sorted(glob_paths(pattern)):
if os.path.isfile(path):
return path
raise ProxyError("proxychains ist nicht installiert (Debian/Ubuntu: apt install proxychains4, "
"RHEL/Fedora: dnf install proxychains-ng)")
def proxy_ipv4(host):
"""proxychains akzeptiert in der ProxyList nur IPv4-Adressen."""
try:
addr = ipaddress.ip_address(host)
except ValueError:
addr = None
if addr is not None:
if addr.version != 4:
raise ProxyError("proxychains braucht eine IPv4-Adresse für den Proxy, nicht %s" % host)
return str(addr)
try:
infos = socket.getaddrinfo(host, None, socket.AF_INET, socket.SOCK_STREAM)
except socket.gaierror as e:
raise ProxyError("Proxy-Host %s nicht auflösbar (%s) – proxychains braucht eine IPv4-Adresse"
% (host, e.strerror))
return infos[0][4][0]
def proxychains_localnets(no_proxy):
"""Standard-Ausnahmen plus alle IP-/Netz-Einträge aus der No-Proxy-Liste."""
nets = list(PROXYCHAINS_LOCALNETS)
for entry in no_proxy.split(","):
entry = entry.strip().strip("[]")
try:
net = ipaddress.ip_network(entry, strict=False)
except ValueError:
continue # Hostnamen/Domains kann proxychains nicht ausnehmen
if net.version == 4:
item = "%s/%s" % (net.network_address, net.netmask)
else:
item = "%s/%d" % (net.network_address, net.prefixlen)
if item not in nets:
nets.append(item)
return nets
def check_mode(eff):
"""Prüft vor dem Speichern, ob der gewählte Modus nutzbar ist."""
if eff["mode"] == "proxychains":
find_proxychains_lib()
proxy_ipv4(eff["host"])
def service_user(unit):
"""User= des Dienstes (leer = root)."""
rc, out, _ = run_cmd(["systemctl", "show", "-p", "User", "-p", "DynamicUser", unit])
if rc != 0:
return "", False
vals = dict(line.split("=", 1) for line in out.splitlines() if "=" in line)
return vals.get("User", ""), vals.get("DynamicUser", "no") == "yes"
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)
def mode_suffix(eff):
return " [proxychains]" if eff["mode"] == "proxychains" else ""
# --------------------------------------------------------------------------
# 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.pc_dir = os.path.join(config_dir, "proxychains")
self.systemd_dir = systemd_dir
self.global_ = empty_settings()
self.services = {}
# Pfade
@staticmethod
def safe_name(unit):
return re.sub(r"[^A-Za-z0-9:_.@-]", "_", unit)
def env_file(self, unit):
return os.path.join(self.env_dir, self.safe_name(unit) + ".env")
def pc_file(self, unit):
return os.path.join(self.pc_dir, self.safe_name(unit) + ".conf")
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, keys):
s = empty_settings()
for k in keys:
if isinstance(d, dict) and isinstance(d.get(k), str):
s[k] = d[k]
return s
self.global_ = clean(data.get("global"), FIELDS)
self.services = {}
for unit, s in (data.get("services") or {}).items():
if UNIT_RE.match(unit):
self.services[unit] = clean(s, FIELDS + ["mode"])
def ensure_config_dir(self):
# 0711: Dienste mit eigenem Benutzer müssen ihre proxychains-Datei erreichen,
# dürfen das Verzeichnis aber nicht auflisten. config.json und env/ bleiben 0600/0700.
os.makedirs(self.config_dir, mode=0o711, exist_ok=True)
os.chmod(self.config_dir, 0o711)
def save(self):
self.ensure_config_dir()
data = {
"version": 1,
"global": {k: self.global_[k] for k in FIELDS},
"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):
lines = [
"# Erzeugt von %s fuer %s - Aenderungen werden ueberschrieben." % (PROG, unit),
"# Bearbeiten mit: %s set %s ..." % (PROG, unit),
]
if eff["mode"] == "proxychains":
lines.append('LD_PRELOAD="%s"' % find_proxychains_lib())
lines.append('PROXYCHAINS_CONF_FILE="%s"' % self.pc_file(unit))
lines.append('PROXYCHAINS_QUIET_MODE="1"')
return "\n".join(lines) + "\n"
url = proxy_url(eff)
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_proxychains(self, unit, eff):
entry = "%s %s %s" % (PROXYCHAINS_TYPES[eff["type"]], proxy_ipv4(eff["host"]), eff["port"])
if eff["user"]:
entry += " %s %s" % (eff["user"], eff["password"])
lines = [
"# Erzeugt von %s fuer %s - Aenderungen werden ueberschrieben." % (PROG, unit),
"# Proxy: %s (%s)" % (proxy_url(eff, mask=True), eff["host"]),
"strict_chain",
"quiet_mode",
"tcp_read_time_out 15000",
"tcp_connect_time_out 8000",
"# Kein proxy_dns: DNS wird normal aufgeloest (wichtig z.B. fuer MX-Abfragen).",
"",
"# Diese Ziele werden direkt (ohne Proxy) verbunden:",
]
lines += ["localnet " + n for n in proxychains_localnets(eff["no_proxy"])]
lines += ["", "[ProxyList]", entry]
return "\n".join(lines) + "\n"
def write_proxychains(self, unit, eff):
"""Schreibt die proxychains-Datei. Liefert (geändert, warnung)."""
self.ensure_config_dir()
os.makedirs(self.pc_dir, mode=0o755, exist_ok=True)
os.chmod(self.pc_dir, 0o755)
path = self.pc_file(unit)
secret = bool(eff["user"])
changed = write_if_different(path, self.render_proxychains(unit, eff), 0o600 if secret else 0o644)
os.chmod(path, 0o600 if secret else 0o644)
warning = None
# Die Bibliothek liest die Datei im Dienst-Prozess, also mit dessen Benutzer.
user, dynamic = service_user(unit)
if secret and os.geteuid() == 0:
uid = gid = 0
if dynamic:
warning = ("%s: nutzt DynamicUser – die proxychains-Datei mit Kennwort ist für den "
"Dienst nicht lesbar. Proxy ohne Anmeldung verwenden." % unit)
elif user and user != "root":
try:
pw = pwd.getpwnam(user)
uid, gid = pw.pw_uid, pw.pw_gid
except KeyError:
warning = "%s: Benutzer '%s' unbekannt – proxychains-Datei evtl. nicht lesbar" % (unit, user)
st = os.stat(path)
if (st.st_uid, st.st_gid) != (uid, gid):
os.chown(path, uid, gid)
return changed, warning
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))
changed |= remove_file(self.pc_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
warning = None
try:
eff, _ = resolve(svc, self.global_)
changed = False
if eff["mode"] == "proxychains":
changed, warning = self.write_proxychains(unit, eff)
else:
changed = remove_file(self.pc_file(unit))
env = self.render_env(unit, eff)
except ProxyError as e:
return self.remove_unit_files(unit), "%s: %s – Proxy für diesen Dienst deaktiviert" % (unit, e)
self.ensure_config_dir()
os.makedirs(self.env_dir, mode=0o700, exist_ok=True)
changed |= write_if_different(self.env_file(unit), env, 0o600)
changed |= write_if_different(self.dropin_file(unit), self.render_dropin(unit), 0o644)
return changed, warning
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="",
display=None):
self.key = key
self.label = label
self.kind = kind # text | choice | button
self.value = value
self.choices = choices or []
self.display = display 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) + mode_suffix(eff), self.c_global
return "eigen → " + proxy_url(eff, mask=True) + mode_suffix(eff), 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.",
"",
"Modus (pro Dienst):",
" Proxy-Variablen setzt http_proxy & Co. – nur für Programme, die sie beachten",
" proxychains leitet ALLE TCP-Verbindungen des Dienstes über den Proxy",
" (z.B. Kerio SMTP/POP3). Lokale/private Netze und DNS gehen direkt.",
"",
"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 = []
if not is_global:
fields.append(FormField("mode", "Modus", settings["mode"] or "env", kind="choice",
choices=MODES, display=MODE_LABELS))
fields.append(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 = [f.key for f in fields].index("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
step = 2 if h >= 4 + len(fields) * 2 + 9 else 1
for i, f in enumerate(fields):
y = 4 + i * step
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":
if f.display:
shown = f.display.get(f.value, f.value)
else:
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) * step
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) + mode_suffix(eff)
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 + 2, 2, "Leere Felder = globaler Wert. Benutzer/Kennwort werden nur geerbt,", self.c_dim)
put(scr, py + 3, 2, "wenn auch der Host leer ist; eigener Host ohne Benutzer = ohne Anmeldung.", self.c_dim)
if fields[0].value == "proxychains":
put(scr, py + 4, 2, "proxychains: alle TCP-Verbindungen über den Proxy; direkt bleiben localhost,", self.c_dim)
put(scr, py + 5, 2, "private Netze, DNS (Port 53) und IP-/Netz-Einträge aus No-Proxy.", self.c_dim)
else:
put(scr, py + 3, 2, "Benutzer/Kennwort leer = Proxy ohne Anmeldung.", self.c_dim)
self.key_bar("Tab/↑↓ Feld ←→ Auswahl 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:
eff, _ = resolve(cand, g)
check_mode(eff)
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", "mode"):
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_)
check_mode(eff)
except ProxyError as e:
raise ProxyError("%s: %s" % (unit, e))
staged[unit] = svc
print("%s -> %s%s%s" % (unit, proxy_url(eff, mask=True),
" (global)" if is_inheriting(svc) else "", mode_suffix(eff)))
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) + mode_suffix(eff)
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("Herkunft: %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
print("Modus: %s" % MODE_LABELS[eff["mode"]])
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))
if eff["mode"] == "proxychains":
print("proxychains-Konfiguration: %s" % cfg.pc_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")
fields.add_argument("--mode", type=str.lower, choices=MODES)
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())