tailctl 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tailctl/__init__.py +1 -0
- tailctl/cli.py +674 -0
- tailctl/config.py +275 -0
- tailctl/forwarder.py +123 -0
- tailctl/instance_manager.py +863 -0
- tailctl/liveness.py +137 -0
- tailctl/log.py +43 -0
- tailctl/paths.py +79 -0
- tailctl/profiles.py +71 -0
- tailctl/registry.py +180 -0
- tailctl/signals.py +52 -0
- tailctl/tailscale.py +365 -0
- tailctl-0.1.0.dist-info/METADATA +143 -0
- tailctl-0.1.0.dist-info/RECORD +18 -0
- tailctl-0.1.0.dist-info/WHEEL +5 -0
- tailctl-0.1.0.dist-info/entry_points.txt +2 -0
- tailctl-0.1.0.dist-info/licenses/LICENSE +21 -0
- tailctl-0.1.0.dist-info/top_level.txt +1 -0
tailctl/config.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""profiles.yaml loader and validator.
|
|
2
|
+
|
|
3
|
+
The on-disk shape:
|
|
4
|
+
|
|
5
|
+
default: drycode-github
|
|
6
|
+
tailscale_binary: /Applications/Tailscale.app/Contents/MacOS/Tailscale # optional
|
|
7
|
+
profiles:
|
|
8
|
+
drycode-github:
|
|
9
|
+
account_id: a1b2
|
|
10
|
+
tailnet: drycode.github # optional; empty/null skips tailnet verification
|
|
11
|
+
exit_node: null # optional
|
|
12
|
+
expected_egress_ip: null # optional; only checked with --verify-egress
|
|
13
|
+
description: "" # optional, informational only
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
Every identifier (profile name, account_id, exit_node) must match
|
|
17
|
+
``^[A-Za-z0-9._-]+$`` so it can flow through subprocess argv without
|
|
18
|
+
quoting concerns. Duplicate account_ids are rejected. ``default`` must
|
|
19
|
+
reference an existing profile name.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import yaml
|
|
30
|
+
|
|
31
|
+
from tailctl import paths
|
|
32
|
+
from tailctl.profiles import PortForward, Profile, Profiles
|
|
33
|
+
from tailctl.tailscale import DEFAULT_BINARY
|
|
34
|
+
|
|
35
|
+
_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
36
|
+
# Env-var-name safe: what a shell can actually export/expand. Used for
|
|
37
|
+
# port-forward service names (exported as <SERVICE>_ADDR) and auth_key_env.
|
|
38
|
+
_ENV_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
39
|
+
|
|
40
|
+
# Userspace-model defaults.
|
|
41
|
+
DEFAULT_TAILSCALED = "/opt/homebrew/opt/tailscale/bin/tailscaled"
|
|
42
|
+
# The tailscale CLI used to control a per-identity daemon over its --socket.
|
|
43
|
+
# Distinct from `tailscale_binary` (the GUI app's CLI, used only for account
|
|
44
|
+
# discovery in init/bootstrap).
|
|
45
|
+
DEFAULT_TAILSCALE_CLI = "/opt/homebrew/bin/tailscale"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ConfigError(RuntimeError):
|
|
49
|
+
"""profiles.yaml is missing, malformed, or fails validation."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Config:
|
|
54
|
+
"""Top-level parsed configuration."""
|
|
55
|
+
|
|
56
|
+
profiles: Profiles
|
|
57
|
+
tailscale_binary: str
|
|
58
|
+
# Userspace-model config: the headless daemon binary and the CLI used to
|
|
59
|
+
# drive a per-identity daemon over its --socket.
|
|
60
|
+
tailscaled_binary: str = DEFAULT_TAILSCALED
|
|
61
|
+
tailscale_cli_binary: str = DEFAULT_TAILSCALE_CLI
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load(path: Path | None = None) -> Config:
|
|
65
|
+
"""Load + validate profiles.yaml. Returns a Config on success.
|
|
66
|
+
|
|
67
|
+
Raises ``ConfigError`` with a specific message on any problem.
|
|
68
|
+
"""
|
|
69
|
+
target = path or paths.profiles_yaml()
|
|
70
|
+
if not target.exists():
|
|
71
|
+
raise ConfigError(
|
|
72
|
+
f"profiles.yaml not found at {target}. "
|
|
73
|
+
f"Run `tailctl init` to scaffold one."
|
|
74
|
+
)
|
|
75
|
+
try:
|
|
76
|
+
raw = yaml.safe_load(target.read_text())
|
|
77
|
+
except yaml.YAMLError as exc:
|
|
78
|
+
raise ConfigError(f"profiles.yaml is not valid YAML: {exc}") from exc
|
|
79
|
+
if not isinstance(raw, dict):
|
|
80
|
+
raise ConfigError("profiles.yaml: top-level must be a mapping")
|
|
81
|
+
return _parse(raw)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _parse(raw: dict[str, Any]) -> Config:
|
|
85
|
+
default_name = raw.get("default")
|
|
86
|
+
if not isinstance(default_name, str) or not default_name:
|
|
87
|
+
raise ConfigError("profiles.yaml: `default` is required and must be a string")
|
|
88
|
+
if not _ID_RE.match(default_name):
|
|
89
|
+
raise ConfigError(
|
|
90
|
+
f"profiles.yaml: `default` value {default_name!r} contains "
|
|
91
|
+
f"characters outside [A-Za-z0-9._-]"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
binary = raw.get("tailscale_binary", DEFAULT_BINARY)
|
|
95
|
+
if not isinstance(binary, str) or not binary:
|
|
96
|
+
raise ConfigError("profiles.yaml: `tailscale_binary` must be a non-empty string")
|
|
97
|
+
|
|
98
|
+
tailscaled_binary = raw.get("tailscaled_binary", DEFAULT_TAILSCALED)
|
|
99
|
+
if not isinstance(tailscaled_binary, str) or not tailscaled_binary:
|
|
100
|
+
raise ConfigError("profiles.yaml: `tailscaled_binary` must be a non-empty string")
|
|
101
|
+
|
|
102
|
+
tailscale_cli_binary = raw.get("tailscale_cli_binary", DEFAULT_TAILSCALE_CLI)
|
|
103
|
+
if not isinstance(tailscale_cli_binary, str) or not tailscale_cli_binary:
|
|
104
|
+
raise ConfigError("profiles.yaml: `tailscale_cli_binary` must be a non-empty string")
|
|
105
|
+
|
|
106
|
+
profiles_section = raw.get("profiles")
|
|
107
|
+
if not isinstance(profiles_section, dict) or not profiles_section:
|
|
108
|
+
raise ConfigError("profiles.yaml: `profiles` must be a non-empty mapping")
|
|
109
|
+
|
|
110
|
+
by_name: dict[str, Profile] = {}
|
|
111
|
+
# Uniqueness key is (account_id, exit_node), not account_id alone.
|
|
112
|
+
# Many clients use one tailnet account but distinct exit nodes per
|
|
113
|
+
# environment (dev / staging / prod) — those are legitimate distinct
|
|
114
|
+
# profiles. The narrower check still catches the "I typed the same
|
|
115
|
+
# profile twice" typo.
|
|
116
|
+
seen_account_exit: dict[tuple[str, str | None], str] = {}
|
|
117
|
+
for name, body in profiles_section.items():
|
|
118
|
+
if not isinstance(name, str):
|
|
119
|
+
raise ConfigError(f"profiles.yaml: profile name must be a string, got {name!r}")
|
|
120
|
+
if not _ID_RE.match(name):
|
|
121
|
+
raise ConfigError(
|
|
122
|
+
f"profiles.yaml: profile name {name!r} contains characters "
|
|
123
|
+
f"outside [A-Za-z0-9._-]"
|
|
124
|
+
)
|
|
125
|
+
if not isinstance(body, dict):
|
|
126
|
+
raise ConfigError(
|
|
127
|
+
f"profiles.yaml: profile {name!r} must be a mapping"
|
|
128
|
+
)
|
|
129
|
+
account_id = body.get("account_id")
|
|
130
|
+
if not isinstance(account_id, str) or not account_id:
|
|
131
|
+
raise ConfigError(
|
|
132
|
+
f"profiles.yaml: profile {name!r} missing required `account_id`"
|
|
133
|
+
)
|
|
134
|
+
if not _ID_RE.match(account_id):
|
|
135
|
+
raise ConfigError(
|
|
136
|
+
f"profiles.yaml: profile {name!r} `account_id` {account_id!r} "
|
|
137
|
+
f"contains characters outside [A-Za-z0-9._-]"
|
|
138
|
+
)
|
|
139
|
+
# Defer the uniqueness check until we've parsed exit_node below.
|
|
140
|
+
|
|
141
|
+
tailnet = body.get("tailnet") or None
|
|
142
|
+
if tailnet is not None and not isinstance(tailnet, str):
|
|
143
|
+
raise ConfigError(
|
|
144
|
+
f"profiles.yaml: profile {name!r} `tailnet` must be a string or null"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
exit_node = body.get("exit_node") or None
|
|
148
|
+
if exit_node is not None:
|
|
149
|
+
if not isinstance(exit_node, str):
|
|
150
|
+
raise ConfigError(
|
|
151
|
+
f"profiles.yaml: profile {name!r} `exit_node` must be a string or null"
|
|
152
|
+
)
|
|
153
|
+
if not _ID_RE.match(exit_node):
|
|
154
|
+
raise ConfigError(
|
|
155
|
+
f"profiles.yaml: profile {name!r} `exit_node` {exit_node!r} "
|
|
156
|
+
f"contains characters outside [A-Za-z0-9._-]"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
expected_egress_ip = body.get("expected_egress_ip") or None
|
|
160
|
+
if expected_egress_ip is not None and not isinstance(expected_egress_ip, str):
|
|
161
|
+
raise ConfigError(
|
|
162
|
+
f"profiles.yaml: profile {name!r} `expected_egress_ip` "
|
|
163
|
+
f"must be a string or null"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
uniq_key = (account_id, exit_node)
|
|
167
|
+
if uniq_key in seen_account_exit:
|
|
168
|
+
raise ConfigError(
|
|
169
|
+
f"profiles.yaml: (account_id={account_id!r}, exit_node="
|
|
170
|
+
f"{exit_node!r}) is duplicated by both "
|
|
171
|
+
f"{seen_account_exit[uniq_key]!r} and {name!r}"
|
|
172
|
+
)
|
|
173
|
+
seen_account_exit[uniq_key] = name
|
|
174
|
+
|
|
175
|
+
accept_routes = body.get("accept_routes", True)
|
|
176
|
+
if not isinstance(accept_routes, bool):
|
|
177
|
+
raise ConfigError(
|
|
178
|
+
f"profiles.yaml: profile {name!r} `accept_routes` must be a boolean"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
auth_key_env = body.get("auth_key_env") or None
|
|
182
|
+
if auth_key_env is not None and (
|
|
183
|
+
not isinstance(auth_key_env, str) or not _ENV_RE.match(auth_key_env)
|
|
184
|
+
):
|
|
185
|
+
raise ConfigError(
|
|
186
|
+
f"profiles.yaml: profile {name!r} `auth_key_env` must be a valid "
|
|
187
|
+
f"env var name [A-Za-z_][A-Za-z0-9_]* (got {auth_key_env!r}); a "
|
|
188
|
+
f"shell cannot export names containing '.' or '-'."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
port_forwards = _parse_port_forwards(name, body.get("port_forwards"))
|
|
192
|
+
|
|
193
|
+
by_name[name] = Profile(
|
|
194
|
+
name=name,
|
|
195
|
+
account_id=account_id,
|
|
196
|
+
tailnet=tailnet,
|
|
197
|
+
exit_node=exit_node,
|
|
198
|
+
expected_egress_ip=expected_egress_ip,
|
|
199
|
+
accept_routes=accept_routes,
|
|
200
|
+
port_forwards=port_forwards,
|
|
201
|
+
auth_key_env=auth_key_env,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if default_name not in by_name:
|
|
205
|
+
raise ConfigError(
|
|
206
|
+
f"profiles.yaml: `default` value {default_name!r} does not match "
|
|
207
|
+
f"any profile name (have {sorted(by_name.keys())!r})"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return Config(
|
|
211
|
+
profiles=Profiles(default_name=default_name, by_name=by_name),
|
|
212
|
+
tailscale_binary=binary,
|
|
213
|
+
tailscaled_binary=tailscaled_binary,
|
|
214
|
+
tailscale_cli_binary=tailscale_cli_binary,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _parse_port_forwards(profile_name: str, raw: Any) -> tuple[PortForward, ...]:
|
|
219
|
+
"""Parse a profile's optional ``port_forwards`` list into PortForward records."""
|
|
220
|
+
if raw is None:
|
|
221
|
+
return ()
|
|
222
|
+
if not isinstance(raw, list):
|
|
223
|
+
raise ConfigError(
|
|
224
|
+
f"profiles.yaml: profile {profile_name!r} `port_forwards` must be a list"
|
|
225
|
+
)
|
|
226
|
+
out: list[PortForward] = []
|
|
227
|
+
seen_services: set[str] = set()
|
|
228
|
+
for i, entry in enumerate(raw):
|
|
229
|
+
if not isinstance(entry, dict):
|
|
230
|
+
raise ConfigError(
|
|
231
|
+
f"profiles.yaml: profile {profile_name!r} port_forwards[{i}] "
|
|
232
|
+
f"must be a mapping"
|
|
233
|
+
)
|
|
234
|
+
service = entry.get("service")
|
|
235
|
+
# Must be env-var-safe: it's exported to `run` subprocesses as
|
|
236
|
+
# <SERVICE>_ADDR, so '.'/'-' would produce a name no shell can expand.
|
|
237
|
+
if not isinstance(service, str) or not _ENV_RE.match(service):
|
|
238
|
+
raise ConfigError(
|
|
239
|
+
f"profiles.yaml: profile {profile_name!r} port_forwards[{i}] "
|
|
240
|
+
f"`service` must be a valid env var name [A-Za-z_][A-Za-z0-9_]* "
|
|
241
|
+
f"(exported as <SERVICE>_ADDR); got {service!r}"
|
|
242
|
+
)
|
|
243
|
+
if service in seen_services:
|
|
244
|
+
raise ConfigError(
|
|
245
|
+
f"profiles.yaml: profile {profile_name!r} has duplicate "
|
|
246
|
+
f"port_forward service {service!r}"
|
|
247
|
+
)
|
|
248
|
+
seen_services.add(service)
|
|
249
|
+
remote_host = entry.get("remote_host")
|
|
250
|
+
if not isinstance(remote_host, str) or not remote_host:
|
|
251
|
+
raise ConfigError(
|
|
252
|
+
f"profiles.yaml: profile {profile_name!r} port_forwards[{i}] "
|
|
253
|
+
f"missing `remote_host`"
|
|
254
|
+
)
|
|
255
|
+
remote_port = entry.get("remote_port")
|
|
256
|
+
if not isinstance(remote_port, int) or not (1 <= remote_port <= 65535):
|
|
257
|
+
raise ConfigError(
|
|
258
|
+
f"profiles.yaml: profile {profile_name!r} port_forwards[{i}] "
|
|
259
|
+
f"`remote_port` must be an int in [1, 65535]"
|
|
260
|
+
)
|
|
261
|
+
local_port = entry.get("local_port", 0)
|
|
262
|
+
if not isinstance(local_port, int) or not (0 <= local_port <= 65535):
|
|
263
|
+
raise ConfigError(
|
|
264
|
+
f"profiles.yaml: profile {profile_name!r} port_forwards[{i}] "
|
|
265
|
+
f"`local_port` must be an int in [0, 65535] (0 = auto)"
|
|
266
|
+
)
|
|
267
|
+
out.append(
|
|
268
|
+
PortForward(
|
|
269
|
+
service=service,
|
|
270
|
+
remote_host=remote_host,
|
|
271
|
+
remote_port=remote_port,
|
|
272
|
+
local_port=local_port,
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
return tuple(out)
|
tailctl/forwarder.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Localhost TCP forward through a userspace tailscaled SOCKS5 proxy.
|
|
2
|
+
|
|
3
|
+
This is the native-DB path: clients that ignore proxy env vars (ClickHouse
|
|
4
|
+
native :9000, Postgres, SQL Server) connect to a stable ``127.0.0.1:<local_port>``
|
|
5
|
+
and the relay tunnels each connection through the profile's userspace tailscaled
|
|
6
|
+
SOCKS5 proxy to ``<remote_host>:<remote_port>`` on the tailnet.
|
|
7
|
+
|
|
8
|
+
Validated end-to-end in the Phase 0 spike (a non-proxy curl reached a tailnet
|
|
9
|
+
Caddy through this exact relay). Run as a detached process per forward:
|
|
10
|
+
|
|
11
|
+
python -m tailctl.forwarder <local_port> <socks_port> <remote_host> <remote_port>
|
|
12
|
+
|
|
13
|
+
The instance manager spawns one per configured port-forward and records its pid
|
|
14
|
+
in the registry so it can be reaped with the instance.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import contextlib
|
|
21
|
+
import struct
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def socks5_connect(
|
|
26
|
+
proxy_host: str, proxy_port: int, dst_host: str, dst_port: int
|
|
27
|
+
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
|
28
|
+
"""Open a SOCKS5 (no-auth) CONNECT tunnel to dst through the proxy."""
|
|
29
|
+
r, w = await asyncio.open_connection(proxy_host, proxy_port)
|
|
30
|
+
w.write(b"\x05\x01\x00") # version 5, 1 method, no-auth
|
|
31
|
+
await w.drain()
|
|
32
|
+
if await r.readexactly(2) != b"\x05\x00":
|
|
33
|
+
raise RuntimeError("SOCKS5 no-auth not accepted")
|
|
34
|
+
host = dst_host.encode()
|
|
35
|
+
# CONNECT, ATYP=domain — let the proxy resolve the name on the tailnet side.
|
|
36
|
+
w.write(b"\x05\x01\x00\x03" + bytes([len(host)]) + host + struct.pack("!H", dst_port))
|
|
37
|
+
await w.drain()
|
|
38
|
+
rep = await r.readexactly(4)
|
|
39
|
+
if rep[1] != 0x00:
|
|
40
|
+
raise RuntimeError(f"SOCKS5 connect failed, REP={rep[1]}")
|
|
41
|
+
atyp = rep[3]
|
|
42
|
+
if atyp == 0x01:
|
|
43
|
+
await r.readexactly(4)
|
|
44
|
+
elif atyp == 0x04:
|
|
45
|
+
await r.readexactly(16)
|
|
46
|
+
elif atyp == 0x03:
|
|
47
|
+
ln = (await r.readexactly(1))[0]
|
|
48
|
+
await r.readexactly(ln)
|
|
49
|
+
await r.readexactly(2) # bound port
|
|
50
|
+
return r, w
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def _pipe(src: asyncio.StreamReader, dst: asyncio.StreamWriter) -> None:
|
|
54
|
+
"""Copy src→dst until src EOF, then HALF-close dst (write_eof) so the other
|
|
55
|
+
direction can still deliver its response. Does NOT fully close dst — that
|
|
56
|
+
would truncate request/response protocols (ClickHouse native, Postgres, TDS)
|
|
57
|
+
where a client finishes sending but still awaits the server's reply."""
|
|
58
|
+
try:
|
|
59
|
+
while data := await src.read(65536):
|
|
60
|
+
dst.write(data)
|
|
61
|
+
await dst.drain()
|
|
62
|
+
except Exception: # noqa: BLE001
|
|
63
|
+
# On a real transport error, fully close so the peer doesn't hang.
|
|
64
|
+
with contextlib.suppress(Exception):
|
|
65
|
+
dst.close()
|
|
66
|
+
return
|
|
67
|
+
# Clean EOF on src → propagate a half-close (FIN) to dst, keep reading the
|
|
68
|
+
# reverse direction.
|
|
69
|
+
try:
|
|
70
|
+
if dst.can_write_eof():
|
|
71
|
+
dst.write_eof()
|
|
72
|
+
except Exception: # noqa: BLE001
|
|
73
|
+
with contextlib.suppress(Exception):
|
|
74
|
+
dst.close()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def serve(
|
|
78
|
+
local_port: int, socks_port: int, remote_host: str, remote_port: int
|
|
79
|
+
) -> None:
|
|
80
|
+
async def handle(cr: asyncio.StreamReader, cw: asyncio.StreamWriter) -> None:
|
|
81
|
+
try:
|
|
82
|
+
pr, pw = await socks5_connect("127.0.0.1", socks_port, remote_host, remote_port)
|
|
83
|
+
except Exception as e: # noqa: BLE001
|
|
84
|
+
print(f"forwarder: upstream connect failed: {e}", file=sys.stderr)
|
|
85
|
+
with contextlib.suppress(Exception):
|
|
86
|
+
cw.close()
|
|
87
|
+
return
|
|
88
|
+
# Both directions run to completion (each half-closes its peer on EOF);
|
|
89
|
+
# only once both finish do we fully close both writers.
|
|
90
|
+
await asyncio.gather(_pipe(cr, pw), _pipe(pr, cw))
|
|
91
|
+
for w in (cw, pw):
|
|
92
|
+
with contextlib.suppress(Exception):
|
|
93
|
+
w.close()
|
|
94
|
+
|
|
95
|
+
server = await asyncio.start_server(handle, "127.0.0.1", local_port)
|
|
96
|
+
print(
|
|
97
|
+
f"forwarder: 127.0.0.1:{local_port} -> socks5 127.0.0.1:{socks_port} "
|
|
98
|
+
f"-> {remote_host}:{remote_port}",
|
|
99
|
+
flush=True,
|
|
100
|
+
)
|
|
101
|
+
async with server:
|
|
102
|
+
await server.serve_forever()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main(argv: list[str] | None = None) -> int:
|
|
106
|
+
args = argv if argv is not None else sys.argv[1:]
|
|
107
|
+
if len(args) != 4:
|
|
108
|
+
print(
|
|
109
|
+
"usage: python -m tailctl.forwarder <local_port> <socks_port> "
|
|
110
|
+
"<remote_host> <remote_port>",
|
|
111
|
+
file=sys.stderr,
|
|
112
|
+
)
|
|
113
|
+
return 2
|
|
114
|
+
lp, sp, rh, rp = int(args[0]), int(args[1]), args[2], int(args[3])
|
|
115
|
+
try:
|
|
116
|
+
asyncio.run(serve(lp, sp, rh, rp))
|
|
117
|
+
except KeyboardInterrupt:
|
|
118
|
+
return 0
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
raise SystemExit(main())
|