- choose_candidates: кандидату назначается его собственный протокол из scheme, а не общий выбранный — иначе TUIC/Naive-ссылки собирались как hysteria2 и не работали - verify_tunnel: SSH-форвард строится через rt.ssh_args() — теперь передаются --identity/sshpass (раньше проверка падала с нестандартным именем ключа) - BatchMode=yes только без пароля (с sshpass он ломал авторизацию) - stop_service: добивает sing-box/ipt2socks вне procd — иначе Text file busy при перезаливке бинарников
1009 lines
40 KiB
Python
1009 lines
40 KiB
Python
#!/usr/bin/env -S uv run --script
|
||
# /// script
|
||
# requires-python = ">=3.9"
|
||
# dependencies = []
|
||
# ///
|
||
# =============================================================================
|
||
# NikoVPN — контроллер для OpenWRT (замена install.sh, запуск с ПК)
|
||
#
|
||
# uv run nikovpn.py [--host 192.168.1.1] install
|
||
# uv run nikovpn.py update | status | uninstall
|
||
#
|
||
# Управляет роутером по SSH: определяет архитектуру, заливает бинарники
|
||
# (локальные bin/ или f.n1ko.dev), спрашивает API vpnd.n1ko.dev, выбирает
|
||
# протокол/сервер (hysteria2 > tuic > naive > vless > shadowsocks),
|
||
# генерирует конфиг sing-box и поднимает прозрачный прокси.
|
||
#
|
||
# Креды VPN (login:password):
|
||
# --creds U:P | --creds-file F | env NIKOVPN_CREDS | /root/creds на роутере
|
||
#
|
||
# На роутере требуется только busybox + wget/curl + nft/iptables.
|
||
# =============================================================================
|
||
|
||
import argparse
|
||
import base64
|
||
import json
|
||
import os
|
||
import re
|
||
import shlex
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
from pathlib import Path
|
||
from urllib.parse import parse_qs, unquote, urlsplit
|
||
|
||
BASE_URL = "https://f.n1ko.dev/nikovpn_openwrt"
|
||
API_URL = "https://vpnd.n1ko.dev/api/v1"
|
||
WORK_DIR = "/etc/nikovpn"
|
||
CREDS_FILE = "/root/creds"
|
||
SB_PATH = "/usr/bin/sing-box"
|
||
IPT_PATH = "/usr/bin/ipt2socks"
|
||
SOCKS_PORT = "1080"
|
||
TP_PORT = "12345"
|
||
RULE_TABLE = "100"
|
||
FW_MARK = "0x1"
|
||
|
||
PRIVATE_CIDRS = ["0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8",
|
||
"169.254.0.0/16", "172.16.0.0/12", "192.168.0.0/16",
|
||
"224.0.0.0/4", "240.0.0.0/4"]
|
||
|
||
log = lambda m: print(f"[*] {m}", flush=True)
|
||
warn = lambda m: print(f"[!] {m}", flush=True)
|
||
|
||
|
||
def die(m):
|
||
print(f"[ERROR] {m}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
|
||
# ============================================================================
|
||
# SSH-транспорт
|
||
# ============================================================================
|
||
class Router:
|
||
def __init__(self, host, user="root", port=22, password=None, key=None):
|
||
self.host, self.user, self.port = host, user, port
|
||
opts = ["-o", "StrictHostKeyChecking=accept-new",
|
||
"-o", "ConnectTimeout=10"]
|
||
pre = []
|
||
if password is not None:
|
||
if not shutil_which("sshpass"):
|
||
die("для парольного входа нужен sshpass")
|
||
pre = ["sshpass", "-p", password]
|
||
opts += ["-o", "PreferredAuthentications=password",
|
||
"-o", "PubkeyAuthentication=no"]
|
||
else:
|
||
opts += ["-o", "BatchMode=yes"]
|
||
if key:
|
||
opts += ["-i", str(key)]
|
||
self.pre, self.opts = pre, opts
|
||
self.base = pre + ["ssh", "-p", str(port)] + opts + [f"{user}@{host}"]
|
||
|
||
def ssh_args(self, *extra):
|
||
return self.pre + ["ssh", "-p", str(self.port)] + self.opts + list(extra)
|
||
|
||
def run(self, cmd, timeout=180, input_bytes=None, check=False):
|
||
r = subprocess.run(self.base + [cmd], capture_output=True,
|
||
timeout=timeout,
|
||
input=input_bytes if input_bytes is not None else b"")
|
||
if check and r.returncode != 0:
|
||
raise RuntimeError(
|
||
f"{cmd}: rc={r.returncode} {r.stderr.decode(errors='replace')[:500]}")
|
||
return r.returncode, r.stdout, r.stderr
|
||
|
||
def have(self, tool):
|
||
rc, out, _ = self.run(f"command -v {shlex.quote(tool)} >/dev/null 2>&1 && echo y")
|
||
return rc == 0 and b"y" in out
|
||
|
||
def exists(self, path):
|
||
rc, out, _ = self.run(f"test -e {shlex.quote(path)} && echo y")
|
||
return rc == 0 and b"y" in out
|
||
|
||
def read_file(self, path):
|
||
rc, out, _ = self.run(f"cat {shlex.quote(path)} 2>/dev/null")
|
||
return out if rc == 0 else None
|
||
|
||
def write_file(self, path, data: bytes, mode=None):
|
||
tmp = f"/tmp/nikovpn.{os.getpid()}.{os.path.basename(path)}"
|
||
cmd = f"cat > {shlex.quote(tmp)}"
|
||
if mode:
|
||
cmd += f" && chmod {mode} {shlex.quote(tmp)}"
|
||
cmd += f" && mv {shlex.quote(tmp)} {shlex.quote(path)}"
|
||
self.run(cmd, input_bytes=data, check=True)
|
||
|
||
def mkdirs(self, *dirs):
|
||
self.run("mkdir -p " + " ".join(shlex.quote(d) for d in dirs), check=True)
|
||
|
||
|
||
def shutil_which(x):
|
||
from shutil import which
|
||
return which(x)
|
||
|
||
|
||
# ============================================================================
|
||
# Архитектуры
|
||
# ============================================================================
|
||
def map_uname(u):
|
||
m = {
|
||
"x86_64|amd64": ("amd64", "x86_64", 1), "aarch64|arm64": ("arm64", "aarch64", 1),
|
||
"armv7l|armv7|arm": ("armv7", "armv7hf", 1), "armv6l|armv6": ("armv6", "armv6", 0),
|
||
"armv5tel|armv5te|armv5l": ("armv5", "armv5", 0),
|
||
"mips": ("mips", "mips", 0), "mipsel": ("mipsle", "mipsel", 1),
|
||
"mips64": ("mips64", "mips64", 0), "mips64el": ("mips64le", "mips64el", 0),
|
||
"i686|i586|i386|x86": ("386", "i686", 1), "riscv64": ("riscv64", "", 1),
|
||
"loongarch64|loong64": ("loong64", "", 1), "ppc64le": ("ppc64le", "", 0),
|
||
"s390x": ("s390x", "", 0),
|
||
}
|
||
for pat, v in m.items():
|
||
if u in pat.split("|"):
|
||
return v
|
||
return ("", "", 0)
|
||
|
||
|
||
def map_openwrt(da, uname_m):
|
||
if da.startswith("x86_64"):
|
||
return map_uname("x86_64")
|
||
if da.startswith("i386_"):
|
||
return map_uname("i686")
|
||
if da.startswith("aarch64_"):
|
||
return map_uname("aarch64")
|
||
if re.match(r"arm_cortex-a(5|7|8|9|15)", da) or da.startswith("arm_"):
|
||
return map_uname("armv7l")
|
||
if da == "arm_arm1176jzf-s_vfp":
|
||
return map_uname("armv6l")
|
||
if da in ("arm_arm926ej-s", "arm_fa526", "arm_xscale"):
|
||
return map_uname("armv5l")
|
||
for pfx, u in [("mips64el_", "mips64el"), ("mips64_", "mips64"),
|
||
("mipsel_", "mipsel"), ("mips_", "mips"),
|
||
("riscv64_", "riscv64"), ("loongarch64_", "loongarch64"),
|
||
("powerpc_", "ppc64le")]:
|
||
if da.startswith(pfx):
|
||
return map_uname(u)
|
||
return map_uname(uname_m)
|
||
|
||
|
||
def detect_arch(rt: Router):
|
||
da = ""
|
||
rel = rt.read_file("/etc/openwrt_release") or b""
|
||
mm = re.search(rb"DISTRIB_ARCH='([^']+)'", rel)
|
||
if mm:
|
||
da = mm.group(1).decode()
|
||
if not da and rt.have("apk"):
|
||
rc, out, _ = rt.run("apk --print-arch 2>/dev/null")
|
||
da = out.decode().strip() if rc == 0 else ""
|
||
if not da and rt.have("opkg"):
|
||
rc, out, _ = rt.run("opkg print-architecture 2>/dev/null")
|
||
if rc == 0:
|
||
for line in out.decode().splitlines():
|
||
p = line.split()
|
||
if len(p) >= 2 and p[0] == "arch" and p[1] not in ("all", "noarch"):
|
||
da = p[1]
|
||
break
|
||
rc, out, _ = rt.run("uname -m")
|
||
uname_m = out.decode().strip()
|
||
sb, ipt, naive = map_openwrt(da, uname_m) if da else map_uname(uname_m)
|
||
if not sb:
|
||
die(f"архитектура не поддерживается ({da or uname_m})")
|
||
return sb, ipt, naive, da or uname_m
|
||
|
||
|
||
# ============================================================================
|
||
# API vpnd.n1ko.dev (запросы идут с ПК)
|
||
# ============================================================================
|
||
def api_get(creds: str, path: str, timeout=30):
|
||
req = urllib.request.Request(
|
||
API_URL + path,
|
||
headers={"Authorization": "Basic " +
|
||
base64.b64encode(creds.encode()).decode()})
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||
return r.status, r.read()
|
||
except urllib.error.HTTPError as e:
|
||
return e.code, e.read()
|
||
except Exception as e:
|
||
warn(f"API недоступен: {e}")
|
||
return None, b""
|
||
|
||
|
||
def get_creds(rt: Router, args) -> str:
|
||
cred = None
|
||
if getattr(args, "creds", None):
|
||
cred = args.creds.strip()
|
||
elif getattr(args, "creds_file", None):
|
||
cred = Path(args.creds_file).read_text().strip().splitlines()[0].strip()
|
||
elif os.environ.get("NIKOVPN_CREDS"):
|
||
cred = os.environ["NIKOVPN_CREDS"].strip()
|
||
else:
|
||
data = rt.read_file(CREDS_FILE)
|
||
if data:
|
||
for line in data.decode(errors="replace").splitlines():
|
||
line = line.strip()
|
||
if line and not line.startswith("#"):
|
||
cred = line
|
||
break
|
||
if not cred or ":" not in cred:
|
||
die("нет кредов: --creds login:password | --creds-file F | "
|
||
"env NIKOVPN_CREDS | /root/creds на роутере")
|
||
return "".join(str(cred).split())
|
||
|
||
|
||
# ============================================================================
|
||
# Разбор ссылок и выбор сервера
|
||
# ============================================================================
|
||
def parse_link(uri: str) -> dict:
|
||
sp = urlsplit(uri)
|
||
q = parse_qs(sp.query, keep_blank_values=True)
|
||
user = unquote(sp.username or "") if sp.username else ""
|
||
pw = unquote(sp.password or "") if sp.password else ""
|
||
return dict(uri=uri, scheme=sp.scheme.lower(),
|
||
host=unquote(sp.hostname or ""), port=sp.port or "",
|
||
user=user, password=pw, query=q, frag=unquote(sp.fragment))
|
||
|
||
|
||
def qp(link, name, default=""):
|
||
v = link["query"].get(name)
|
||
return v[0] if v else default
|
||
|
||
|
||
def host_alive(link) -> bool:
|
||
"""проверка с ПК (у роутера busybox-nc всегда возвращает ошибку).
|
||
UDP-протоколы (hysteria2/tuic) так проверить нельзя — считаем живыми."""
|
||
import socket
|
||
if link["scheme"] in ("hysteria2", "tuic"):
|
||
return True
|
||
host, port = link["host"], int(link["port"] or 443)
|
||
try:
|
||
s = socket.create_connection((host, port), timeout=4)
|
||
s.close()
|
||
return True
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
def choose_candidates(rt: Router, links_text: bytes, naive_ok: bool) -> list:
|
||
prefs = ["hysteria2", "tuic", "naive", "vless", "shadowsocks"]
|
||
prefer_proto, prefer_srv = read_prefs(rt)
|
||
links = [parse_link(l) for l in links_text.decode(errors="replace").splitlines()
|
||
if "://" in l]
|
||
proto = None
|
||
for p in prefs:
|
||
if p == "naive" and not naive_ok:
|
||
continue
|
||
if prefer_proto and prefer_proto != p:
|
||
continue
|
||
if any(l["scheme"] == p or (p == "naive" and l["scheme"].startswith("naive"))
|
||
for l in links):
|
||
proto = p
|
||
break
|
||
if not proto:
|
||
die("ни один протокол недоступен (или предпочтение не найдено)")
|
||
cand = [l for l in links
|
||
if l["scheme"] == proto or (proto == "naive" and l["scheme"].startswith("naive"))]
|
||
if prefer_srv:
|
||
filt = [l for l in cand if prefer_srv.lower() in l["uri"].lower()]
|
||
if filt:
|
||
cand = filt
|
||
# плоский список: внутри протокола — живые первыми, протоколы по приоритету
|
||
picked = []
|
||
for p in prefs:
|
||
group = [l for l in links
|
||
if l["scheme"] == p or (p == "naive" and l["scheme"].startswith("naive"))]
|
||
alive = [l for l in group if host_alive(l)]
|
||
rest = [l for l in group if not host_alive(l)]
|
||
picked += alive + rest
|
||
for l in picked:
|
||
l["protocol"] = l["scheme"].split("+")[0]
|
||
l["sni"] = qp(l, "sni") or l["host"]
|
||
return picked
|
||
|
||
|
||
def read_prefs(rt: Router):
|
||
proto, srv = "", ""
|
||
data = rt.read_file(f"{WORK_DIR}/config")
|
||
if data:
|
||
for line in data.decode(errors="replace").splitlines():
|
||
mm = re.match(r'\s*PREFER_PROTOCOL="([^"]+)"', line)
|
||
if mm:
|
||
proto = mm.group(1)
|
||
mm = re.match(r'\s*PREFER_SERVER="([^"]+)"', line)
|
||
if mm:
|
||
srv = mm.group(1)
|
||
return proto, srv
|
||
|
||
|
||
# ============================================================================
|
||
# Генерация config.json sing-box
|
||
# ============================================================================
|
||
def tls_block(link, sni=None, insecure=False, utls_fp=None, reality=False):
|
||
t = {"enabled": True, "server_name": sni or link["sni"]}
|
||
if insecure:
|
||
t["insecure"] = True
|
||
if utls_fp:
|
||
t["utls"] = {"enabled": True, "fingerprint": utls_fp}
|
||
if reality:
|
||
t["reality"] = {"enabled": True,
|
||
"public_key": qp(link, "pbk"),
|
||
"short_id": qp(link, "sid")}
|
||
return t
|
||
|
||
|
||
def build_outbound(link) -> dict:
|
||
p = link["protocol"]
|
||
ob = {"tag": "main", "server": link["host"], "server_port": int(link["port"])}
|
||
if p == "hysteria2":
|
||
ob.update(type="hysteria2", password=link["user"],
|
||
tls=tls_block(link, insecure=qp(link, "insecure") == "1"))
|
||
if qp(link, "obfs"):
|
||
ob["obfs"] = {"type": qp(link, "obfs"),
|
||
"password": qp(link, "obfs-password")}
|
||
elif p == "tuic":
|
||
ob.update(type="tuic", uuid=link["user"], password=link["password"],
|
||
congestion_control=qp(link, "congestion_control", "bbr"),
|
||
udp_relay_mode=qp(link, "udp_relay_mode", "native"),
|
||
tls=tls_block(link))
|
||
elif p.startswith("naive"):
|
||
ob.update(type="naive", username=link["user"], password=link["password"],
|
||
quic=(link["scheme"] == "naive+quic"),
|
||
tls=tls_block(link, sni=link["host"]))
|
||
elif p == "vless":
|
||
ob.update(type="vless", uuid=link["user"])
|
||
if qp(link, "flow"):
|
||
ob["flow"] = qp(link, "flow")
|
||
sec = qp(link, "security")
|
||
fp = qp(link, "fp", "chrome")
|
||
ob["tls"] = tls_block(link, utls_fp=fp, reality=(sec == "reality"))
|
||
elif p == "shadowsocks":
|
||
try:
|
||
pad = "=" * (-len(link["user"]) % 4)
|
||
dec = base64.b64decode(link["user"] + pad).decode()
|
||
except Exception:
|
||
dec = ""
|
||
if ":" not in dec:
|
||
die("не удалось разобрать shadowsocks-ссылку")
|
||
method, sspass = dec.split(":", 1)
|
||
ob.update(type="shadowsocks", method=method, password=sspass)
|
||
plugin = unquote(qp(link, "plugin"))
|
||
if plugin:
|
||
popts = ""
|
||
if ";" in plugin:
|
||
plugin, popts = plugin.split(";", 1)
|
||
ob["plugin"], ob["plugin_opts"] = plugin, popts
|
||
else:
|
||
die(f"неизвестный протокол {p}")
|
||
return ob
|
||
|
||
|
||
def gen_config(rt: Router, link, tunnel: str):
|
||
main_ob = build_outbound(link)
|
||
if tunnel == "singbox":
|
||
inbound = {"type": "tproxy", "tag": "tproxy-in",
|
||
"listen": "0.0.0.0", "listen_port": int(TP_PORT)}
|
||
else:
|
||
inbound = {"type": "mixed", "tag": "mixed-in",
|
||
"listen": "127.0.0.1", "listen_port": int(SOCKS_PORT)}
|
||
cfg = {
|
||
"log": {"level": "info", "timestamp": True},
|
||
"inbounds": [inbound],
|
||
"outbounds": [main_ob, {"type": "direct", "tag": "direct"}],
|
||
"route": {"final": "main",
|
||
"rules": [{"ip_cidr": PRIVATE_CIDRS, "outbound": "direct"}]},
|
||
}
|
||
data = json.dumps(cfg, indent=2).encode()
|
||
rt.write_file(f"{WORK_DIR}/config.json", data)
|
||
rc, _, err = rt.run(f"{SB_PATH} check -c {WORK_DIR}/config.json")
|
||
if rc != 0:
|
||
die("конфиг sing-box не проходит проверку: " +
|
||
err.decode(errors="replace")[:300])
|
||
|
||
|
||
# ============================================================================
|
||
# fw.sh / init.d (выполняются на роутере — остаются shell)
|
||
# ============================================================================
|
||
FW_SH = r"""#!/bin/sh
|
||
# NikoVPN firewall (генерируется nikovpn.py)
|
||
WORK_DIR=/etc/nikovpn
|
||
STATE="$WORK_DIR/state"
|
||
[ -f "$STATE" ] && . "$STATE"
|
||
MODE="${MODE:-tcp}"
|
||
FW="${FW:-nft}"
|
||
TUNNEL="${TUNNEL:-ipt2socks}"
|
||
TP_PORT="${TP_PORT:-12345}"
|
||
VPN_PORT="${PORT:-0}"
|
||
MARK="0x1"
|
||
TABLE="100"
|
||
NFT_FILE="/etc/nftables.d/10-nikovpn.nft"
|
||
PRIVATE="0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4"
|
||
NFT_PRIVATE="$(echo $PRIVATE | tr ' ' ',')"
|
||
|
||
have() { command -v "$1" >/dev/null 2>&1; }
|
||
|
||
ip_rules_add() {
|
||
ip rule add fwmark $MARK table $TABLE 2>/dev/null
|
||
ip route add local default dev lo table $TABLE 2>/dev/null
|
||
true
|
||
}
|
||
ip_rules_del() {
|
||
ip rule del fwmark $MARK table $TABLE 2>/dev/null
|
||
ip route del local default dev lo table $TABLE 2>/dev/null
|
||
true
|
||
}
|
||
|
||
nft_write() {
|
||
cat > "$NFT_FILE" <<EOF2
|
||
table inet nikovpn {
|
||
set bypass4 { type ipv4_addr; flags interval;
|
||
elements = { $NFT_PRIVATE } }
|
||
set vpn_ports { type inet_service; elements = { $VPN_PORT } }
|
||
EOF2
|
||
if [ "$TUNNEL" = "singbox" ]; then
|
||
cat >> "$NFT_FILE" <<EOF2
|
||
chain prerouting_tcp {
|
||
type filter hook prerouting priority -1; policy accept;
|
||
ip daddr @bypass4 return
|
||
tcp dport 53 return
|
||
meta l4proto tcp tproxy to :$TP_PORT meta mark set 1
|
||
}
|
||
chain prerouting_udp {
|
||
type filter hook prerouting priority -1; policy accept;
|
||
ip daddr @bypass4 return
|
||
udp dport 53 return
|
||
meta l4proto udp tproxy to :$TP_PORT meta mark set 1
|
||
}
|
||
}
|
||
EOF2
|
||
else
|
||
cat >> "$NFT_FILE" <<EOF2
|
||
chain prerouting_tcp {
|
||
type nat hook prerouting priority 0; policy accept;
|
||
ip daddr @bypass4 return
|
||
tcp dport 53 return
|
||
meta l4proto tcp redirect to :$TP_PORT
|
||
}
|
||
chain output_tcp {
|
||
type nat hook output priority 0; policy accept;
|
||
ip daddr @bypass4 return
|
||
fib daddr type local return
|
||
tcp dport 53 return
|
||
tcp dport @vpn_ports return
|
||
meta l4proto tcp redirect to :$TP_PORT
|
||
}
|
||
EOF2
|
||
if [ "$MODE" = "full" ]; then
|
||
cat >> "$NFT_FILE" <<EOF2
|
||
chain prerouting_udp {
|
||
type filter hook prerouting priority -1; policy accept;
|
||
ip daddr @bypass4 return
|
||
udp dport 53 return
|
||
meta l4proto udp tproxy to :$TP_PORT meta mark set 1
|
||
}
|
||
}
|
||
EOF2
|
||
else
|
||
echo '}' >> "$NFT_FILE"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
fw_start() {
|
||
if [ "$FW" = "nft" ]; then
|
||
have nft || return 1
|
||
nft_write
|
||
nft delete table inet nikovpn 2>/dev/null
|
||
nft -f "$NFT_FILE" || return 1
|
||
if [ "$MODE" = full ] || [ "$TUNNEL" = singbox ]; then ip_rules_add; fi
|
||
else
|
||
have iptables || return 1
|
||
if [ "$TUNNEL" = singbox ]; then
|
||
iptables -t mangle -N NIKOVPN_TP 2>/dev/null
|
||
iptables -t mangle -F NIKOVPN_TP
|
||
for n in $PRIVATE; do iptables -t mangle -A NIKOVPN_TP -d $n -j RETURN; done
|
||
iptables -t mangle -A NIKOVPN_TP -m addrtype --dst-type LOCAL -j RETURN
|
||
iptables -t mangle -A NIKOVPN_TP -p tcp --dport 53 -j RETURN
|
||
iptables -t mangle -A NIKOVPN_TP -p tcp -j TPROXY --on-port $TP_PORT --tproxy-mark $MARK/$MARK
|
||
iptables -t mangle -C PREROUTING -p tcp -j NIKOVPN_TP 2>/dev/null || iptables -t mangle -A PREROUTING -p tcp -j NIKOVPN_TP
|
||
else
|
||
iptables -t nat -N NIKOVPN 2>/dev/null
|
||
iptables -t nat -F NIKOVPN
|
||
for n in $PRIVATE; do iptables -t nat -A NIKOVPN -d $n -j RETURN; done
|
||
iptables -t nat -A NIKOVPN -m addrtype --dst-type LOCAL -j RETURN
|
||
iptables -t nat -A NIKOVPN -p tcp --dport 53 -j RETURN
|
||
iptables -t nat -A NIKOVPN -p tcp --dport $VPN_PORT -j RETURN
|
||
iptables -t nat -A NIKOVPN -p tcp -j REDIRECT --to-ports $TP_PORT
|
||
iptables -t nat -C PREROUTING -p tcp -j NIKOVPN 2>/dev/null || iptables -t nat -A PREROUTING -p tcp -j NIKOVPN
|
||
iptables -t nat -C OUTPUT -p tcp -m addrtype ! --dst-type LOCAL -j NIKOVPN 2>/dev/null || iptables -t nat -A OUTPUT -p tcp -m addrtype ! --dst-type LOCAL -j NIKOVPN
|
||
fi
|
||
if [ "$MODE" = full ] || [ "$TUNNEL" = singbox ]; then
|
||
iptables -t mangle -N NIKOVPN_UDP 2>/dev/null
|
||
iptables -t mangle -F NIKOVPN_UDP
|
||
for n in $PRIVATE; do iptables -t mangle -A NIKOVPN_UDP -d $n -j RETURN; done
|
||
iptables -t mangle -A NIKOVPN_UDP -m addrtype --dst-type LOCAL -j RETURN
|
||
iptables -t mangle -A NIKOVPN_UDP -p udp --dport 53 -j RETURN
|
||
iptables -t mangle -A NIKOVPN_UDP -p udp -j TPROXY --on-port $TP_PORT --tproxy-mark $MARK/$MARK
|
||
iptables -t mangle -C PREROUTING -p udp -j NIKOVPN_UDP 2>/dev/null || iptables -t mangle -A PREROUTING -p udp -j NIKOVPN_UDP
|
||
ip_rules_add
|
||
fi
|
||
grep -q 'nikovpn/fw.sh start' /etc/firewall.user 2>/dev/null || echo "sh /etc/nikovpn/fw.sh start" >> /etc/firewall.user
|
||
fi
|
||
}
|
||
|
||
fw_stop() {
|
||
if [ "$FW" = "nft" ]; then
|
||
nft delete table inet nikovpn 2>/dev/null
|
||
rm -f "$NFT_FILE"
|
||
else
|
||
iptables -t nat -D PREROUTING -p tcp -j NIKOVPN 2>/dev/null
|
||
iptables -t nat -D OUTPUT -p tcp -m addrtype ! --dst-type LOCAL -j NIKOVPN 2>/dev/null
|
||
iptables -t mangle -D PREROUTING -p tcp -j NIKOVPN_TP 2>/dev/null
|
||
iptables -t mangle -D PREROUTING -p udp -j NIKOVPN_UDP 2>/dev/null
|
||
iptables -t nat -F NIKOVPN 2>/dev/null; iptables -t nat -X NIKOVPN 2>/dev/null
|
||
iptables -t mangle -F NIKOVPN_TP 2>/dev/null; iptables -t mangle -X NIKOVPN_TP 2>/dev/null
|
||
iptables -t mangle -F NIKOVPN_UDP 2>/dev/null; iptables -t mangle -X NIKOVPN_UDP 2>/dev/null
|
||
sed -i '\#nikovpn/fw.sh start#d' /etc/firewall.user 2>/dev/null
|
||
fi
|
||
ip_rules_del
|
||
}
|
||
|
||
case "$1" in
|
||
start) fw_start ;;
|
||
stop) fw_stop ;;
|
||
*) echo "usage: $0 start|stop"; exit 1 ;;
|
||
esac
|
||
"""
|
||
|
||
INIT_SH = r"""#!/bin/sh /etc/rc.common
|
||
START=95
|
||
STOP=10
|
||
USE_PROCD=1
|
||
|
||
WORK_DIR=/etc/nikovpn
|
||
STATE="$WORK_DIR/state"
|
||
[ -f "$STATE" ] && . "$STATE"
|
||
|
||
start_service() {
|
||
[ -f "$WORK_DIR/config.json" ] || return 1
|
||
procd_open_instance singbox
|
||
procd_set_param command /usr/bin/sing-box run -c "$WORK_DIR/config.json"
|
||
procd_set_param respawn 3600 5 5
|
||
procd_set_param file /var/log/nikovpn.log
|
||
procd_set_param pidfile /var/run/nikovpn-singbox.pid
|
||
procd_close_instance
|
||
|
||
if [ "$TUNNEL" = "ipt2socks" ]; then
|
||
IPT_FLAGS="-R"
|
||
[ "$MODE" = "tcp" ] && IPT_FLAGS="-R -T"
|
||
procd_open_instance ipt2socks
|
||
procd_set_param command /usr/bin/ipt2socks $IPT_FLAGS -l $TP_PORT -s 127.0.0.1 -p $SOCKS_PORT -b 0.0.0.0
|
||
procd_set_param respawn 3600 5 5
|
||
procd_set_param file /var/log/nikovpn-ipt2socks.log
|
||
procd_set_param pidfile /var/run/nikovpn-ipt2socks.pid
|
||
procd_close_instance
|
||
fi
|
||
}
|
||
|
||
service_started() {
|
||
"$WORK_DIR/fw.sh" start
|
||
}
|
||
|
||
stop_service() {
|
||
"$WORK_DIR/fw.sh" stop
|
||
}
|
||
"""
|
||
|
||
|
||
# ============================================================================
|
||
# Установка бинарников
|
||
# ============================================================================
|
||
def router_download(rt: Router, url: str, out: str) -> bool:
|
||
"""скачивание на самом роутере (curl/wget + http-fallback для f.n1ko.dev)"""
|
||
if rt.have("curl"):
|
||
dl = f"curl -fsSL --max-time 120 -o {shlex.quote(out)} {shlex.quote(url)}"
|
||
elif rt.have("wget"):
|
||
dl = f"wget -q -T 120 -O {shlex.quote(out)} {shlex.quote(url)}"
|
||
else:
|
||
return False
|
||
if rt.run(dl + " >/dev/null 2>&1")[0] == 0:
|
||
return True
|
||
if url.startswith("https://f.n1ko.dev/"):
|
||
http_url = "http://" + url[len("https://"):]
|
||
return rt.run(dl.replace(shlex.quote(url), shlex.quote(http_url)) +
|
||
" >/dev/null 2>&1")[0] == 0
|
||
return False
|
||
|
||
|
||
def push_gz(rt: Router, local_gz: Path, dest: str, test_cmd: str, label: str):
|
||
"""залить .gz с ПК и распаковать сразу в dest (без staging в tmpfs —
|
||
/tmp на OpenWRT это RAM, большой бинарник туда класть нельзя)"""
|
||
log(f"заливаю {label} ({local_gz.stat().st_size // 1024 // 1024}MB) по SSH ...")
|
||
with open(local_gz, "rb") as f:
|
||
rt.run(f"gzip -dc > {shlex.quote(dest)} && chmod 755 {shlex.quote(dest)}",
|
||
input_bytes=f.read(), check=True)
|
||
if rt.run(test_cmd.format(b=shlex.quote(dest)), timeout=30)[0] != 0:
|
||
die(f"{label} не запускается на этой системе (архитектура?)")
|
||
|
||
|
||
def install_bins(rt: Router, sb_arch: str, ipt_arch: str, tunnel: str,
|
||
update: bool, state: dict, local_bin: Path):
|
||
rt.mkdirs("/usr/bin")
|
||
want_sb = SB_VER
|
||
want_ipt = IPT_VER
|
||
skip_sb = (update and state.get("SB_VER") == want_sb and rt.exists(SB_PATH))
|
||
skip_ipt = (update and state.get("IPT_VER") == want_ipt and rt.exists(IPT_PATH))
|
||
|
||
if not skip_sb:
|
||
local = local_bin / f"sing-box-{sb_arch}.gz"
|
||
if local.exists():
|
||
push_gz(rt, local, SB_PATH, "{b} version", "sing-box")
|
||
else:
|
||
log(f"скачиваю sing-box ({sb_arch}) на роутере с f.n1ko.dev ...")
|
||
gz = f"/tmp/nikovpn.{os.getpid()}.sb.gz"
|
||
if not router_download(rt, f"{BASE_URL}/bin/sing-box-{sb_arch}.gz", gz):
|
||
die("не удалось скачать sing-box")
|
||
rt.run(f"gzip -dc {gz} > {SB_PATH} && chmod +x {SB_PATH}", check=True)
|
||
if rt.run(f"{SB_PATH} version")[0] != 0:
|
||
die("sing-box не запускается на этой системе")
|
||
rt.run(f"rm -f {gz}")
|
||
|
||
if tunnel == "ipt2socks" and not skip_ipt:
|
||
local = local_bin / f"ipt2socks-{ipt_arch}.gz"
|
||
if local.exists():
|
||
push_gz(rt, local, IPT_PATH, "{b} -V", "ipt2socks")
|
||
else:
|
||
log(f"скачиваю ipt2socks ({ipt_arch}) на роутере с f.n1ko.dev ...")
|
||
gz = f"/tmp/nikovpn.{os.getpid()}.ipt.gz"
|
||
if not router_download(rt, f"{BASE_URL}/bin/ipt2socks-{ipt_arch}.gz", gz):
|
||
die("не удалось скачать ipt2socks")
|
||
rt.run(f"gzip -dc {gz} > {IPT_PATH} && chmod +x {IPT_PATH}", check=True)
|
||
if rt.run(f"{IPT_PATH} -V")[0] != 0:
|
||
die("ipt2socks не запускается на этой системе")
|
||
rt.run(f"rm -f {gz}")
|
||
|
||
|
||
# ============================================================================
|
||
# FULL-режим (TCP+UDP через TPROXY)
|
||
# ============================================================================
|
||
def probe_full(rt: Router, fw: str) -> bool:
|
||
if not rt.have("ip"):
|
||
return False
|
||
ok = True
|
||
if fw == "nft":
|
||
if rt.run("nft add table inet nikovpn_test")[0] != 0:
|
||
return False
|
||
rt.run("nft add chain inet nikovpn_test t "
|
||
"'{ type filter hook prerouting priority -1; policy accept; }'")
|
||
if rt.run("nft add rule inet nikovpn_test t 'meta l4proto udp tproxy to :12345'")[0] != 0:
|
||
ok = False
|
||
rt.run("nft delete table inet nikovpn_test")
|
||
else:
|
||
rt.run("iptables -t mangle -N NIKOVPN_TEST")
|
||
if rt.run("iptables -t mangle -A NIKOVPN_TEST -p udp -j TPROXY "
|
||
"--on-port 12345 --tproxy-mark 0x1/0x1")[0] != 0:
|
||
ok = False
|
||
rt.run("iptables -t mangle -F NIKOVPN_TEST; iptables -t mangle -X NIKOVPN_TEST")
|
||
if not ok:
|
||
return False
|
||
if rt.run("ip rule add fwmark 0x1 table 101")[0] != 0:
|
||
return False
|
||
rt.run("ip rule del fwmark 0x1 table 101")
|
||
if rt.run("ip route add local 192.0.2.1/32 dev lo table 101")[0] != 0:
|
||
return False
|
||
rt.run("ip route del local 192.0.2.1/32 dev lo table 101")
|
||
return True
|
||
|
||
|
||
# ============================================================================
|
||
# Состояние
|
||
# ============================================================================
|
||
def write_state(rt: Router, **kw):
|
||
lines = "".join(f"{k}='{v}'\n" for k, v in kw.items())
|
||
rt.write_file(f"{WORK_DIR}/state", lines.encode())
|
||
|
||
|
||
def read_state(rt: Router) -> dict:
|
||
data = rt.read_file(f"{WORK_DIR}/state")
|
||
if data is None:
|
||
return {}
|
||
st = {}
|
||
for k, v in re.findall(r"^([A-Z_]+)='(.*)'$",
|
||
data.decode(errors="replace"), re.M):
|
||
st[k] = v
|
||
return st
|
||
|
||
|
||
def human(n):
|
||
try:
|
||
n = float(n)
|
||
except (TypeError, ValueError):
|
||
return "?"
|
||
for u in ["Б", "КБ", "МБ", "ГБ", "ТБ"]:
|
||
if n < 1024 or u == "ТБ":
|
||
return f"{n:.1f} {u}"
|
||
n /= 1024
|
||
|
||
|
||
# ============================================================================
|
||
# Команды
|
||
# ============================================================================
|
||
def stop_service(rt: Router):
|
||
if rt.exists("/etc/init.d/nikovpn"):
|
||
rt.run("/etc/init.d/nikovpn stop >/dev/null 2>&1")
|
||
# процессы, запущенные в обход procd, держат бинарники (Text file busy)
|
||
rt.run("kill $(pgrep sing-box) 2>/dev/null; "
|
||
"kill $(pgrep ipt2socks) 2>/dev/null")
|
||
for _ in range(5):
|
||
_, out, _ = rt.run("pgrep sing-box; pgrep ipt2socks")
|
||
if not out.strip():
|
||
break
|
||
rt.run("kill -9 $(pgrep sing-box) 2>/dev/null; "
|
||
"kill -9 $(pgrep ipt2socks) 2>/dev/null")
|
||
time.sleep(1)
|
||
|
||
|
||
def verify_tunnel(rt: Router, local_port: int) -> str | None:
|
||
"""сквозной тест: socks5 роутера -> SSH-форвард -> curl с ПК.
|
||
Возвращает внешний IP или None."""
|
||
import socket as _s
|
||
fwd = None
|
||
try:
|
||
with _s.socket() as t:
|
||
t.bind(("127.0.0.1", 0))
|
||
local_port = t.getsockname()[1]
|
||
fwd = subprocess.Popen(
|
||
rt.ssh_args("-f", "-N", "-L",
|
||
f"{local_port}:127.0.0.1:{SOCKS_PORT}",
|
||
f"{rt.user}@{rt.host}"),
|
||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
time.sleep(1.5)
|
||
r = subprocess.run(
|
||
["curl", "-s", "--socks5-hostname", f"127.0.0.1:{local_port}",
|
||
"--max-time", "12", "https://api.ipify.org"],
|
||
capture_output=True, text=True, timeout=20)
|
||
ip = r.stdout.strip()
|
||
return ip if re.match(r"^\d+\.\d+\.\d+\.\d+$", ip) else None
|
||
except Exception:
|
||
return None
|
||
finally:
|
||
if fwd:
|
||
subprocess.run(["pkill", "-f", f"-L {local_port}:127.0.0.1:{SOCKS_PORT}"],
|
||
capture_output=True)
|
||
|
||
|
||
def restart_singbox_manual(rt: Router):
|
||
# procd-respawn мешает ручным запускам: гасим надзор и дожидаемся смерти
|
||
rt.run("/etc/init.d/nikovpn stop >/dev/null 2>&1; "
|
||
"/etc/init.d/nikovpn disable >/dev/null 2>&1")
|
||
for _ in range(10):
|
||
_, out, _ = rt.run("pgrep sing-box")
|
||
if not out.strip():
|
||
break
|
||
rt.run("kill $(pgrep sing-box) 2>/dev/null")
|
||
time.sleep(1)
|
||
rt.run("kill $(pgrep ipt2socks) 2>/dev/null")
|
||
time.sleep(1)
|
||
rt.run(f"(/usr/bin/sing-box run -c {WORK_DIR}/config.json "
|
||
f">> /var/log/nikovpn.log 2>&1 &); "
|
||
f"(/usr/bin/ipt2socks -R -T -l {TP_PORT} -s 127.0.0.1 -p {SOCKS_PORT} "
|
||
f"-b 0.0.0.0 >/dev/null 2>&1 &)")
|
||
time.sleep(3)
|
||
_, out, _ = rt.run("netstat -tln 2>/dev/null | grep ':${P}'".replace("${P}", SOCKS_PORT))
|
||
if SOCKS_PORT.encode() not in out:
|
||
warn("sing-box не слушает порт — см. лог")
|
||
|
||
|
||
def do_install(rt: Router, args, update=False):
|
||
sb_arch, ipt_arch, naive_ok, da = detect_arch(rt)
|
||
creds = get_creds(rt, args)
|
||
tunnel = "ipt2socks" if ipt_arch else "singbox"
|
||
if tunnel == "singbox":
|
||
warn("для этой архитектуры нет ipt2socks — использую встроенный tproxy sing-box")
|
||
|
||
rt.mkdirs(WORK_DIR)
|
||
if args.creds:
|
||
rt.write_file(CREDS_FILE, (args.creds.strip() + "\n").encode(), mode="600")
|
||
|
||
# версии: локальный bin/ приоритетнее
|
||
local_bin = Path(__file__).resolve().parent / "bin"
|
||
global SB_VER, IPT_VER
|
||
SB_VER = IPT_VER = ""
|
||
src_versions = None
|
||
vf = local_bin / ".." / "versions"
|
||
if vf.exists():
|
||
src_versions = vf.read_text()
|
||
if src_versions:
|
||
for line in src_versions.splitlines():
|
||
k, _, v = line.partition("=")
|
||
if k == "sing-box":
|
||
SB_VER = v
|
||
elif k == "ipt2socks":
|
||
IPT_VER = v
|
||
if not SB_VER:
|
||
rc, out, _ = rt.run(
|
||
f"(curl -fsSL {BASE_URL}/versions 2>/dev/null || "
|
||
f"wget -q -O - {BASE_URL}/versions 2>/dev/null)")
|
||
if rc == 0:
|
||
for line in out.decode(errors="replace").splitlines():
|
||
k, _, v = line.partition("=")
|
||
if k == "sing-box":
|
||
SB_VER = v
|
||
elif k == "ipt2socks":
|
||
IPT_VER = v
|
||
if not SB_VER or not IPT_VER:
|
||
die("не удалось определить версии бинарников")
|
||
|
||
log(f"Архитектура: {sb_arch} (sing-box {SB_VER}), ipt2socks: {ipt_arch or 'нет'}")
|
||
|
||
log("Проверяю аккаунт ...")
|
||
code, body = api_get(creds, "/me")
|
||
if code == 401:
|
||
die("неверный логин/пароль")
|
||
if code == 403:
|
||
die("аккаунт заблокирован/истёк/лимит — проверьте подписку")
|
||
if code != 200:
|
||
die(f"не удалось связаться с {API_URL}")
|
||
|
||
log("Получаю список серверов ...")
|
||
code, links = api_get(creds, "/config?format=links")
|
||
if code != 200 or not links.strip():
|
||
code, links = api_get(creds, "/config?format=links")
|
||
if code != 200 or not links.strip():
|
||
die(f"не удалось получить список серверов (код {code})")
|
||
|
||
cands = choose_candidates(rt, links, bool(naive_ok))
|
||
if not cands:
|
||
die("пустой список кандидатов")
|
||
|
||
# остановить старый сервис перед перебором
|
||
stop_service(rt)
|
||
|
||
install_bins(rt, sb_arch, ipt_arch, tunnel, update, read_state(rt), local_bin)
|
||
|
||
link = None
|
||
for i, cand in enumerate(cands[:5]):
|
||
tag = f" ({cand['frag']})" if cand["frag"] else ""
|
||
log(f"Кандидат {i + 1}/{min(5, len(cands))}: "
|
||
f"{cand['protocol']} {cand['host']}:{cand['port']}{tag}")
|
||
gen_config(rt, cand, tunnel)
|
||
restart_singbox_manual(rt)
|
||
ip = verify_tunnel(rt, 0)
|
||
if ip:
|
||
log(f"Туннель работает, внешний IP: {ip}")
|
||
link = cand
|
||
break
|
||
warn("не работает, пробую следующего кандидата")
|
||
|
||
if link is None:
|
||
die("ни один сервер не заработал — смотрите /var/log/nikovpn.log на роутере")
|
||
assert link is not None
|
||
|
||
fw = ""
|
||
if rt.have("nft"):
|
||
fw = "nft"
|
||
elif rt.have("iptables"):
|
||
fw = "ipt"
|
||
else:
|
||
die("не найдено ни nftables, ни iptables")
|
||
|
||
mode = "tcp"
|
||
if tunnel == "singbox":
|
||
mode = "full"
|
||
elif link["protocol"] == "naive":
|
||
mode = "tcp"
|
||
elif probe_full(rt, fw):
|
||
mode = "full"
|
||
else:
|
||
warn("UDP/TPROXY недоступно — режим только TCP")
|
||
|
||
log(f"Выбран протокол: {link['protocol']}")
|
||
log(f"Выбран сервер: {link['host']}:{link['port']}" +
|
||
(f" ({link['frag']})" if link["frag"] else ""))
|
||
|
||
write_state(rt, SB_VER=SB_VER, IPT_VER=IPT_VER, ARCH=sb_arch,
|
||
SB_ARCH=sb_arch, IPT_ARCH=ipt_arch, NAIVE_OK=int(naive_ok),
|
||
PROTOCOL=link["protocol"], LINK=link["uri"], HOST=link["host"],
|
||
PORT=str(link["port"]), SNI=link["sni"], MODE=mode, FW=fw,
|
||
TUNNEL=tunnel, SOCKS_PORT=SOCKS_PORT, TP_PORT=TP_PORT)
|
||
rt.write_file(f"{WORK_DIR}/fw.sh", FW_SH.encode(), mode="755")
|
||
rt.write_file("/etc/init.d/nikovpn", INIT_SH.encode(), mode="755")
|
||
|
||
log("Запускаю сервис ...")
|
||
rt.run("kill $(pgrep sing-box) 2>/dev/null; "
|
||
"kill $(pgrep ipt2socks) 2>/dev/null")
|
||
time.sleep(1)
|
||
rt.run("/etc/init.d/nikovpn enable >/dev/null 2>&1")
|
||
rt.run("/etc/init.d/nikovpn start >/dev/null 2>&1")
|
||
time.sleep(3)
|
||
rc, out, _ = rt.run("pgrep sing-box >/dev/null && echo y")
|
||
if rc == 0 and b"y" in out:
|
||
log("sing-box запущен. Прозрачный прокси активен.")
|
||
else:
|
||
warn("sing-box не запустился. Лог: ssh root@host cat /var/log/nikovpn.log")
|
||
log("Готово. Проверка: uv run nikovpn.py status")
|
||
|
||
|
||
def do_status(rt: Router, args):
|
||
st = read_state(rt)
|
||
if not st:
|
||
die(f"NikoVPN не установлен (нет {WORK_DIR}/state)")
|
||
print("=== NikoVPN ===")
|
||
print(f"Протокол: {st.get('PROTOCOL', '?')}")
|
||
print(f"Сервер: {st.get('HOST', '?')}:{st.get('PORT', '?')}")
|
||
print(f"Архитектура: {st.get('ARCH', '?')} "
|
||
f"(sing-box {st.get('SB_VER', '?')}, ipt2socks {st.get('IPT_VER', '?')})")
|
||
print(f"Режим: {st.get('MODE', '?')} / {st.get('TUNNEL', '?')} / {st.get('FW', '?')}")
|
||
rc, out, _ = rt.run("pgrep sing-box >/dev/null && echo RUNNING || echo STOPPED")
|
||
print(f"sing-box: {out.decode().strip()}")
|
||
if st.get("TUNNEL") == "ipt2socks":
|
||
rc, out, _ = rt.run("pgrep ipt2socks >/dev/null && echo RUNNING || echo STOPPED")
|
||
print(f"ipt2socks: {out.decode().strip()}")
|
||
logs = rt.read_file("/var/log/nikovpn.log")
|
||
if logs:
|
||
print("--- последние строки /var/log/nikovpn.log ---")
|
||
for line in logs.decode(errors="replace").splitlines()[-5:]:
|
||
print(line)
|
||
try:
|
||
creds = get_creds(rt, args)
|
||
code, body = api_get(creds, "/me")
|
||
if code == 200:
|
||
me = json.loads(body)
|
||
print(f"Трафик: использовано {human(me.get('traffic_used'))}, "
|
||
f"за сутки {human(me.get('traffic_day'))}, "
|
||
f"лимит {human(me.get('traffic_total'))}")
|
||
except SystemExit:
|
||
pass
|
||
|
||
|
||
def do_uninstall(rt: Router, args):
|
||
rt.run("/etc/init.d/nikovpn stop >/dev/null 2>&1")
|
||
rt.run("/etc/init.d/nikovpn disable >/dev/null 2>&1")
|
||
rt.run("nft delete table inet nikovpn >/dev/null 2>&1")
|
||
rt.run("rm -f /etc/nftables.d/10-nikovpn.nft")
|
||
for c in ("NIKOVPN", "NIKOVPN_TP", "NIKOVPN_UDP"):
|
||
rt.run(f"iptables -t nat -D PREROUTING -p tcp -j {c} >/dev/null 2>&1")
|
||
rt.run(f"iptables -t mangle -D PREROUTING -p udp -j {c} >/dev/null 2>&1")
|
||
rt.run("ip rule del fwmark 0x1 table 100 >/dev/null 2>&1")
|
||
rt.run("ip route del local default dev lo table 100 >/dev/null 2>&1")
|
||
rt.run("rm -f /etc/init.d/nikovpn /etc/rc.d/S95nikovpn /etc/rc.d/K10nikovpn")
|
||
rt.run(r"sed -i '\#nikovpn/fw.sh start#d' /etc/firewall.user >/dev/null 2>&1")
|
||
rt.run(f"rm -rf {shlex.quote(WORK_DIR)}")
|
||
rt.run(f"rm -f {SB_PATH} {IPT_PATH}")
|
||
print("NikoVPN удалён.")
|
||
|
||
|
||
# ============================================================================
|
||
# MAIN
|
||
# ============================================================================
|
||
SB_VER = ""
|
||
IPT_VER = ""
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(prog="nikovpn",
|
||
description="NikoVPN OpenWRT controller")
|
||
ap.add_argument("--host", default=os.environ.get("NIKOVPN_HOST", "192.168.1.1"))
|
||
ap.add_argument("--user", default="root")
|
||
ap.add_argument("--port", type=int, default=22)
|
||
ap.add_argument("--password", help="пароль SSH (нужен sshpass)")
|
||
ap.add_argument("--identity", help="ключ SSH")
|
||
ap.add_argument("--creds", help="креды VPN login:password")
|
||
ap.add_argument("--creds-file", help="файл с кредами login:password")
|
||
ap.add_argument("command", choices=["install", "update", "status", "uninstall"])
|
||
args = ap.parse_args()
|
||
|
||
rt = Router(args.host, args.user, args.port, args.password, args.identity)
|
||
rc, out, err = rt.run("echo ok", timeout=20)
|
||
if rc != 0:
|
||
die(f"нет SSH-доступа к {args.user}@{args.host}: "
|
||
f"{err.decode(errors='replace').strip()[:200]}")
|
||
|
||
if args.command == "install":
|
||
do_install(rt, args)
|
||
elif args.command == "update":
|
||
do_install(rt, args, update=True)
|
||
elif args.command == "status":
|
||
do_status(rt, args)
|
||
elif args.command == "uninstall":
|
||
do_uninstall(rt, args)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|