check with pings
This commit is contained in:
parent
6c8d5982d0
commit
7aab89576e
17
.gitignore
vendored
17
.gitignore
vendored
@ -1,7 +1,10 @@
|
|||||||
*.ovpn
|
*.ovpn
|
||||||
*.log
|
*.log
|
||||||
*.png
|
*.png
|
||||||
*.jpg
|
*.jpg
|
||||||
*.jpeg
|
*.jpeg
|
||||||
*.zip
|
*.zip
|
||||||
*.7z
|
*.7z
|
||||||
|
|
||||||
|
vpn_config.json
|
||||||
|
__pycache__/
|
||||||
|
|||||||
25
init_vpn.py
25
init_vpn.py
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
13
vpn_config.example.json
Normal 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
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user