diff --git a/.gitignore b/.gitignore index 92df407..537b28e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ -*.ovpn -*.log -*.png -*.jpg -*.jpeg -*.zip -*.7z +*.ovpn +*.log +*.png +*.jpg +*.jpeg +*.zip +*.7z + +vpn_config.json +__pycache__/ diff --git a/init_vpn.py b/init_vpn.py index 8e44003..2bcd0e8 100644 --- a/init_vpn.py +++ b/init_vpn.py @@ -1,10 +1,24 @@ #!/usr/bin/env python3 -"""setup_keyring.py — одноразово кладёт secret и static в keyring из env.""" +"""setup_keyring.py — одноразово кладёт secret и static в keyring из env. +Имя сервиса keyring берётся из общего vpn_config.json (см. launch_vpn_gui.py).""" import os import sys +import json import keyring -SERVICE = "openvpn-totp" +CONFIG_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "vpn_config.json") +DEFAULT_SERVICE = "openvpn-totp" + +def keyring_service(): + try: + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + return data.get("keyring_service", DEFAULT_SERVICE) + except (OSError, ValueError): + pass + return DEFAULT_SERVICE def main(): secret = os.environ.get("OVPN_TOTP_SECRET") @@ -16,9 +30,10 @@ def main(): if missing: sys.exit(f"Не заданы переменные окружения: {', '.join(missing)}") - keyring.set_password(SERVICE, "secret", secret) - keyring.set_password(SERVICE, "static", static) - print("Сохранено в keyring.") + service = keyring_service() + keyring.set_password(service, "secret", secret) + keyring.set_password(service, "static", static) + print(f"Сохранено в keyring (сервис: {service}).") if __name__ == "__main__": main() diff --git a/launch_vpn.py b/launch_vpn.py index dcd4e6c..9bd3855 100644 --- a/launch_vpn.py +++ b/launch_vpn.py @@ -1,20 +1,40 @@ #!/usr/bin/env python3 -"""launch_vpn.py — auth = static+TOTP, конкатенация без разделителя.""" -import subprocess, pyotp, keyring, tempfile, os +"""launch_vpn.py — auth = static+TOTP, конкатенация без разделителя. +Параметры берутся из общего vpn_config.json (см. launch_vpn_gui.py); +секреты — из keyring.""" +import subprocess, pyotp, keyring, tempfile, json, os -USERNAME = "boyko_vn" -OVPN_CONFIG = r"C:\Users\User\app\vpn\ovpn-config-ano.ovpn" -OPENVPN_BIN = r"C:\Program Files\OpenVPN\bin\openvpn.exe" +CONFIG_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "vpn_config.json") +DEFAULTS = { + "username": "boyko_vn", + "ovpn_config": r"C:\Users\User\app\vpn\ovpn-config-ano.ovpn", + "openvpn_bin": r"C:\Program Files\OpenVPN\bin\openvpn.exe", + "keyring_service": "openvpn-totp", +} + +def load_config(): + cfg = dict(DEFAULTS) + try: + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + cfg.update(data) + except (OSError, ValueError): + pass + return cfg def main(): - secret = keyring.get_password("openvpn-totp", "secret") - static = keyring.get_password("openvpn-totp", "static") + cfg = load_config() + service = cfg.get("keyring_service", "openvpn-totp") + secret = keyring.get_password(service, "secret") + static = keyring.get_password(service, "static") code = pyotp.TOTP(secret).now() fd, path = tempfile.mkstemp(suffix=".txt") try: with os.fdopen(fd, "w", newline="\n") as f: - f.write(f"{USERNAME}\n{static}{code}\n") - subprocess.run([OPENVPN_BIN, "--config", OVPN_CONFIG, + f.write(f"{cfg['username']}\n{static}{code}\n") + subprocess.run([cfg["openvpn_bin"], "--config", cfg["ovpn_config"], "--auth-user-pass", path]) finally: os.remove(path) diff --git a/launch_vpn_gui.py b/launch_vpn_gui.py index 6ab9876..a10e6ca 100644 --- a/launch_vpn_gui.py +++ b/launch_vpn_gui.py @@ -5,29 +5,136 @@ import subprocess import time import threading import tempfile +import json import os import pyotp import keyring import pystray from PIL import Image, ImageDraw -USERNAME = "boyko_vn" -OVPN_CONFIG = r"C:\Users\User\app\vpn\ovpn-config-ano.ovpn" -OPENVPN_BIN = r"C:\Program Files\OpenVPN\bin\openvpn.exe" -DEV_NODE = "OpenVPN Data Channel Offload" -SERVICE = "openvpn-totp" -LOG_PATH = r"ovpn.log" +# --- Настройки ----------------------------------------------------------------- +# Все параметры лежат в JSON-файле рядом со скриптом. Правится вручную (пункт +# меню "Открыть настройки"); перечитывается автоматически при изменении. +# Секреты (TOTP secret и статический пароль) здесь НЕ хранятся — они лежат в +# keyring (Windows Credential Manager) под именем сервиса keyring_service; +# кладёт их init_vpn.py. +CONFIG_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "vpn_config.json") +DEFAULT_CONFIG = { + # --- параметры подключения / авторизации --- + # имя пользователя VPN. + "username": "boyko_vn", + # путь к .ovpn-профилю. + "ovpn_config": r"C:\Users\User\app\vpn\ovpn-config-ano.ovpn", + # путь к openvpn.exe. + "openvpn_bin": r"C:\Program Files\OpenVPN\bin\openvpn.exe", + # имя dev-node адаптера OpenVPN. + "dev_node": "OpenVPN Data Channel Offload", + # имя сервиса в keyring, откуда берутся secret (TOTP) и static (пароль). + "keyring_service": "openvpn-totp", + # путь к лог-файлу openvpn. + "log_path": "ovpn.log", + + # --- проверка связи --- + # включить проверку доступности сервера пингами. + # False -> работает как раньше: серый (выкл) / зелёный (вкл), без жёлтого. + "ping_check_enabled": True, + # IP или хост, доступность которого проверяем (обычно шлюз VPN-сети). + "ping_host": "10.8.0.1", + # интервал между проверками, сек. + "ping_interval": 5, + # таймаут одного пинга, мс. + "ping_timeout_ms": 1000, +} + +config = dict(DEFAULT_CONFIG) +_config_mtime = None + +# Три состояния индикатора в трее. +STATE_OFF = "off" # серый — процесс openvpn не запущен +STATE_WAIT = "wait" # жёлтый — процесс запущен, но пинги до сервера не доходят +STATE_ON = "on" # зелёный — процесс запущен, пинги есть + +STATE_COLORS = { + STATE_OFF: (130, 130, 130, 255), + STATE_WAIT: (230, 200, 40, 255), + STATE_ON: (40, 200, 80, 255), +} +STATE_TITLES = { + STATE_OFF: "VPN: отключено", + STATE_WAIT: "VPN: запущен, нет связи с сервером", + STATE_ON: "VPN: подключено", +} proc = None # текущий процесс openvpn proc_lock = threading.Lock() icon = None +ping_alive = False # результат последней проверки пингов -def make_icon(connected: bool) -> Image.Image: - """Кружок: зелёный = подключено, серый = отключено.""" +def load_config(): + """Загрузить настройки из JSON, дополнив дефолтами. Молча создаёт файл, + если его нет, и падает обратно на дефолты при ошибке чтения.""" + global _config_mtime + try: + mtime = os.path.getmtime(CONFIG_PATH) + except OSError: + # файла нет — создаём с дефолтами + try: + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(DEFAULT_CONFIG, f, ensure_ascii=False, indent=2) + _config_mtime = os.path.getmtime(CONFIG_PATH) + except OSError: + _config_mtime = None + config.clear() + config.update(DEFAULT_CONFIG) + return + if mtime == _config_mtime: + return # файл не менялся — ничего не делаем + try: + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + data = json.load(f) + except (OSError, ValueError): + data = {} + merged = dict(DEFAULT_CONFIG) + if isinstance(data, dict): + merged.update(data) + config.clear() + config.update(merged) + _config_mtime = mtime + + +def ping_ok(host: str) -> bool: + """Один ICMP-пинг до host. Возвращает True только при реальном ответе + (наличие 'TTL=' в выводе), чтобы не считать 'Destination unreachable' + успехом — Windows-ping в этом случае тоже отдаёт код возврата 0.""" + if not host: + return False + try: + r = subprocess.run( + ["ping", "-n", "1", "-w", str(config["ping_timeout_ms"]), host], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + return b"TTL=" in r.stdout.upper() + except Exception: + return False + + +def current_state() -> str: + """Текущее состояние индикатора с учётом настроек.""" + if not is_running(): + return STATE_OFF + if not config.get("ping_check_enabled", True): + return STATE_ON # проверки выключены — старое поведение + return STATE_ON if ping_alive else STATE_WAIT + + +def make_icon(state: str) -> Image.Image: + """Кружок: серый = выкл, жёлтый = нет связи, зелёный = связь есть.""" img = Image.new("RGBA", (64, 64), (0, 0, 0, 0)) d = ImageDraw.Draw(img) - color = (40, 200, 80, 255) if connected else (130, 130, 130, 255) + color = STATE_COLORS.get(state, STATE_COLORS[STATE_OFF]) d.ellipse((12, 12, 52, 52), fill=color) return img @@ -35,9 +142,9 @@ def make_icon(connected: bool) -> Image.Image: def update_icon(): if icon is None: return - connected = is_running() - icon.icon = make_icon(connected) - icon.title = "VPN: подключено" if connected else "VPN: отключено" + state = current_state() + icon.icon = make_icon(state) + icon.title = STATE_TITLES[state] icon.update_menu() # перерисовать тексты/доступность пунктов @@ -47,12 +154,13 @@ def is_running() -> bool: def build_auth_file() -> str: - secret = keyring.get_password(SERVICE, "secret") - static = keyring.get_password(SERVICE, "static") + service = config.get("keyring_service", "openvpn-totp") + secret = keyring.get_password(service, "secret") + static = keyring.get_password(service, "static") code = pyotp.TOTP(secret).now() fd, path = tempfile.mkstemp(suffix=".txt") with os.fdopen(fd, "w", newline="\n") as f: - f.write(f"{USERNAME}\n{static}{code}\n") + f.write(f"{config['username']}\n{static}{code}\n") return path @@ -63,10 +171,10 @@ def run_vpn(): try: logf = open(rf"ovpn-wrapper.log", "a", encoding="utf-8") p = subprocess.Popen( - [OPENVPN_BIN, "--config", OVPN_CONFIG, + [config["openvpn_bin"], "--config", config["ovpn_config"], "--auth-user-pass", auth_path, - "--dev-node", DEV_NODE, - "--log", LOG_PATH, + "--dev-node", config["dev_node"], + "--log", config["log_path"], "--verb", "4"], stdout=logf, stderr=logf, creationflags=subprocess.CREATE_NO_WINDOW, @@ -101,7 +209,7 @@ def on_disconnect(_icon, _item): def on_open_log(_icon, _item): """Открыть файл лога в редакторе по умолчанию; иначе — в блокноте.""" - log_abs = os.path.abspath(LOG_PATH) + log_abs = os.path.abspath(config.get("log_path", "ovpn.log")) # создаём пустой файл, если лога ещё нет, чтобы было что открывать if not os.path.exists(log_abs): try: @@ -120,6 +228,22 @@ def on_open_log(_icon, _item): subprocess.Popen(["notepad.exe", log_abs]) +def on_open_settings(_icon, _item): + """Открыть файл настроек в редакторе; создаёт его при отсутствии.""" + load_config() # гарантирует, что файл существует + try: + os.startfile(CONFIG_PATH, "edit") + except OSError: + try: + os.startfile(CONFIG_PATH) + except OSError: + subprocess.Popen(["notepad.exe", CONFIG_PATH]) + + +def _status_text(_item): + return STATE_TITLES[current_state()] + + def on_exit(_icon, _item): on_disconnect(_icon, _item) _icon.stop() @@ -127,10 +251,9 @@ def on_exit(_icon, _item): def menu(): return pystray.Menu( - pystray.MenuItem( - lambda item: "Отключено" if not is_running() else "Подключено", - None, enabled=False), + pystray.MenuItem(_status_text, None, enabled=False), pystray.MenuItem("Открыть лог", on_open_log), + pystray.MenuItem("Открыть настройки", on_open_settings), pystray.Menu.SEPARATOR, pystray.MenuItem("Подключить", on_connect, enabled=lambda item: not is_running()), @@ -143,13 +266,26 @@ def menu(): def watcher(): + """Следит за состоянием процесса и связью с сервером, обновляя иконку + только при смене итогового состояния (серый/жёлтый/зелёный).""" + global ping_alive last = None while True: - cur = is_running() - if cur != last: + load_config() # подхватываем правки настроек на лету + if is_running() and config.get("ping_check_enabled", True): + ping_alive = ping_ok(config.get("ping_host", "")) + else: + ping_alive = False + state = current_state() + if state != last: update_icon() - last = cur - time.sleep(1) + last = state + # пока идёт активная проверка пингов — ждём интервал, иначе опрашиваем + # процесс раз в секунду для отзывчивого серо/зелёного переключения + if is_running() and config.get("ping_check_enabled", True): + time.sleep(max(1, int(config.get("ping_interval", 5)))) + else: + time.sleep(1) def ensure_admin_old(): @@ -181,8 +317,10 @@ def ensure_admin(): def main(): ensure_admin() + load_config() global icon - icon = pystray.Icon("vpn-totp", make_icon(False), "VPN: отключено", menu()) + icon = pystray.Icon( + "vpn-totp", make_icon(STATE_OFF), STATE_TITLES[STATE_OFF], menu()) threading.Thread(target=watcher, daemon=True).start() icon.run() diff --git a/vpn_config.example.json b/vpn_config.example.json new file mode 100644 index 0000000..adf14a5 --- /dev/null +++ b/vpn_config.example.json @@ -0,0 +1,13 @@ +{ + "username": "login", + "ovpn_config": "C:\\Users\\User\\app\\vpn\\ovpn-config-ano.ovpn", + "openvpn_bin": "C:\\Program Files\\OpenVPN\\bin\\openvpn.exe", + "dev_node": "OpenVPN Data Channel Offload", + "keyring_service": "openvpn-totp", + "log_path": "ovpn.log", + + "ping_check_enabled": true, + "ping_host": "10.8.0.1", + "ping_interval": 5, + "ping_timeout_ms": 1000 +}