重写为多协议管理器: 6 协议, CLI + curses TUI, 用户级 systemd
This commit is contained in:
1 parent
c43ab4a16e
commit
237b958e41
71 files changed
+4599
-1171
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,41 @@
|
||||
import unittest
|
||||
|
||||
from sbp import commands, protocols
|
||||
from sbp.cli import build_parser
|
||||
|
||||
|
||||
class ParserTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.parser = build_parser()
|
||||
|
||||
def test_every_command_has_a_module(self) -> None:
|
||||
sub = next(
|
||||
action for action in self.parser._actions if action.dest == "command"
|
||||
)
|
||||
registered = set(sub.choices)
|
||||
self.assertEqual(registered, set(commands.COMMANDS))
|
||||
|
||||
def test_protocol_choices_match_registry(self) -> None:
|
||||
args = self.parser.parse_args(["protocol", "enable", "hysteria2", "--port", "24000"])
|
||||
self.assertEqual(args.command, "protocol")
|
||||
self.assertEqual(args.action, "enable")
|
||||
self.assertEqual(args.port, 24000)
|
||||
self.assertEqual(set(protocols.keys()), set(protocols.REGISTRY))
|
||||
|
||||
def test_user_show_options(self) -> None:
|
||||
args = self.parser.parse_args(
|
||||
["user", "show", "phone", "--protocol", "trojan", "--json"]
|
||||
)
|
||||
self.assertEqual(args.name, "phone")
|
||||
self.assertEqual(args.protocol, "trojan")
|
||||
self.assertTrue(args.json)
|
||||
|
||||
def test_install_defaults(self) -> None:
|
||||
args = self.parser.parse_args(["install"])
|
||||
self.assertEqual(args.base_port, 20000)
|
||||
self.assertFalse(args.force)
|
||||
self.assertIsNone(args.protocols)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,138 @@
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from sbp.config import build_client_config, build_server_config
|
||||
from sbp.errors import SbpError
|
||||
from sbp.share import all_uris, share_uri
|
||||
from tests.test_state import SERVER_SS_KEY, USER_SS_KEY, sample_state
|
||||
|
||||
|
||||
class ServerConfigTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.state = sample_state()
|
||||
self.config = build_server_config(self.state)
|
||||
|
||||
def test_all_protocols_present(self) -> None:
|
||||
tags = {inbound["tag"] for inbound in self.config["inbounds"]}
|
||||
self.assertEqual(
|
||||
tags,
|
||||
{"hysteria2-in", "vless-reality-in", "trojan-in", "shadowsocks-in", "tuic-in", "anytls-in"},
|
||||
)
|
||||
|
||||
def test_disabled_protocol_is_skipped(self) -> None:
|
||||
self.state.protocols["trojan"].enabled = False
|
||||
tags = {inbound["tag"] for inbound in build_server_config(self.state)["inbounds"]}
|
||||
self.assertNotIn("trojan-in", tags)
|
||||
|
||||
def test_hysteria2_tls_and_obfs(self) -> None:
|
||||
inbound = next(i for i in self.config["inbounds"] if i["tag"] == "hysteria2-in")
|
||||
self.assertEqual(inbound["tls"]["alpn"], ["h3"])
|
||||
self.assertEqual(inbound["obfs"]["type"], "salamander")
|
||||
self.assertEqual(inbound["users"][0]["password"], "PASSWORD")
|
||||
|
||||
def test_reality_uses_private_key(self) -> None:
|
||||
inbound = next(i for i in self.config["inbounds"] if i["tag"] == "vless-reality-in")
|
||||
self.assertEqual(inbound["tls"]["reality"]["private_key"], "PRIVATE")
|
||||
self.assertEqual(inbound["tls"]["reality"]["handshake"]["server"], "www.bing.com")
|
||||
|
||||
def test_shadowsocks_multi_user(self) -> None:
|
||||
inbound = next(i for i in self.config["inbounds"] if i["tag"] == "shadowsocks-in")
|
||||
self.assertEqual(inbound["password"], SERVER_SS_KEY)
|
||||
self.assertEqual(inbound["users"][0]["password"], USER_SS_KEY)
|
||||
|
||||
def test_shadowsocks_client_sends_identity_and_user_key(self) -> None:
|
||||
config = build_client_config(self.state, "shadowsocks", self.state.users[0])
|
||||
self.assertEqual(
|
||||
config["outbounds"][0]["password"], f"{SERVER_SS_KEY}:{USER_SS_KEY}"
|
||||
)
|
||||
|
||||
def test_config_is_json_serializable(self) -> None:
|
||||
json.dumps(self.config)
|
||||
|
||||
def test_reality_rejects_ip_server_name(self) -> None:
|
||||
self.state.protocols["vless-reality"].options["server_name"] = "203.0.113.10"
|
||||
with self.assertRaises(SbpError):
|
||||
build_server_config(self.state)
|
||||
|
||||
def test_reality_rejects_odd_length_short_id(self) -> None:
|
||||
self.state.protocols["vless-reality"].options["short_id"] = "abc"
|
||||
with self.assertRaises(SbpError):
|
||||
build_server_config(self.state)
|
||||
|
||||
def test_reality_accepts_even_short_id(self) -> None:
|
||||
self.state.protocols["vless-reality"].options["short_id"] = "ab12"
|
||||
build_server_config(self.state)
|
||||
|
||||
|
||||
class ClientConfigTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.state = sample_state()
|
||||
self.user = self.state.users[0]
|
||||
|
||||
def test_disabled_protocol_raises_sbp_error(self) -> None:
|
||||
self.state.protocols["tuic"].enabled = False
|
||||
with self.assertRaises(SbpError):
|
||||
build_client_config(self.state, "tuic", self.user)
|
||||
with self.assertRaises(SbpError):
|
||||
share_uri(self.state, "tuic", self.user)
|
||||
|
||||
def test_each_client_config_routes_to_outbound(self) -> None:
|
||||
for key in self.state.protocols:
|
||||
config = build_client_config(self.state, key, self.user)
|
||||
self.assertEqual(config["route"]["final"], config["outbounds"][0]["tag"])
|
||||
json.dumps(config)
|
||||
|
||||
def test_self_signed_pins_public_key(self) -> None:
|
||||
config = build_client_config(self.state, "trojan", self.user)
|
||||
self.assertEqual(
|
||||
config["outbounds"][0]["tls"]["certificate_public_key_sha256"], ["SPKI"]
|
||||
)
|
||||
|
||||
def test_reality_does_not_pin(self) -> None:
|
||||
config = build_client_config(self.state, "vless-reality", self.user)
|
||||
tls = config["outbounds"][0]["tls"]
|
||||
self.assertNotIn("certificate_public_key_sha256", tls)
|
||||
self.assertEqual(tls["reality"]["public_key"], "PUBLIC")
|
||||
|
||||
|
||||
class ShareUriTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.state = sample_state()
|
||||
self.user = self.state.users[0]
|
||||
|
||||
def test_uri_prefixes(self) -> None:
|
||||
prefixes = {
|
||||
"hysteria2": "hysteria2://",
|
||||
"vless-reality": "vless://",
|
||||
"trojan": "trojan://",
|
||||
"shadowsocks": "ss://",
|
||||
"tuic": "tuic://",
|
||||
"anytls": "anytls://",
|
||||
}
|
||||
for key, prefix in prefixes.items():
|
||||
self.assertTrue(share_uri(self.state, key, self.user).startswith(prefix), key)
|
||||
|
||||
def test_hysteria2_pin_and_obfs(self) -> None:
|
||||
uri = share_uri(self.state, "hysteria2", self.user)
|
||||
self.assertIn("pinSHA256=SHA256", uri)
|
||||
self.assertIn("obfs=salamander", uri)
|
||||
|
||||
def test_vless_reality_parameters(self) -> None:
|
||||
uri = share_uri(self.state, "vless-reality", self.user)
|
||||
self.assertIn("security=reality", uri)
|
||||
self.assertIn("pbk=PUBLIC", uri)
|
||||
self.assertIn("flow=xtls-rprx-vision", uri)
|
||||
|
||||
def test_shadowsocks_uses_plain_userinfo(self) -> None:
|
||||
from urllib.parse import unquote
|
||||
|
||||
uri = share_uri(self.state, "shadowsocks", self.user)
|
||||
userinfo = unquote(uri.removeprefix("ss://").split("@", 1)[0])
|
||||
self.assertEqual(userinfo, f"2022-blake3-aes-128-gcm:{SERVER_SS_KEY}:{USER_SS_KEY}")
|
||||
|
||||
def test_all_uris(self) -> None:
|
||||
self.assertEqual(len(all_uris(self.state, self.user)), 6)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,59 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from sbp import hostinfo
|
||||
from sbp.errors import SbpError
|
||||
from sbp.fsutil import safe_rmtree
|
||||
from sbp.state import StateStore
|
||||
|
||||
|
||||
class PickPortTest(unittest.TestCase):
|
||||
def test_stops_at_65535(self) -> None:
|
||||
with mock.patch("sbp.hostinfo.port_free", return_value=False) as probe:
|
||||
with self.assertRaises(SbpError):
|
||||
hostinfo.pick_port("127.0.0.1", set(), start=65535)
|
||||
self.assertEqual(probe.call_count, 1)
|
||||
|
||||
def test_skips_used_ports(self) -> None:
|
||||
with mock.patch("sbp.hostinfo.port_free", return_value=True):
|
||||
self.assertEqual(hostinfo.pick_port("127.0.0.1", {20000, 20001}, start=20000), 20002)
|
||||
|
||||
|
||||
class SafeRmtreeTest(unittest.TestCase):
|
||||
def test_refuses_marker_outside_target(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
target = root / "data"
|
||||
target.mkdir()
|
||||
marker = root / "elsewhere"
|
||||
marker.write_text("x")
|
||||
with self.assertRaises(SbpError):
|
||||
safe_rmtree(target, marker)
|
||||
self.assertTrue(target.exists())
|
||||
|
||||
def test_removes_when_marker_is_inside(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
target = Path(tmp) / "data"
|
||||
(target / "bin").mkdir(parents=True)
|
||||
marker = target / "bin" / "sing-box"
|
||||
marker.write_text("x")
|
||||
safe_rmtree(target, marker)
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
|
||||
class TryLoadTest(unittest.TestCase):
|
||||
def test_corrupt_state_returns_none(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = StateStore(Path(tmp))
|
||||
store.path.write_text("{not json")
|
||||
self.assertIsNone(store.try_load())
|
||||
|
||||
def test_missing_state_returns_none(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self.assertIsNone(StateStore(Path(tmp)).try_load())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,158 @@
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from sbp import net, paths as path_module, tls
|
||||
from sbp.errors import SbpError
|
||||
from sbp.ops import install as install_op
|
||||
from sbp.ops import protocols as protocols_op
|
||||
from sbp.ops import upgrade as upgrade_op
|
||||
from sbp.ops import users as users_op
|
||||
from sbp.state import RealityState, StateStore
|
||||
|
||||
|
||||
class InstallTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp = Path(tempfile.mkdtemp(prefix="sbp-test-"))
|
||||
self.cert = self.tmp / "cert.pem"
|
||||
self.key = self.tmp / "key.pem"
|
||||
tls.generate("www.bing.com", self.cert, self.key)
|
||||
self.env = mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"SBP_CONFIG_DIR": str(self.tmp / "config"),
|
||||
"SBP_DATA_DIR": str(self.tmp / "data"),
|
||||
"SBP_UNIT_DIR": str(self.tmp / "units"),
|
||||
},
|
||||
)
|
||||
self.env.start()
|
||||
self.patchers = [
|
||||
mock.patch("sbp.ops.install.hostinfo.check_platform"),
|
||||
mock.patch("sbp.ops.install.hostinfo.warnings", return_value=[]),
|
||||
mock.patch("sbp.ops.install.hostinfo.detect_host", return_value="203.0.113.10"),
|
||||
mock.patch("sbp.ops.install.hostinfo.ipv6_available", return_value=True),
|
||||
mock.patch("sbp.ops.install.hostinfo.default_listen", return_value="::"),
|
||||
mock.patch("sbp.ops.install.net.get_release", return_value=net.Release("v1.14.4", [])),
|
||||
mock.patch("sbp.singbox.install_binary", side_effect=self._install_binary),
|
||||
mock.patch("sbp.singbox.check_config"),
|
||||
mock.patch(
|
||||
"sbp.singbox.reality_keypair",
|
||||
return_value=RealityState(private_key="PRIVATE", public_key="PUBLIC"),
|
||||
),
|
||||
mock.patch("sbp.ops.common.restart"),
|
||||
mock.patch("sbp.service.enable"),
|
||||
mock.patch("sbp.service.active", return_value=True),
|
||||
]
|
||||
for patcher in self.patchers:
|
||||
patcher.start()
|
||||
self.paths = path_module.load()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
for patcher in self.patchers:
|
||||
patcher.stop()
|
||||
self.env.stop()
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def _install_binary(self, paths, release, current_config=None, progress=None):
|
||||
paths.binary.parent.mkdir(parents=True, exist_ok=True)
|
||||
paths.binary.write_text("#!/bin/sh\nexit 0\n")
|
||||
os.chmod(paths.binary, 0o755)
|
||||
|
||||
def test_install_creates_state_config_and_unit(self) -> None:
|
||||
state = install_op.install(
|
||||
self.paths,
|
||||
install_op.InstallOptions(host="203.0.113.10", cert=str(self.cert), key=str(self.key), sni="www.bing.com"),
|
||||
)
|
||||
self.assertTrue(self.paths.state_file.is_file())
|
||||
self.assertTrue(self.paths.server_config.is_file())
|
||||
self.assertTrue(self.paths.unit_file.is_file())
|
||||
self.assertEqual(len(state.users), 1)
|
||||
self.assertEqual(set(state.protocols), {"hysteria2", "vless-reality", "trojan", "shadowsocks", "tuic", "anytls"})
|
||||
ports = [proto.port for proto in state.protocols.values()]
|
||||
self.assertEqual(len(ports), len(set(ports)))
|
||||
self.assertTrue(all(port >= 20000 for port in ports))
|
||||
|
||||
def test_add_and_remove_user(self) -> None:
|
||||
install_op.install(self.paths, install_op.InstallOptions(host="203.0.113.10", cert=str(self.cert), key=str(self.key)))
|
||||
users_op.add(self.paths, "phone")
|
||||
store = path_module.load()
|
||||
state = StateStore(store.config_dir).load()
|
||||
self.assertEqual([user.name for user in state.users], ["u1", "phone"])
|
||||
users_op.remove(self.paths, "phone")
|
||||
state = StateStore(store.config_dir).load()
|
||||
self.assertEqual([user.name for user in state.users], ["u1"])
|
||||
|
||||
def test_cannot_remove_last_user(self) -> None:
|
||||
install_op.install(self.paths, install_op.InstallOptions(host="203.0.113.10", cert=str(self.cert), key=str(self.key)))
|
||||
with self.assertRaises(SbpError):
|
||||
users_op.remove(self.paths, "u1")
|
||||
|
||||
def test_disable_protocol_updates_config(self) -> None:
|
||||
import json
|
||||
|
||||
install_op.install(self.paths, install_op.InstallOptions(host="203.0.113.10", cert=str(self.cert), key=str(self.key)))
|
||||
protocols_op.disable(self.paths, "trojan")
|
||||
config = json.loads(self.paths.server_config.read_text())
|
||||
tags = {inbound["tag"] for inbound in config["inbounds"]}
|
||||
self.assertNotIn("trojan-in", tags)
|
||||
protocols_op.enable(self.paths, "trojan")
|
||||
config = json.loads(self.paths.server_config.read_text())
|
||||
tags = {inbound["tag"] for inbound in config["inbounds"]}
|
||||
self.assertIn("trojan-in", tags)
|
||||
|
||||
def test_set_port_rejects_port_of_another_protocol(self) -> None:
|
||||
state = install_op.install(
|
||||
self.paths, install_op.InstallOptions(host="203.0.113.10", cert=str(self.cert), key=str(self.key))
|
||||
)
|
||||
taken = state.protocols["trojan"].port
|
||||
with self.assertRaises(SbpError):
|
||||
protocols_op.set_port(self.paths, "vless-reality", taken)
|
||||
|
||||
def test_upgrade_restores_missing_binary(self) -> None:
|
||||
install_op.install(
|
||||
self.paths, install_op.InstallOptions(host="203.0.113.10", cert=str(self.cert), key=str(self.key))
|
||||
)
|
||||
self.paths.binary.unlink()
|
||||
current, target = upgrade_op.upgrade(self.paths)
|
||||
self.assertTrue(self.paths.binary.is_file())
|
||||
self.assertEqual(target, "v1.14.4")
|
||||
self.assertNotEqual(current, "missing")
|
||||
|
||||
def test_set_port_to_current_port_is_noop(self) -> None:
|
||||
state = install_op.install(
|
||||
self.paths, install_op.InstallOptions(host="203.0.113.10", cert=str(self.cert), key=str(self.key))
|
||||
)
|
||||
port = state.protocols["trojan"].port
|
||||
protocols_op.set_port(self.paths, "trojan", port)
|
||||
reloaded = StateStore(self.paths.config_dir).load()
|
||||
self.assertEqual(reloaded.protocols["trojan"].port, port)
|
||||
|
||||
def test_reinstall_rejects_without_force(self) -> None:
|
||||
install_op.install(self.paths, install_op.InstallOptions(host="203.0.113.10", cert=str(self.cert), key=str(self.key)))
|
||||
with self.assertRaises(Exception):
|
||||
install_op.install(self.paths, install_op.InstallOptions(host="203.0.113.10"))
|
||||
|
||||
def test_reinstall_keeps_custom_certificate(self) -> None:
|
||||
install_op.install(
|
||||
self.paths,
|
||||
install_op.InstallOptions(host="203.0.113.10", cert=str(self.cert), key=str(self.key), sni="www.bing.com"),
|
||||
)
|
||||
state = install_op.install(self.paths, install_op.InstallOptions(force=True))
|
||||
self.assertEqual(state.tls.mode, "custom")
|
||||
self.assertEqual(state.tls.cert, str(self.cert.resolve()))
|
||||
|
||||
def test_reinstall_keeps_ports(self) -> None:
|
||||
first = install_op.install(
|
||||
self.paths,
|
||||
install_op.InstallOptions(host="203.0.113.10", cert=str(self.cert), key=str(self.key)),
|
||||
)
|
||||
ports = {key: proto.port for key, proto in first.protocols.items()}
|
||||
second = install_op.install(self.paths, install_op.InstallOptions(force=True))
|
||||
self.assertEqual({key: proto.port for key, proto in second.protocols.items()}, ports)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,44 @@
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from sbp import net
|
||||
|
||||
ASSETS = [
|
||||
{"name": "sing-box-1.14.4-linux-amd64-musl.tar.gz"},
|
||||
{"name": "sing-box-1.14.4-linux-amd64.tar.gz"},
|
||||
{"name": "sing-box-1.14.4-linux-arm64.tar.gz"},
|
||||
{"name": "sing-box-1.14.4-linux-amd64.deb"},
|
||||
]
|
||||
|
||||
|
||||
def pick(machine: str, libc: tuple[str, str]) -> str:
|
||||
with mock.patch("platform.machine", return_value=machine), mock.patch(
|
||||
"platform.libc_ver", return_value=libc
|
||||
):
|
||||
return net.asset_for(ASSETS, "v1.14.4")["name"]
|
||||
|
||||
|
||||
class AssetTest(unittest.TestCase):
|
||||
def test_glibc_prefers_plain_build(self) -> None:
|
||||
self.assertEqual(pick("x86_64", ("glibc", "2.36")), "sing-box-1.14.4-linux-amd64.tar.gz")
|
||||
|
||||
def test_musl_prefers_musl_build(self) -> None:
|
||||
self.assertEqual(
|
||||
pick("x86_64", ("musl", "1.2")), "sing-box-1.14.4-linux-amd64-musl.tar.gz"
|
||||
)
|
||||
|
||||
def test_arm64_maps_to_arm64(self) -> None:
|
||||
self.assertEqual(pick("aarch64", ("glibc", "2.36")), "sing-box-1.14.4-linux-arm64.tar.gz")
|
||||
|
||||
def test_unknown_arch_fails(self) -> None:
|
||||
with self.assertRaises(Exception):
|
||||
pick("riscv64", ("glibc", "2.36"))
|
||||
|
||||
|
||||
class DigestTest(unittest.TestCase):
|
||||
def test_missing_digest_is_not_verified(self) -> None:
|
||||
self.assertFalse(net.verify_digest(__file__, {}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,114 @@
|
||||
import base64
|
||||
import unittest
|
||||
|
||||
from sbp.state import ProtocolState, RealityState, State, TlsState, User, migrate
|
||||
|
||||
USER_UUID = "11111111-2222-3333-4444-555555555555"
|
||||
USER_SS_KEY = base64.b64encode(b"0123456789abcdef").decode()
|
||||
SERVER_SS_KEY = base64.b64encode(b"abcdef0123456789").decode()
|
||||
|
||||
|
||||
def sample_state() -> State:
|
||||
return State(
|
||||
schema=2,
|
||||
created="2026-01-01T00:00:00Z",
|
||||
host="203.0.113.10",
|
||||
sni="www.bing.com",
|
||||
tls=TlsState(
|
||||
mode="self-signed",
|
||||
cert="/tmp/cert.pem",
|
||||
key="/tmp/key.pem",
|
||||
sni="www.bing.com",
|
||||
self_signed=True,
|
||||
spki_pin="SPKI",
|
||||
cert_sha256="SHA256",
|
||||
),
|
||||
reality=RealityState(private_key="PRIVATE", public_key="PUBLIC"),
|
||||
users=[User("u1", "PASSWORD", USER_UUID, USER_SS_KEY, "2026-01-01T00:00:00Z")],
|
||||
protocols={
|
||||
"hysteria2": ProtocolState(
|
||||
True, 21001, "::", {"obfs": True, "obfs_password": "OBFS", "masquerade": ""}
|
||||
),
|
||||
"vless-reality": ProtocolState(
|
||||
True, 21002, "::", {"server_name": "www.bing.com", "short_id": "", "flow": "xtls-rprx-vision"}
|
||||
),
|
||||
"trojan": ProtocolState(True, 21003, "::", {}),
|
||||
"shadowsocks": ProtocolState(
|
||||
True, 21004, "::", {"method": "2022-blake3-aes-128-gcm", "server_key": SERVER_SS_KEY}
|
||||
),
|
||||
"tuic": ProtocolState(True, 21005, "::", {"congestion_control": "bbr"}),
|
||||
"anytls": ProtocolState(True, 21006, "::", {}),
|
||||
},
|
||||
singbox_version="v1.14.4",
|
||||
)
|
||||
|
||||
|
||||
class StateRoundTripTest(unittest.TestCase):
|
||||
def test_round_trip(self) -> None:
|
||||
state = sample_state()
|
||||
restored = State.from_dict(state.to_dict())
|
||||
self.assertEqual(restored.host, state.host)
|
||||
self.assertEqual(restored.sni, state.sni)
|
||||
self.assertEqual(restored.users[0].uuid, USER_UUID)
|
||||
self.assertEqual(restored.protocols["hysteria2"].port, 21001)
|
||||
self.assertEqual(restored.reality.public_key, "PUBLIC")
|
||||
|
||||
def test_migrate_schema_one(self) -> None:
|
||||
raw = {
|
||||
"schema": 1,
|
||||
"created": "2025-01-01T00:00:00Z",
|
||||
"host": "198.51.100.1",
|
||||
"sni": "www.example.com",
|
||||
"listen": "::",
|
||||
"port": 23456,
|
||||
"tls": {"mode": "self-signed", "self_signed": True, "cert": "/c", "key": "/k",
|
||||
"sni": "www.example.com", "spki_pin": "S", "cert_sha256": "C"},
|
||||
"obfs": {"enabled": True, "password": "O"},
|
||||
"users": [{"name": "u1", "password": "P", "created": "2025-01-01T00:00:00Z"}],
|
||||
"version": "v1.10.0",
|
||||
}
|
||||
migrated = migrate(raw)
|
||||
state = State.from_dict(migrated)
|
||||
self.assertEqual(state.schema, 2)
|
||||
self.assertEqual(state.protocols["hysteria2"].port, 23456)
|
||||
options = state.protocols["hysteria2"].options
|
||||
self.assertIs(options["obfs"], True)
|
||||
self.assertEqual(options["obfs_password"], "O")
|
||||
self.assertEqual(state.users[0].name, "u1")
|
||||
self.assertTrue(state.users[0].uuid)
|
||||
self.assertTrue(state.users[0].ss_key)
|
||||
self.assertEqual(state.singbox_version, "v1.10.0")
|
||||
|
||||
def test_migrated_state_builds_config_and_uri(self) -> None:
|
||||
from sbp.config import build_server_config
|
||||
from sbp.share import share_uri
|
||||
|
||||
raw = {
|
||||
"schema": 1,
|
||||
"host": "198.51.100.1",
|
||||
"sni": "www.example.com",
|
||||
"listen": "::",
|
||||
"port": 23456,
|
||||
"tls": {"mode": "self-signed", "self_signed": True, "cert": "/c", "key": "/k",
|
||||
"sni": "www.example.com", "spki_pin": "S", "cert_sha256": "C"},
|
||||
"obfs": {"enabled": True, "password": "O"},
|
||||
"users": [{"name": "u1", "password": "P"}],
|
||||
}
|
||||
state = State.from_dict(migrate(raw))
|
||||
inbound = build_server_config(state)["inbounds"][0]
|
||||
self.assertEqual(inbound["obfs"]["password"], "O")
|
||||
self.assertIn("obfs-password=O", share_uri(state, "hysteria2", state.users[0]))
|
||||
|
||||
def test_obfs_without_key_is_disabled(self) -> None:
|
||||
from sbp.config import build_server_config
|
||||
|
||||
state = sample_state()
|
||||
state.protocols["hysteria2"].options = {"obfs": True}
|
||||
inbound = next(
|
||||
i for i in build_server_config(state)["inbounds"] if i["tag"] == "hysteria2-in"
|
||||
)
|
||||
self.assertNotIn("obfs", inbound)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,36 @@
|
||||
import unittest
|
||||
|
||||
from sbp.tui.term import display_width, pad, truncate, wrap_display
|
||||
|
||||
|
||||
class WidthTest(unittest.TestCase):
|
||||
def test_ascii_width(self) -> None:
|
||||
self.assertEqual(display_width("hello"), 5)
|
||||
|
||||
def test_cjk_width(self) -> None:
|
||||
self.assertEqual(display_width("中文"), 4)
|
||||
self.assertEqual(display_width("a中"), 3)
|
||||
|
||||
def test_truncate_ascii(self) -> None:
|
||||
self.assertEqual(truncate("abcdef", 10), "abcdef")
|
||||
self.assertEqual(truncate("abcdef", 4), "abc…")
|
||||
|
||||
def test_truncate_cjk(self) -> None:
|
||||
self.assertEqual(truncate("中文字符", 5), "中文…")
|
||||
|
||||
def test_pad(self) -> None:
|
||||
self.assertEqual(display_width(pad("中", 5)), 5)
|
||||
|
||||
def test_wrap_display_keeps_long_uri_visible(self) -> None:
|
||||
uri = "vless://" + "a" * 200
|
||||
lines = wrap_display(uri, 40)
|
||||
self.assertTrue(all(display_width(line) <= 40 for line in lines))
|
||||
self.assertEqual("".join(lines), uri)
|
||||
|
||||
def test_wrap_display_cjk(self) -> None:
|
||||
lines = wrap_display("中文字符", 4)
|
||||
self.assertEqual(lines, ["中文", "字符"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user