重写为多协议管理器: 6 协议, CLI + curses TUI, 用户级 systemd

This commit is contained in:
typist committed 2026-11-01 06:38:56 +08:00
1 parent c43ab4a16e
commit 237b958e41
71 files changed
+4599 -1171

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Full-screen terminal interface."""
+105
View File
@@ -0,0 +1,105 @@
"""TUI application loop and shared helpers."""
from __future__ import annotations
import curses
import sys
from .. import constants, protocols
from ..errors import SbpError
from ..ops import install as install_op
from ..ops import status as status_op
from ..paths import Paths
from .term import Term
from .screens import dashboard, logs, protocols as protocols_screen, settings, users
class App:
def __init__(self, paths: Paths) -> None:
self.paths = paths
self.term: Term
def status(self) -> status_op.Status:
return status_op.status(self.paths)
def main(self, screen) -> None:
self.term = Term(screen)
while True:
status = self.status()
if not status.installed:
choice = self.term.menu("sbp", ["Install", "Quit"], subtitle="not installed")
if choice == 0:
self.install_wizard()
continue
return
subtitle = f"{status.host} {'active' if status.active else 'inactive'}"
choice = self.term.menu(
f"sbp {constants.VERSION}",
["Dashboard", "Protocols", "Users", "Settings", "Logs", "Quit"],
subtitle=subtitle,
)
if choice is None or choice == 5:
return
handler = (
dashboard.show,
protocols_screen.show,
users.show,
settings.show,
logs.show,
)[choice]
try:
handler(self)
except SbpError as exc:
self.term.message("Error", exc.message, "error")
def install_wizard(self) -> None:
host = self.term.prompt("Install", "Client-facing host", "")
sni = self.term.prompt("Install", "TLS server name", constants.DEFAULT_SNI)
if sni is None:
return
keys = ", ".join(protocols.keys())
selection = self.term.prompt("Install", f"Protocols ({keys})", "all")
if selection is None:
return
port_text = self.term.prompt("Install", "First port", str(constants.BASE_PORT))
if port_text is None:
return
try:
base_port = int(port_text)
except ValueError:
self.term.message("Error", f"invalid port: {port_text}", "error")
return
selected = None if selection.strip().lower() in ("", "all") else [
item.strip() for item in selection.split(",") if item.strip()
]
options = install_op.InstallOptions(
host=host or None,
sni=sni or None,
protocols=selected,
base_port=base_port,
)
def action(progress):
install_op.install(self.paths, options, progress=progress)
for warning in options.warnings:
print(f"warning: {warning}")
self.term.run_external("Install sbp", action)
def run(paths: Paths) -> int:
if not sys.stdout.isatty() or not sys.stdin.isatty():
print("sbp tui needs an interactive terminal", file=sys.stderr)
return 1
app = App(paths)
try:
curses.wrapper(app.main)
except curses.error as exc:
print(f"error: cannot start the TUI: {exc}", file=sys.stderr)
return 1
except SbpError as exc:
print(f"error: {exc.message}", file=sys.stderr)
return exc.code
except KeyboardInterrupt:
return 130
return 0
+1
View File
@@ -0,0 +1 @@
"""TUI screens."""
+50
View File
@@ -0,0 +1,50 @@
"""Dashboard: status summary and service control."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ...ops import service as service_op
if TYPE_CHECKING:
from ..app import App
def _summary(app: "App") -> str:
status = app.status()
lines = [
f"Service {'active' if status.active else 'inactive'}",
f"sing-box {status.singbox_version}",
f"Address {status.host}",
f"SNI {status.sni}",
f"TLS {status.tls_mode}",
f"Cert until {status.cert_until}",
f"Users {status.user_count}",
"",
"Protocols",
]
for row in status.protocols:
mark = "on " if row.enabled else "off"
details = ", ".join(f"{key}={value}" for key, value in row.details)
lines.append(f" [{mark}] {row.title:<18} port {row.port:<6} {row.transport} {details}")
return "\n".join(lines)
def show(app: "App") -> None:
while True:
status = app.status()
choice = app.term.menu(
"Dashboard",
["Show full status", "Start service", "Stop service", "Restart service", "Back"],
subtitle=f"{status.host} {'active' if status.active else 'inactive'}",
)
if choice is None or choice == 4:
return
if choice == 0:
app.term.scroll_text("Status", _summary(app))
elif choice == 1:
app.term.run_external("Start service", lambda progress: service_op.start(app.paths))
elif choice == 2:
app.term.run_external("Stop service", lambda progress: service_op.stop(app.paths))
elif choice == 3:
app.term.run_external("Restart service", lambda progress: service_op.restart(app.paths))
+22
View File
@@ -0,0 +1,22 @@
"""Journal viewer."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ...ops import service as service_op
if TYPE_CHECKING:
from ..app import App
def show(app: "App") -> None:
while True:
choice = app.term.menu("Logs", ["Recent 100 lines", "Recent 500 lines", "Back"])
if choice is None or choice == 2:
return
lines = 100 if choice == 0 else 500
app.term.scroll_text(
"Logs",
"\n".join(service_op.logs(app.paths, lines=lines)) or "no log lines",
)
+99
View File
@@ -0,0 +1,99 @@
"""Protocol list and per-protocol actions."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ... import protocols
from ...ops import protocols as protocols_op
if TYPE_CHECKING:
from ..app import App
def _show(value) -> str:
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def _label(row, protocol) -> str:
state = "enabled" if row[1] else "off"
details = ", ".join(f"{key}={value}" for key, value in row[3])
suffix = f" {details}" if details else ""
return f"{protocol.info.title:<18} {state:<8} {row[2]:<6} {protocol.info.transport}{suffix}"
def show(app: "App") -> None:
while True:
rows = protocols_op.summary(app.paths)
labels = [_label(row, protocols.get(row[0])) for row in rows]
labels.append("Back")
choice = app.term.menu("Protocols", labels, subtitle="select a protocol to manage")
if choice is None or choice == len(rows):
return
_detail(app, rows[choice][0])
def _detail(app: "App", key: str) -> None:
while True:
row = next(item for item in protocols_op.summary(app.paths) if item[0] == key)
protocol = protocols.get(key)
enabled = row[1]
actions = [
"Disable" if enabled else "Enable",
"Change port",
"Edit options",
"Show settings",
"Back",
]
choice = app.term.menu(
protocol.info.title,
actions,
subtitle=f"{'enabled' if enabled else 'off'} port {row[2]} {protocol.info.transport}",
)
if choice is None or choice == 4:
return
if choice == 0:
if enabled:
app.term.run_external("Disable", lambda progress: protocols_op.disable(app.paths, key))
else:
port_text = app.term.prompt("Enable", "Port (blank = auto)", "")
port = int(port_text) if port_text and port_text.isdigit() else None
app.term.run_external(
"Enable", lambda progress: protocols_op.enable(app.paths, key, port=port)
)
elif choice == 1:
current = str(row[2])
value = app.term.prompt("Change port", "Port", current)
if value and value.isdigit():
app.term.run_external(
"Change port", lambda progress: protocols_op.set_port(app.paths, key, int(value))
)
elif choice == 2:
_edit_options(app, key)
elif choice == 3:
lines = [f"{name}: {value}" for name, value in row[3]] or ["no editable options"]
app.term.scroll_text(protocol.info.title, "\n".join(lines))
def _edit_options(app: "App", key: str) -> None:
protocol = protocols.get(key)
editable = protocol.editable()
if not editable:
app.term.message("Options", "this protocol has no editable options")
return
current = protocols_op.options(app.paths, key)
labels = [f"{label}: {_show(current.get(name, ''))}" for name, label, _ in editable]
labels.append("Back")
choice = app.term.menu("Edit options", labels)
if choice is None or choice == len(editable):
return
name, label, _kind = editable[choice]
value = app.term.prompt("Edit option", label, _show(current.get(name, "")))
if value is None:
return
app.term.run_external(
"Apply option",
lambda progress: protocols_op.set_option(app.paths, key, name, value),
)
+98
View File
@@ -0,0 +1,98 @@
"""Server settings, upgrade, and uninstall."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ...ops import install as install_op
from ...ops import settings as settings_op
from ...ops import uninstall as uninstall_op
from ...ops import upgrade as upgrade_op
if TYPE_CHECKING:
from ..app import App
ACTIONS = (
"Show settings",
"Set host",
"Set SNI",
"Use custom certificate",
"Renew certificate",
"Upgrade sing-box",
"Reinstall",
"Uninstall",
"Back",
)
def show(app: "App") -> None:
while True:
status = app.status()
choice = app.term.menu(
"Settings",
list(ACTIONS),
subtitle=f"{status.host} sni {status.sni}",
)
if choice is None or choice == 8:
return
if choice == 0:
app.term.scroll_text(
"Settings",
"\n".join([
f"Host {status.host}",
f"SNI {status.sni}",
f"TLS {status.tls_mode}",
f"Cert until {status.cert_until}",
f"Config {status.config_path}",
f"Created {status.created}",
]),
)
elif choice == 1:
value = app.term.prompt("Set host", "Client-facing host", status.host)
if value:
app.term.run_external(
"Set host", lambda progress: settings_op.set_host(app.paths, value)
)
elif choice == 2:
value = app.term.prompt("Set SNI", "TLS server name", status.sni)
if value:
app.term.run_external(
"Set SNI", lambda progress: settings_op.set_sni(app.paths, value)
)
elif choice == 3:
cert = app.term.prompt("Custom certificate", "Certificate path (PEM)", "")
if not cert:
continue
key = app.term.prompt("Custom certificate", "Private key path (PEM)", "")
if not key:
continue
sni = app.term.prompt("Custom certificate", "SNI", status.sni)
app.term.run_external(
"Use custom certificate",
lambda progress: settings_op.set_cert(app.paths, cert, key, sni or None),
)
elif choice == 4:
app.term.run_external(
"Renew certificate", lambda progress: settings_op.regenerate_cert(app.paths)
)
elif choice == 5:
app.term.run_external(
"Upgrade sing-box", lambda progress: upgrade_op.upgrade(app.paths, progress=progress)
)
elif choice == 6:
if app.term.confirm("Reinstall", "Reinstall sing-box and regenerate the service?"):
options = install_op.InstallOptions(force=True)
app.term.run_external(
"Reinstall",
lambda progress: install_op.install(app.paths, options, progress=progress),
)
elif choice == 7:
if app.term.confirm("Uninstall", "Stop and remove the service and binary?"):
purge = app.term.confirm(
"Uninstall",
"Also delete configuration, users, and certificates?",
)
app.term.run_external(
"Uninstall", lambda progress: uninstall_op.uninstall(app.paths, purge=purge)
)
return
+60
View File
@@ -0,0 +1,60 @@
"""User list, share links, and client configs."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ... import protocols
from ...ops import show as show_op
from ...ops import users as users_op
if TYPE_CHECKING:
from ..app import App
def show(app: "App") -> None:
while True:
users = users_op.list_users(app.paths)
labels = [f"{user.name:<20} {user.created[:10]}" for user in users]
labels.append("+ Add user")
labels.append("Back")
choice = app.term.menu("Users", labels, subtitle=f"{len(users)} users")
if choice is None or choice == len(users) + 1:
return
if choice == len(users):
name = app.term.prompt("Add user", "User name", "")
if name:
app.term.run_external("Add user", lambda progress: users_op.add(app.paths, name))
continue
_detail(app, users[choice].name)
def _detail(app: "App", name: str) -> None:
while True:
choice = app.term.menu(
f"User {name}",
["Show share URIs", "Show client config", "Delete user", "Back"],
)
if choice is None or choice == 3:
return
if choice == 0:
uris = show_op.user_uris(app.paths, name)
text = "\n\n".join(f"{key}\n {uri}" for key, uri in uris) or "no enabled protocols"
app.term.scroll_text(f"Share URIs: {name}", text)
elif choice == 1:
enabled = [row.key for row in app.status().protocols if row.enabled]
if not enabled:
app.term.message("Client config", "no enabled protocols")
continue
labels = [protocols.get(key).info.title for key in enabled] + ["Back"]
picked = app.term.menu("Client config", labels)
if picked is None or picked == len(enabled):
continue
app.term.scroll_text(
f"Client config: {name} / {enabled[picked]}",
show_op.client_config_json(app.paths, name, enabled[picked]),
)
elif choice == 2:
if app.term.confirm("Delete user", f"Delete {name}? Clients using this user stop working."):
app.term.run_external("Delete user", lambda progress: users_op.remove(app.paths, name))
return
+279
View File
@@ -0,0 +1,279 @@
"""Curses widgets: menus, prompts, dialogs, and text paging.
Only this module touches curses. Screens call these helpers and never draw
raw escape sequences themselves.
"""
from __future__ import annotations
import curses
import textwrap
import unicodedata
from typing import Callable, Optional
ACCENT = 1
GOOD = 2
WARN = 3
BAD = 4
DIM = 5
def display_width(text: str) -> int:
width = 0
for char in text:
if unicodedata.combining(char):
continue
width += 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
return width
def truncate(text: str, width: int) -> str:
if display_width(text) <= width:
return text
result = ""
used = 0
for char in text:
size = 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
if used + size > width - 1:
break
result += char
used += size
return result + "…"
def pad(text: str, width: int) -> str:
return text + " " * max(0, width - display_width(text))
def wrap_display(text: str, width: int) -> list[str]:
"""Split text into lines that fit the display width, breaking anywhere."""
if width <= 0:
return [text]
lines: list[str] = []
current = ""
used = 0
for char in text:
size = 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
if used + size > width and current:
lines.append(current)
current = ""
used = 0
current += char
used += size
lines.append(current)
return lines
class Term:
def __init__(self, screen) -> None:
self.screen = screen
self.has_color = curses.has_colors()
if self.has_color:
curses.start_color()
try:
curses.use_default_colors()
except curses.error:
pass
curses.init_pair(ACCENT, curses.COLOR_CYAN, -1)
curses.init_pair(GOOD, curses.COLOR_GREEN, -1)
curses.init_pair(WARN, curses.COLOR_YELLOW, -1)
curses.init_pair(BAD, curses.COLOR_RED, -1)
curses.init_pair(DIM, curses.COLOR_WHITE, -1)
self.screen.keypad(True)
try:
curses.curs_set(0)
except curses.error:
pass
def attr(self, pair: int, extra: int = 0) -> int:
if not self.has_color:
return extra
return curses.color_pair(pair) | extra
def size(self) -> tuple[int, int]:
height, width = self.screen.getmaxyx()
return height, width
def put(self, y: int, x: int, text: str, attr: int = 0) -> None:
height, width = self.size()
if y < 0 or y >= height or x >= width:
return
try:
self.screen.addstr(y, x, truncate(text, max(0, width - x - 1)), attr)
except curses.error:
pass
def clear(self) -> None:
self.screen.erase()
def refresh(self) -> None:
self.screen.refresh()
def header(self, title: str, subtitle: str = "") -> int:
height, width = self.size()
self.put(0, 1, title, self.attr(ACCENT, curses.A_BOLD))
if subtitle:
self.put(0, max(2, width - display_width(subtitle) - 2), subtitle, self.attr(DIM))
self.put(1, 0, "─" * max(1, width - 1), self.attr(DIM))
return 3
def footer(self, text: str) -> None:
height, width = self.size()
self.put(height - 2, 0, "─" * max(1, width - 1), self.attr(DIM))
self.put(height - 1, 1, text, self.attr(DIM))
def message(self, title: str, text: str, kind: str = "info") -> None:
pair = {"info": ACCENT, "good": GOOD, "warn": WARN, "error": BAD}.get(kind, ACCENT)
self.clear()
height, width = self.size()
y = self.header(title)
lines: list[str] = []
for paragraph in text.splitlines() or [""]:
lines.extend(textwrap.wrap(paragraph, max(20, width - 6)) or [""])
for line in lines[: max(1, height - 7)]:
self.put(y, 3, line, self.attr(pair))
y += 1
self.footer(" press any key ")
self.refresh()
self.screen.getch()
def confirm(self, title: str, text: str) -> bool:
items = ["Yes", "No"]
return self.menu(title, items, subtitle=text, start=1) == 0
def prompt(self, title: str, label: str, default: str = "", secret: bool = False) -> Optional[str]:
self.clear()
height, width = self.size()
self.header(title)
self.put(3, 3, label)
if default and not secret:
self.put(3, 3 + display_width(label) + 1, f"[{default}]", self.attr(DIM))
self.put(height - 1, 1, "Enter accept Esc cancel", self.attr(DIM))
self.refresh()
try:
curses.curs_set(1)
except curses.error:
pass
if secret:
curses.noecho()
else:
curses.echo()
input_x = 3 + display_width(label) + (
display_width(default) + 3 if default and not secret else 1
)
try:
self.screen.move(
min(3, max(0, height - 2)), min(max(0, input_x), max(0, width - 1))
)
except curses.error:
pass
try:
raw = self.screen.getstr()
except (curses.error, KeyboardInterrupt):
raw = b""
finally:
curses.noecho()
try:
curses.curs_set(0)
except curses.error:
pass
if raw == b"\x1b" or b"\x1b" in raw:
return None
value = raw.decode(errors="replace").strip()
if value == "" and default:
return default
return value or None
def menu(
self,
title: str,
items: list[str],
subtitle: str = "",
footer: str = "↑/↓ select Enter open q back",
start: int = 0,
) -> Optional[int]:
index = min(max(start, 0), max(0, len(items) - 1))
while True:
self.clear()
height, width = self.size()
y = self.header(title, subtitle)
visible = max(1, height - 6)
offset = max(0, index - visible + 1)
for row, item in enumerate(items[offset: offset + visible]):
absolute = offset + row
selected = absolute == index
marker = "▸ " if selected else " "
attr = self.attr(ACCENT, curses.A_BOLD) if selected else 0
self.put(y + row, 2, marker + item, attr)
self.footer(footer)
self.refresh()
key = self.screen.getch()
if key == -1:
return None
if key in (curses.KEY_UP, ord("k")):
index = (index - 1) % len(items)
elif key in (curses.KEY_DOWN, ord("j")):
index = (index + 1) % len(items)
elif key in (10, 13, curses.KEY_ENTER, ord(" ")):
return index
elif key in (ord("q"), 27):
return None
elif key == curses.KEY_RESIZE:
continue
elif ord("1") <= key <= ord("9"):
candidate = key - ord("1")
if candidate < len(items):
return candidate
def scroll_text(self, title: str, text: str, subtitle: str = "") -> None:
height, width = self.size()
available = max(1, width - 4)
lines: list[str] = []
for source in text.splitlines() or [""]:
lines.extend(wrap_display(source, available))
top = 0
while True:
self.clear()
height, width = self.size()
y = self.header(title, subtitle)
body = max(1, height - 5)
for row in range(body):
position = top + row
if position >= len(lines):
break
self.put(y + row, 2, lines[position])
self.footer("↑/↓ scroll PgUp/PgDn q back")
self.refresh()
key = self.screen.getch()
if key == -1 or key in (ord("q"), 27):
return
if key in (curses.KEY_UP, ord("k")):
top = max(0, top - 1)
elif key in (curses.KEY_DOWN, ord("j")):
top = min(max(0, len(lines) - body), top + 1)
elif key == curses.KEY_PPAGE:
top = max(0, top - body)
elif key == curses.KEY_NPAGE:
top = min(max(0, len(lines) - body), top + body)
def run_external(self, title: str, action: Callable[[Callable[[str], None]], None]) -> bool:
"""Leave curses, run action, wait for Enter, then restore curses."""
try:
curses.def_prog_mode()
curses.endwin()
print(f"\n== {title} ==\n")
try:
action(lambda message: print(f"==> {message}", flush=True))
ok = True
except Exception as exc: # noqa: BLE001 - shown to the user
print(f"\nerror: {exc}")
ok = False
print("\npress Enter to return")
try:
input()
except EOFError:
pass
finally:
curses.reset_prog_mode()
self.screen.refresh()
return ok