Neuer Befehl 'svc-proxy test [DIENST]' sowie Taste 't' in der Liste und Schaltfläche/F3 im Formular (testet ungespeicherte Werte). - prüft Erreichbarkeit, Handshake und Anmeldung für SOCKS5/SOCKS4/HTTP(S) mit klaren Meldungen (Kennwort falsch, Proxy-Typ vertauscht, Port gesperrt) - baut Tunnel zu Testzielen auf und liest die Begrüßung (SMTP, POP3, IMAP, HTTP), TLS für 443/465/993/995 - --target HOST:PORT, --mail (SMTP 25/587/465, POP3S, IMAPS), --timeout - im proxychains-Modus zusätzlicher Test über die proxychains-Bibliothek mit der Konfiguration des Dienstes - Ad-hoc-Test mit --proxy ohne root und ohne Speichern - Hilfe und README ergänzt, Version 1.2.0 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2239 lines
87 KiB
Python
Executable File
2239 lines
87 KiB
Python
Executable File
#!/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 base64
|
||
import curses
|
||
import ipaddress
|
||
import json
|
||
import locale
|
||
import os
|
||
import pwd
|
||
import re
|
||
import socket
|
||
import ssl
|
||
import struct
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
from glob import glob as glob_paths
|
||
from urllib.parse import quote, unquote, urlsplit
|
||
|
||
VERSION = "1.2.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",
|
||
]
|
||
|
||
# Verbindungstest
|
||
TEST_TARGETS_WEB = ["example.com:80", "example.com:443"]
|
||
TEST_TARGETS_MAIL = [
|
||
"gmail-smtp-in.l.google.com:25", # SMTP (MX-Zustellung)
|
||
"smtp.gmail.com:587", # SMTP Submission
|
||
"smtp.gmail.com:465", # SMTPS
|
||
"pop.gmail.com:995", # POP3S
|
||
"imap.gmail.com:993", # IMAPS
|
||
]
|
||
TEST_TIMEOUT = 6.0
|
||
TLS_PORTS = {443, 465, 563, 636, 853, 993, 995, 8443}
|
||
HTTP_PORTS = {80, 443, 8080, 8443}
|
||
SOCKS5_ERRORS = {
|
||
1: "allgemeiner Fehler des Proxys",
|
||
2: "vom Regelwerk des Proxys nicht erlaubt (Port/Ziel gesperrt?)",
|
||
3: "Netz nicht erreichbar",
|
||
4: "Host nicht erreichbar",
|
||
5: "Verbindung vom Ziel abgelehnt",
|
||
6: "Zeitüberschreitung (TTL abgelaufen)",
|
||
7: "Befehl nicht unterstützt",
|
||
8: "Adresstyp nicht unterstützt",
|
||
}
|
||
|
||
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
|
||
test [DIENST] [ZIELE] Verbindungstest über den Proxy des Dienstes
|
||
(ohne DIENST: globaler Proxy, oder ad hoc mit --proxy)
|
||
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.
|
||
|
||
VERBINDUNGSTEST ('test')
|
||
Prüft der Reihe nach:
|
||
1. Ist der Proxy erreichbar (TCP)?
|
||
2. Klappt der Handshake inkl. Anmeldung (SOCKS5/SOCKS4/HTTP CONNECT)?
|
||
Klare Meldung bei falschem Kennwort, falschem Proxy-Typ, gesperrtem Port.
|
||
3. Für jedes Ziel: Tunnel aufbauen und Begrüßung lesen (SMTP "220",
|
||
POP3 "+OK", IMAP "* OK", HTTP-Status). Ports 443/465/993/995 per TLS.
|
||
4. Im Modus proxychains zusätzlich: derselbe Test über die proxychains-
|
||
Bibliothek mit der Konfiguration des Dienstes (so verbindet der Dienst).
|
||
--target HOST:PORT Testziel (mehrfach möglich). Standard: {web}
|
||
--mail Mail-Ziele testen: SMTP 25/587/465, POP3S 995, IMAPS 993 (Gmail)
|
||
--timeout SEK Zeitlimit pro Schritt (Standard: {timeout:g} s)
|
||
FELDER (--proxy, --host, ...) überschreiben die gespeicherten Werte nur für den
|
||
Test. Mit --proxy ohne DIENST geht der Test auch ohne root.
|
||
Rückgabewert: 0 = alles ok, 1 = mindestens ein Test fehlgeschlagen.
|
||
|
||
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
|
||
|
||
# Verbindungstests
|
||
sudo {prog} test # globaler Proxy, Web-Ziele
|
||
sudo {prog} test kerio-connect --mail # Mail-Ports über Kerios Proxy
|
||
sudo {prog} test kerio-connect --target mx.example.com:25 --target pop.example.com:995
|
||
{prog} test --proxy socks5://192.0.2.10:1080 --mail # ad hoc, ohne Speichern
|
||
|
||
# Ü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,
|
||
web=", ".join(TEST_TARGETS_WEB), timeout=TEST_TIMEOUT,
|
||
sysd=DEFAULT_SYSTEMD_DIR, dropin=DROPIN_NAME)
|
||
|
||
|
||
class ProxyError(Exception):
|
||
pass
|
||
|
||
|
||
class ProxyFatal(ProxyError):
|
||
"""Fehler, bei dem weitere Testziele sinnlos sind (falscher Typ, Anmeldung)."""
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 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
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Verbindungstest
|
||
# --------------------------------------------------------------------------
|
||
|
||
def parse_target(text):
|
||
text = text.strip()
|
||
m = re.match(r"^\[([0-9A-Fa-f:.]+)\]:(\d+)$", text) or re.match(r"^([A-Za-z0-9._-]+):(\d+)$", text)
|
||
if not m or not 1 <= int(m.group(2)) <= 65535:
|
||
raise ProxyError("Ungültiges Ziel %r (Format: HOST:PORT)" % text)
|
||
return m.group(1), int(m.group(2))
|
||
|
||
|
||
def format_target(host, port):
|
||
return "[%s]:%d" % (host, port) if ":" in host else "%s:%d" % (host, port)
|
||
|
||
|
||
def recv_exact(sock, n):
|
||
buf = b""
|
||
while len(buf) < n:
|
||
chunk = sock.recv(n - len(buf))
|
||
if not chunk:
|
||
raise ProxyError("Proxy hat die Verbindung unerwartet geschlossen")
|
||
buf += chunk
|
||
return buf
|
||
|
||
|
||
def resolve_local(host, ipv4_only=False):
|
||
family = socket.AF_INET if ipv4_only else 0
|
||
try:
|
||
infos = socket.getaddrinfo(host, None, family, socket.SOCK_STREAM)
|
||
except socket.gaierror as e:
|
||
raise ProxyError("Ziel %s lokal nicht auflösbar (%s)" % (host, e.strerror))
|
||
infos.sort(key=lambda i: i[0] != socket.AF_INET)
|
||
return infos[0][4][0]
|
||
|
||
|
||
def socks5_connect(sock, eff, host, port):
|
||
methods = b"\x00\x02" if eff["user"] else b"\x00"
|
||
sock.sendall(b"\x05" + bytes([len(methods)]) + methods)
|
||
try:
|
||
ver, method = recv_exact(sock, 2)
|
||
except socket.timeout:
|
||
raise ProxyFatal("Keine Antwort auf den SOCKS5-Handshake – ist der Proxy-Typ richtig (http statt socks5?)")
|
||
if ver != 5:
|
||
raise ProxyFatal("Keine SOCKS5-Antwort – ist der Proxy-Typ richtig (http statt socks5?)")
|
||
if method == 0xFF:
|
||
raise ProxyFatal("Proxy akzeptiert keine angebotene Anmeldemethode%s"
|
||
% ("" if eff["user"] else " – verlangt er Benutzer/Kennwort?"))
|
||
if method == 2:
|
||
if not eff["user"]:
|
||
raise ProxyFatal("Proxy verlangt Benutzer/Kennwort")
|
||
u, p = eff["user"].encode(), eff["password"].encode()
|
||
sock.sendall(b"\x01" + bytes([len(u)]) + u + bytes([len(p)]) + p)
|
||
if recv_exact(sock, 2)[1] != 0:
|
||
raise ProxyFatal("Anmeldung abgelehnt – Benutzer oder Kennwort falsch")
|
||
elif method != 0:
|
||
raise ProxyFatal("Proxy verlangt eine nicht unterstützte Anmeldemethode (%d)" % method)
|
||
|
||
if eff["type"] == "socks5h":
|
||
name = host.encode("idna")
|
||
addr = b"\x03" + bytes([len(name)]) + name
|
||
else:
|
||
ip = resolve_local(host)
|
||
if ":" in ip:
|
||
addr = b"\x04" + socket.inet_pton(socket.AF_INET6, ip)
|
||
else:
|
||
addr = b"\x01" + socket.inet_aton(ip)
|
||
sock.sendall(b"\x05\x01\x00" + addr + struct.pack(">H", port))
|
||
try:
|
||
_, rep, _, atyp = recv_exact(sock, 4)
|
||
except socket.timeout:
|
||
raise ProxyError("Proxy erreicht das Ziel nicht (Zeitüberschreitung) – Port/Ziel gesperrt?")
|
||
if rep != 0:
|
||
raise ProxyError("Proxy meldet: %s" % SOCKS5_ERRORS.get(rep, "Fehlercode %d" % rep))
|
||
if atyp == 1:
|
||
recv_exact(sock, 4)
|
||
elif atyp == 3:
|
||
recv_exact(sock, recv_exact(sock, 1)[0])
|
||
elif atyp == 4:
|
||
recv_exact(sock, 16)
|
||
recv_exact(sock, 2)
|
||
return b""
|
||
|
||
|
||
def socks4_connect(sock, eff, host, port):
|
||
ip = resolve_local(host, ipv4_only=True)
|
||
sock.sendall(struct.pack(">BBH", 4, 1, port) + socket.inet_aton(ip) + eff["user"].encode() + b"\x00")
|
||
try:
|
||
reply = recv_exact(sock, 8)
|
||
except socket.timeout:
|
||
raise ProxyError("Keine Antwort vom Proxy – falscher Proxy-Typ oder Ziel nicht erreichbar?")
|
||
if reply[0] != 0:
|
||
raise ProxyFatal("Keine SOCKS4-Antwort – ist der Proxy-Typ richtig?")
|
||
if reply[1] != 0x5A:
|
||
reasons = {0x5B: "Anfrage abgelehnt oder Ziel nicht erreichbar",
|
||
0x5C: "identd des Clients nicht erreichbar",
|
||
0x5D: "identd-Benutzer passt nicht"}
|
||
raise ProxyError("Proxy meldet: %s" % reasons.get(reply[1], "Code %d" % reply[1]))
|
||
return b""
|
||
|
||
|
||
def http_connect(sock, eff, host, port):
|
||
target = format_target(host, port)
|
||
req = "CONNECT %s HTTP/1.1\r\nHost: %s\r\nUser-Agent: %s/%s\r\n" % (target, target, PROG, VERSION)
|
||
if eff["user"]:
|
||
cred = base64.b64encode(("%s:%s" % (eff["user"], eff["password"])).encode()).decode()
|
||
req += "Proxy-Authorization: Basic %s\r\n" % cred
|
||
sock.sendall((req + "\r\n").encode())
|
||
data = b""
|
||
while b"\r\n\r\n" not in data:
|
||
try:
|
||
chunk = sock.recv(4096)
|
||
except socket.timeout:
|
||
raise ProxyError("Keine Antwort vom Proxy – falscher Proxy-Typ (socks statt http?) "
|
||
"oder Ziel nicht erreichbar")
|
||
if not chunk:
|
||
if not data:
|
||
raise ProxyFatal("Proxy hat ohne Antwort geschlossen – ist der Proxy-Typ richtig (socks statt http?)")
|
||
break
|
||
data += chunk
|
||
if len(data) > 65536:
|
||
raise ProxyFatal("Antwort des Proxys zu groß")
|
||
head, _, rest = data.partition(b"\r\n\r\n")
|
||
status = head.split(b"\r\n", 1)[0].decode("latin-1").strip()
|
||
parts = status.split(None, 2)
|
||
if len(parts) < 2 or not parts[0].startswith("HTTP/") or not parts[1].isdigit():
|
||
raise ProxyFatal("Keine HTTP-Proxy-Antwort – ist der Proxy-Typ richtig?")
|
||
code = int(parts[1])
|
||
if code == 407:
|
||
raise ProxyFatal("Anmeldung %s (407)" % ("abgelehnt – Benutzer/Kennwort falsch" if eff["user"]
|
||
else "erforderlich – Benutzer/Kennwort fehlt"))
|
||
if code != 200:
|
||
raise ProxyError("Proxy lehnt ab: %s" % status)
|
||
return rest
|
||
|
||
|
||
def proxy_connect(eff, host, port, timeout):
|
||
"""Baut über den Proxy einen Tunnel zu host:port. Liefert (socket, schon empfangene Daten)."""
|
||
sock = socket.create_connection((eff["host"], int(eff["port"])), timeout)
|
||
try:
|
||
if eff["type"] == "https":
|
||
ctx = ssl.create_default_context()
|
||
sock = ctx.wrap_socket(sock, server_hostname=eff["host"])
|
||
if eff["type"] in ("http", "https"):
|
||
rest = http_connect(sock, eff, host, port)
|
||
elif eff["type"] == "socks4":
|
||
rest = socks4_connect(sock, eff, host, port)
|
||
else:
|
||
rest = socks5_connect(sock, eff, host, port)
|
||
except BaseException:
|
||
sock.close()
|
||
raise
|
||
return sock, rest
|
||
|
||
|
||
def probe(sock, host, port, early=b"", timeout=TEST_TIMEOUT):
|
||
"""Prüft, ob über die Verbindung echte Daten fließen. Liefert (ok, text)."""
|
||
sock.settimeout(timeout)
|
||
prefix = ""
|
||
if port in TLS_PORTS:
|
||
ctx = ssl.create_default_context()
|
||
ctx.check_hostname = False
|
||
ctx.verify_mode = ssl.CERT_NONE # nur Erreichbarkeit testen, keine Zertifikatsprüfung
|
||
try:
|
||
try:
|
||
ipaddress.ip_address(host)
|
||
is_ip = True
|
||
except ValueError:
|
||
is_ip = False
|
||
sock = ctx.wrap_socket(sock, server_hostname=None if is_ip else host)
|
||
except (ssl.SSLError, OSError) as e:
|
||
return False, "TLS-Handshake fehlgeschlagen (%s)" % (getattr(e, "reason", None) or e)
|
||
prefix = "TLS ok, "
|
||
early = b""
|
||
if port in HTTP_PORTS:
|
||
sock.sendall(("HEAD / HTTP/1.0\r\nHost: %s\r\nUser-Agent: %s/%s\r\n\r\n"
|
||
% (host, PROG, VERSION)).encode())
|
||
data = early
|
||
try:
|
||
while b"\n" not in data and len(data) < 1024:
|
||
chunk = sock.recv(1024)
|
||
if not chunk:
|
||
break
|
||
data += chunk
|
||
except socket.timeout:
|
||
pass
|
||
except OSError as e:
|
||
return False, prefix + "Verbindung abgebrochen (%s)" % e
|
||
finally:
|
||
try:
|
||
sock.close()
|
||
except OSError:
|
||
pass
|
||
if not data:
|
||
return True, prefix + "verbunden (keine Begrüßung innerhalb %d s)" % timeout
|
||
line = data.split(b"\n", 1)[0].decode("utf-8", "replace").strip()
|
||
return True, prefix + "Antwort: " + (line[:70] or "(leer)")
|
||
|
||
|
||
def probe_direct(targets, timeout):
|
||
"""Wird im proxychains-Selbsttest im Kindprozess ausgeführt: normale Verbindungen
|
||
wie ein Programm ohne Proxy-Unterstützung – proxychains leitet sie um."""
|
||
rc = 0
|
||
for text in targets:
|
||
host, port = parse_target(text)
|
||
t0 = time.monotonic()
|
||
try:
|
||
sock = socket.create_connection((host, port), timeout)
|
||
ok, msg = probe(sock, host, port, timeout=timeout)
|
||
except (OSError, ProxyError) as e:
|
||
ok, msg = False, describe_error(e)
|
||
ms = (time.monotonic() - t0) * 1000
|
||
print("%s\t%s → %s (%d ms)" % ("OK" if ok else "FAIL", format_target(host, port), msg, ms))
|
||
rc |= 0 if ok else 1
|
||
return rc
|
||
|
||
|
||
def describe_error(e):
|
||
if isinstance(e, ProxyError):
|
||
return str(e)
|
||
if isinstance(e, socket.timeout):
|
||
return "Zeitüberschreitung"
|
||
if isinstance(e, ConnectionRefusedError):
|
||
return "Verbindung abgelehnt"
|
||
if isinstance(e, ConnectionResetError):
|
||
return "Verbindung zurückgesetzt"
|
||
if isinstance(e, socket.gaierror):
|
||
return "Name nicht auflösbar (%s)" % e.strerror
|
||
if isinstance(e, ssl.SSLError):
|
||
return "TLS-Fehler (%s)" % (e.reason or e)
|
||
return getattr(e, "strerror", None) or str(e)
|
||
|
||
|
||
def proxychains_selftest(pc_conf, targets, timeout):
|
||
try:
|
||
lib = find_proxychains_lib()
|
||
except ProxyError as e:
|
||
return [("fail", "proxychains-Test: %s" % e)]
|
||
fd, path = tempfile.mkstemp(prefix="svc-proxy-test-", suffix=".conf")
|
||
try:
|
||
with os.fdopen(fd, "w") as f:
|
||
f.write(pc_conf)
|
||
env = dict(os.environ, LD_PRELOAD=lib, PROXYCHAINS_CONF_FILE=path, PROXYCHAINS_QUIET_MODE="1")
|
||
cmd = [sys.executable, os.path.abspath(__file__), "_probe", str(timeout)]
|
||
cmd += [format_target(h, p) for h, p in targets]
|
||
try:
|
||
r = subprocess.run(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||
universal_newlines=True, timeout=timeout * (len(targets) + 1) + 10)
|
||
except subprocess.TimeoutExpired:
|
||
return [("fail", "proxychains-Test: Zeitüberschreitung")]
|
||
finally:
|
||
os.unlink(path)
|
||
results = []
|
||
for line in r.stdout.splitlines():
|
||
status, _, text = line.partition("\t")
|
||
results.append(("ok" if status == "OK" else "fail", "proxychains: " + text))
|
||
if not results:
|
||
err = (r.stderr.strip().splitlines() or ["keine Ausgabe"])[-1]
|
||
results.append(("fail", "proxychains-Test fehlgeschlagen: %s" % err))
|
||
return results
|
||
|
||
|
||
def run_proxy_test(eff, targets, cfg=None, unit=None, timeout=TEST_TIMEOUT):
|
||
"""Testet den Proxy. Liefert Liste von (status, text), status: ok | fail | info."""
|
||
results = [("info", "Proxy: %s%s" % (proxy_url(eff, mask=True), mode_suffix(eff)))]
|
||
t0 = time.monotonic()
|
||
try:
|
||
socket.create_connection((eff["host"], int(eff["port"])), timeout).close()
|
||
except socket.gaierror as e:
|
||
results.append(("fail", "Proxy-Host %s nicht auflösbar (%s)" % (eff["host"], e.strerror)))
|
||
return results
|
||
except ConnectionRefusedError:
|
||
results.append(("fail", "Proxy %s:%s lehnt die Verbindung ab – läuft dort ein Proxy auf diesem Port?"
|
||
% (eff["host"], eff["port"])))
|
||
return results
|
||
except socket.timeout:
|
||
results.append(("fail", "Proxy %s:%s antwortet nicht (Zeitüberschreitung) – Firewall?"
|
||
% (eff["host"], eff["port"])))
|
||
return results
|
||
except OSError as e:
|
||
results.append(("fail", "Proxy %s:%s nicht erreichbar: %s" % (eff["host"], eff["port"], describe_error(e))))
|
||
return results
|
||
results.append(("ok", "Proxy erreichbar: %s:%s (%d ms)"
|
||
% (eff["host"], eff["port"], (time.monotonic() - t0) * 1000)))
|
||
|
||
for host, port in targets:
|
||
t0 = time.monotonic()
|
||
label = format_target(host, port)
|
||
try:
|
||
sock, early = proxy_connect(eff, host, port, timeout)
|
||
ok, msg = probe(sock, host, port, early, timeout)
|
||
except ProxyFatal as e:
|
||
results.append(("fail", "%s → %s" % (label, e)))
|
||
return results
|
||
except (OSError, ProxyError) as e:
|
||
ok, msg = False, describe_error(e)
|
||
ms = (time.monotonic() - t0) * 1000
|
||
results.append(("ok" if ok else "fail", "%s → %s (%d ms)" % (label, msg, ms)))
|
||
|
||
if eff["mode"] == "proxychains":
|
||
try:
|
||
conf = (cfg or Config(DEFAULT_CONFIG_DIR, DEFAULT_SYSTEMD_DIR)).render_proxychains(unit or "test", eff)
|
||
except ProxyError as e:
|
||
results.append(("fail", "proxychains-Test: %s" % e))
|
||
else:
|
||
results.append(("info", "Test über proxychains (so verbindet der Dienst):"))
|
||
results += proxychains_selftest(conf, targets, timeout)
|
||
return results
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 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 Entfernen "
|
||
"t Test / 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 in ("t", "T") and n:
|
||
self.test_service(self.visible[self.sel])
|
||
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 busy(self, text):
|
||
win, bh, bw = self.box("Bitte warten", [text], height_extra=0)
|
||
put(win, 1, 2, text, curses.A_BOLD, bw - 4)
|
||
win.refresh()
|
||
del win
|
||
|
||
def prompt(self, title, label, value=""):
|
||
"""Einzeilige Eingabe in einem Dialog. Liefert Text oder None (Esc)."""
|
||
while True:
|
||
win, bh, bw = self.box(title, [label, " " * 50])
|
||
put(win, 1, 2, label, 0, bw - 4)
|
||
field_w = bw - 4
|
||
vis = value[-(field_w - 1):]
|
||
put(win, 2, 2, vis.ljust(field_w), self.c_sel)
|
||
put(win, bh - 2, 2, "Enter übernehmen Esc abbrechen", self.c_dim)
|
||
self.cursor(True)
|
||
try:
|
||
win.move(2, 2 + len(vis))
|
||
except curses.error:
|
||
pass
|
||
win.refresh()
|
||
k = win.get_wch()
|
||
del win
|
||
if is_enter(k):
|
||
self.cursor(False)
|
||
self.scr.touchwin()
|
||
return value
|
||
if k == "\x1b":
|
||
self.cursor(False)
|
||
self.scr.touchwin()
|
||
return None
|
||
if is_backspace(k):
|
||
value = value[:-1]
|
||
elif k == "\x15":
|
||
value = ""
|
||
elif isinstance(k, str) and k.isprintable():
|
||
value += k
|
||
|
||
def choose_targets(self):
|
||
lines = [
|
||
"Welche Ziele sollen über den Proxy getestet werden?",
|
||
"",
|
||
" w Web: " + ", ".join(TEST_TARGETS_WEB),
|
||
" m Mail: SMTP 25/587/465, POP3S 995, IMAPS 993 (Gmail)",
|
||
" b beides",
|
||
" e eigenes Ziel (HOST:PORT, mehrere mit Komma)",
|
||
]
|
||
win, bh, bw = self.box("Verbindungstest", lines)
|
||
for i, l in enumerate(lines):
|
||
put(win, 1 + i, 2, l, 0, bw - 4)
|
||
put(win, bh - 2, 2, "w / m / b / e wählen, Esc abbrechen", self.c_dim)
|
||
win.refresh()
|
||
while True:
|
||
k = win.get_wch()
|
||
if k in ("w", "W", "\n", "\r", curses.KEY_ENTER):
|
||
names = TEST_TARGETS_WEB
|
||
elif k in ("m", "M"):
|
||
names = TEST_TARGETS_MAIL
|
||
elif k in ("b", "B"):
|
||
names = TEST_TARGETS_WEB + TEST_TARGETS_MAIL
|
||
elif k in ("e", "E"):
|
||
del win
|
||
self.scr.touchwin()
|
||
text = self.prompt("Eigenes Ziel", "Ziel(e) als HOST:PORT, z.B. mail.example.com:25",
|
||
getattr(self, "last_target", ""))
|
||
if not text:
|
||
return None
|
||
self.last_target = text
|
||
names = [t for t in text.split(",") if t.strip()]
|
||
elif k in ("\x1b", "q"):
|
||
del win
|
||
self.scr.touchwin()
|
||
return None
|
||
else:
|
||
continue
|
||
try:
|
||
return [parse_target(t) for t in names]
|
||
except ProxyError as e:
|
||
self.message("Fehler", [str(e)], error=True)
|
||
return None
|
||
|
||
def run_test(self, eff, unit, title):
|
||
targets = self.choose_targets()
|
||
if not targets:
|
||
return
|
||
self.busy("Verbindungstest läuft ... (bis zu %d s pro Ziel)" % TEST_TIMEOUT)
|
||
results = run_proxy_test(eff, targets, self.cfg, unit)
|
||
failed = sum(1 for st, _ in results if st == "fail")
|
||
results.append(("info", ""))
|
||
results.append(("ok" if not failed else "fail",
|
||
"Alles in Ordnung." if not failed else "%d Test(s) fehlgeschlagen." % failed))
|
||
self.results_box(title, results)
|
||
|
||
def results_box(self, title, results):
|
||
lines = [("[ OK ] " if st == "ok" else "[FEHL] " if st == "fail" else " ") + text
|
||
for st, text in results]
|
||
win, bh, bw = self.box(title, lines)
|
||
for i, (line, (st, _)) in enumerate(list(zip(lines, results))[:bh - 4]):
|
||
attr = self.c_ok if st == "ok" else self.c_err if st == "fail" else curses.A_BOLD
|
||
put(win, 1 + i, 2, line, attr, bw - 4)
|
||
put(win, bh - 2, 2, "[ OK – beliebige Taste ]", curses.A_REVERSE)
|
||
win.refresh()
|
||
win.get_wch()
|
||
del win
|
||
self.scr.touchwin()
|
||
|
||
def test_service(self, unit):
|
||
svc = self.cfg.services.get(unit)
|
||
try:
|
||
eff, _ = resolve(svc or empty_settings(), self.cfg.global_)
|
||
except ProxyError as e:
|
||
self.message("Verbindungstest", [str(e)], error=True)
|
||
return
|
||
title = "Test %s%s" % (unit, "" if svc else " (globaler Proxy)")
|
||
self.run_test(eff, unit if svc else None, title)
|
||
|
||
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, t Verbindungstest,",
|
||
"F5 Liste neu laden, q Ende. Im Formular: F3 testet die eingegebenen Werte.",
|
||
"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("test", "Testen", "", 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 F3 Testen Strg+U 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 == curses.KEY_F3:
|
||
action = "test"
|
||
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 == "test":
|
||
self.cursor(False)
|
||
try:
|
||
cand = validate_settings(collect(), is_global)
|
||
eff, _ = resolve(empty_settings() if is_global else cand, cand if is_global else g)
|
||
except ProxyError as e:
|
||
error = "Test nicht möglich: %s" % e
|
||
continue
|
||
self.run_test(eff, unit, "Test %s (ungespeicherte Werte)" % (unit or "globaler Proxy"))
|
||
error = ""
|
||
continue
|
||
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 print_test_results(results):
|
||
marks = {"ok": "[ OK ] ", "fail": "[FEHLER]", "info": " "}
|
||
for status, text in results:
|
||
print("%s %s" % (marks[status], text))
|
||
failed = sum(1 for st, _ in results if st == "fail")
|
||
print()
|
||
print("Ergebnis: alles in Ordnung." if not failed else "Ergebnis: %d Test(s) fehlgeschlagen." % failed)
|
||
return 1 if failed else 0
|
||
|
||
|
||
def test_targets(args):
|
||
targets = [parse_target(t) for t in (args.target or [])]
|
||
if args.mail:
|
||
targets += [parse_target(t) for t in TEST_TARGETS_MAIL]
|
||
if not targets:
|
||
targets = [parse_target(t) for t in TEST_TARGETS_WEB]
|
||
return targets
|
||
|
||
|
||
def cmd_test(cfg, args):
|
||
vals = fields_from_args(args)
|
||
targets = test_targets(args)
|
||
if args.unit:
|
||
unit = normalize_unit(args.unit)
|
||
base = dict(cfg.services.get(unit) or empty_settings())
|
||
base.update(vals)
|
||
svc = validate_settings(base, is_global=False)
|
||
what = "Dienst %s%s" % (unit, "" if unit in cfg.services else " (nicht konfiguriert → globaler Proxy)")
|
||
else:
|
||
unit = None
|
||
svc = validate_settings(vals, is_global=False)
|
||
what = "angegebener Proxy" if any(svc[k] for k in FIELDS) else "globaler Proxy"
|
||
eff, _ = resolve(svc, cfg.global_)
|
||
print("Verbindungstest: %s" % what)
|
||
return print_test_results(run_proxy_test(eff, targets, cfg, unit, args.timeout))
|
||
|
||
|
||
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])
|
||
p = sub.add_parser("test", add_help=False, parents=[c, fields])
|
||
p.add_argument("unit", nargs="?")
|
||
p.add_argument("--target", action="append", metavar="HOST:PORT")
|
||
p.add_argument("--mail", action="store_true")
|
||
p.add_argument("--timeout", type=float, default=TEST_TIMEOUT)
|
||
return parser
|
||
|
||
|
||
def main(argv=None):
|
||
argv = sys.argv[1:] if argv is None else argv
|
||
if argv and argv[0] == "_probe": # intern: Kindprozess des proxychains-Selbsttests
|
||
return probe_direct(argv[2:], float(argv[1]))
|
||
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)
|
||
# 'test' mit eigenem --proxy/--host geht auch ohne root (ohne gespeicherte Konfiguration)
|
||
adhoc_test = (command == "test" and not args.unit and (args.proxy or args.host))
|
||
if os.geteuid() != 0 and not custom_dirs and not adhoc_test:
|
||
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:
|
||
try:
|
||
cfg.load()
|
||
except ProxyError:
|
||
if not adhoc_test:
|
||
raise
|
||
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, "test": cmd_test,
|
||
}[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())
|