check with pings

This commit is contained in:
boyko_vn 2026-07-08 00:03:14 +03:00
parent 6c8d5982d0
commit 7aab89576e
5 changed files with 237 additions and 48 deletions

17
.gitignore vendored
View File

@ -1,7 +1,10 @@
*.ovpn *.ovpn
*.log *.log
*.png *.png
*.jpg *.jpg
*.jpeg *.jpeg
*.zip *.zip
*.7z *.7z
vpn_config.json
__pycache__/

View File

@ -1,10 +1,24 @@
#!/usr/bin/env python3 #!/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 os
import sys import sys
import json
import keyring 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(): def main():
secret = os.environ.get("OVPN_TOTP_SECRET") secret = os.environ.get("OVPN_TOTP_SECRET")
@ -16,9 +30,10 @@ def main():
if missing: if missing:
sys.exit(f"Не заданы переменные окружения: {', '.join(missing)}") sys.exit(f"Не заданы переменные окружения: {', '.join(missing)}")
keyring.set_password(SERVICE, "secret", secret) service = keyring_service()
keyring.set_password(SERVICE, "static", static) keyring.set_password(service, "secret", secret)
print("Сохранено в keyring.") keyring.set_password(service, "static", static)
print(f"Сохранено в keyring (сервис: {service}).")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -1,20 +1,40 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""launch_vpn.py — auth = static+TOTP, конкатенация без разделителя.""" """launch_vpn.py — auth = static+TOTP, конкатенация без разделителя.
import subprocess, pyotp, keyring, tempfile, os Параметры берутся из общего vpn_config.json (см. launch_vpn_gui.py);
секреты из keyring."""
import subprocess, pyotp, keyring, tempfile, json, os
USERNAME = "boyko_vn" CONFIG_PATH = os.path.join(
OVPN_CONFIG = r"C:\Users\User\app\vpn\ovpn-config-ano.ovpn" os.path.dirname(os.path.abspath(__file__)), "vpn_config.json")
OPENVPN_BIN = r"C:\Program Files\OpenVPN\bin\openvpn.exe" 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(): def main():
secret = keyring.get_password("openvpn-totp", "secret") cfg = load_config()
static = keyring.get_password("openvpn-totp", "static") service = cfg.get("keyring_service", "openvpn-totp")
secret = keyring.get_password(service, "secret")
static = keyring.get_password(service, "static")
code = pyotp.TOTP(secret).now() code = pyotp.TOTP(secret).now()
fd, path = tempfile.mkstemp(suffix=".txt") fd, path = tempfile.mkstemp(suffix=".txt")
try: try:
with os.fdopen(fd, "w", newline="\n") as f: with os.fdopen(fd, "w", newline="\n") as f:
f.write(f"{USERNAME}\n{static}{code}\n") f.write(f"{cfg['username']}\n{static}{code}\n")
subprocess.run([OPENVPN_BIN, "--config", OVPN_CONFIG, subprocess.run([cfg["openvpn_bin"], "--config", cfg["ovpn_config"],
"--auth-user-pass", path]) "--auth-user-pass", path])
finally: finally:
os.remove(path) os.remove(path)

View File

@ -5,29 +5,136 @@ import subprocess
import time import time
import threading import threading
import tempfile import tempfile
import json
import os import os
import pyotp import pyotp
import keyring import keyring
import pystray import pystray
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
USERNAME = "boyko_vn" # --- Настройки -----------------------------------------------------------------
OVPN_CONFIG = r"C:\Users\User\app\vpn\ovpn-config-ano.ovpn" # Все параметры лежат в JSON-файле рядом со скриптом. Правится вручную (пункт
OPENVPN_BIN = r"C:\Program Files\OpenVPN\bin\openvpn.exe" # меню "Открыть настройки"); перечитывается автоматически при изменении.
DEV_NODE = "OpenVPN Data Channel Offload" # Секреты (TOTP secret и статический пароль) здесь НЕ хранятся — они лежат в
SERVICE = "openvpn-totp" # keyring (Windows Credential Manager) под именем сервиса keyring_service;
LOG_PATH = r"ovpn.log" # кладёт их 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 = None # текущий процесс openvpn
proc_lock = threading.Lock() proc_lock = threading.Lock()
icon = None 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)) img = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
d = ImageDraw.Draw(img) 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) d.ellipse((12, 12, 52, 52), fill=color)
return img return img
@ -35,9 +142,9 @@ def make_icon(connected: bool) -> Image.Image:
def update_icon(): def update_icon():
if icon is None: if icon is None:
return return
connected = is_running() state = current_state()
icon.icon = make_icon(connected) icon.icon = make_icon(state)
icon.title = "VPN: подключено" if connected else "VPN: отключено" icon.title = STATE_TITLES[state]
icon.update_menu() # перерисовать тексты/доступность пунктов icon.update_menu() # перерисовать тексты/доступность пунктов
@ -47,12 +154,13 @@ def is_running() -> bool:
def build_auth_file() -> str: def build_auth_file() -> str:
secret = keyring.get_password(SERVICE, "secret") service = config.get("keyring_service", "openvpn-totp")
static = keyring.get_password(SERVICE, "static") secret = keyring.get_password(service, "secret")
static = keyring.get_password(service, "static")
code = pyotp.TOTP(secret).now() code = pyotp.TOTP(secret).now()
fd, path = tempfile.mkstemp(suffix=".txt") fd, path = tempfile.mkstemp(suffix=".txt")
with os.fdopen(fd, "w", newline="\n") as f: 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 return path
@ -63,10 +171,10 @@ def run_vpn():
try: try:
logf = open(rf"ovpn-wrapper.log", "a", encoding="utf-8") logf = open(rf"ovpn-wrapper.log", "a", encoding="utf-8")
p = subprocess.Popen( p = subprocess.Popen(
[OPENVPN_BIN, "--config", OVPN_CONFIG, [config["openvpn_bin"], "--config", config["ovpn_config"],
"--auth-user-pass", auth_path, "--auth-user-pass", auth_path,
"--dev-node", DEV_NODE, "--dev-node", config["dev_node"],
"--log", LOG_PATH, "--log", config["log_path"],
"--verb", "4"], "--verb", "4"],
stdout=logf, stderr=logf, stdout=logf, stderr=logf,
creationflags=subprocess.CREATE_NO_WINDOW, creationflags=subprocess.CREATE_NO_WINDOW,
@ -101,7 +209,7 @@ def on_disconnect(_icon, _item):
def on_open_log(_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): if not os.path.exists(log_abs):
try: try:
@ -120,6 +228,22 @@ def on_open_log(_icon, _item):
subprocess.Popen(["notepad.exe", log_abs]) 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): def on_exit(_icon, _item):
on_disconnect(_icon, _item) on_disconnect(_icon, _item)
_icon.stop() _icon.stop()
@ -127,10 +251,9 @@ def on_exit(_icon, _item):
def menu(): def menu():
return pystray.Menu( return pystray.Menu(
pystray.MenuItem( pystray.MenuItem(_status_text, None, enabled=False),
lambda item: "Отключено" if not is_running() else "Подключено",
None, enabled=False),
pystray.MenuItem("Открыть лог", on_open_log), pystray.MenuItem("Открыть лог", on_open_log),
pystray.MenuItem("Открыть настройки", on_open_settings),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem("Подключить", on_connect, pystray.MenuItem("Подключить", on_connect,
enabled=lambda item: not is_running()), enabled=lambda item: not is_running()),
@ -143,13 +266,26 @@ def menu():
def watcher(): def watcher():
"""Следит за состоянием процесса и связью с сервером, обновляя иконку
только при смене итогового состояния (серый/жёлтый/зелёный)."""
global ping_alive
last = None last = None
while True: while True:
cur = is_running() load_config() # подхватываем правки настроек на лету
if cur != last: 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() update_icon()
last = cur last = state
time.sleep(1) # пока идёт активная проверка пингов — ждём интервал, иначе опрашиваем
# процесс раз в секунду для отзывчивого серо/зелёного переключения
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(): def ensure_admin_old():
@ -181,8 +317,10 @@ def ensure_admin():
def main(): def main():
ensure_admin() ensure_admin()
load_config()
global icon 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() threading.Thread(target=watcher, daemon=True).start()
icon.run() icon.run()

13
vpn_config.example.json Normal file
View File

@ -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
}