nullgate 1.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.
- nullgate/__init__.py +1 -0
- nullgate/account.py +23 -0
- nullgate/bridge.py +162 -0
- nullgate/client_config.py +116 -0
- nullgate/commands.py +172 -0
- nullgate/gateway.py +897 -0
- nullgate/ingress.py +206 -0
- nullgate/runtime.py +236 -0
- nullgate/session.py +509 -0
- nullgate/transports.py +406 -0
- nullgate/wsroute.py +483 -0
- nullgate-1.1.0.dist-info/METADATA +10 -0
- nullgate-1.1.0.dist-info/RECORD +17 -0
- nullgate-1.1.0.dist-info/WHEEL +5 -0
- nullgate-1.1.0.dist-info/entry_points.txt +2 -0
- nullgate-1.1.0.dist-info/licenses/LICENSE +21 -0
- nullgate-1.1.0.dist-info/top_level.txt +1 -0
nullgate/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.1.0"
|
nullgate/account.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Current-account helpers for hosts without a passwd database entry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def current_username(default: str = "nullgate") -> str:
|
|
10
|
+
"""Return the current username, or a stable fallback for synthetic UIDs."""
|
|
11
|
+
try:
|
|
12
|
+
return getpass.getuser() or default
|
|
13
|
+
except (KeyError, OSError):
|
|
14
|
+
return default
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ensure_username_environment(username: str | None = None) -> str:
|
|
18
|
+
"""Populate the account variables required by libraries such as AsyncSSH."""
|
|
19
|
+
resolved = username or current_username()
|
|
20
|
+
for name in ("USER", "LOGNAME"):
|
|
21
|
+
if not os.environ.get(name):
|
|
22
|
+
os.environ[name] = resolved
|
|
23
|
+
return resolved
|
nullgate/bridge.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stdio/TCP <-> WebSocket bridge for the Nullgate cloudflare transport.
|
|
3
|
+
|
|
4
|
+
Two modes pump a raw byte stream across a WebSocket relay (a Cloudflare Worker):
|
|
5
|
+
|
|
6
|
+
origin Connects out to the relay and, on the first inbound byte, dials the
|
|
7
|
+
local SSH server, splicing the two. Loops to serve many sessions.
|
|
8
|
+
client Connects out to the relay and splices it to stdin/stdout, so it can be
|
|
9
|
+
used as an ssh ProxyCommand.
|
|
10
|
+
|
|
11
|
+
The relay pairs one "origin" and one "client" socket per tunnel id. The role
|
|
12
|
+
is the final path segment of ``/relay/<session>/<role>``.
|
|
13
|
+
|
|
14
|
+
Launched by the CLI as ``python -m nullgate.bridge`` for both the origin
|
|
15
|
+
(remote) and client (ProxyCommand) roles; it needs the websockets package.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import asyncio
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
import websockets
|
|
24
|
+
|
|
25
|
+
CHUNK = 65536
|
|
26
|
+
CONNECT_KWARGS = dict(max_size=None, ping_interval=20, ping_timeout=20, close_timeout=5)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def log(message):
|
|
30
|
+
sys.stderr.write("nullgate-bridge: " + message + "\n")
|
|
31
|
+
sys.stderr.flush()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def role_url(base, role):
|
|
35
|
+
return base.rstrip("/") + "/" + role
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _write_all(fd, data):
|
|
39
|
+
view = memoryview(data)
|
|
40
|
+
while view:
|
|
41
|
+
written = os.write(fd, view)
|
|
42
|
+
view = view[written:]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def _await_first_completed(*coros):
|
|
46
|
+
tasks = [asyncio.ensure_future(coro) for coro in coros]
|
|
47
|
+
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
48
|
+
for task in pending:
|
|
49
|
+
task.cancel()
|
|
50
|
+
for task in pending:
|
|
51
|
+
try:
|
|
52
|
+
await task
|
|
53
|
+
except (asyncio.CancelledError, Exception): # noqa: BLE001 - draining only
|
|
54
|
+
pass
|
|
55
|
+
for task in done:
|
|
56
|
+
exc = task.exception()
|
|
57
|
+
if exc is not None:
|
|
58
|
+
raise exc
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def run_client(base):
|
|
62
|
+
url = role_url(base, "client")
|
|
63
|
+
async with websockets.connect(url, **CONNECT_KWARGS) as ws:
|
|
64
|
+
loop = asyncio.get_event_loop()
|
|
65
|
+
stdin_fd = sys.stdin.fileno()
|
|
66
|
+
|
|
67
|
+
async def stdin_to_ws():
|
|
68
|
+
while True:
|
|
69
|
+
data = await loop.run_in_executor(None, os.read, stdin_fd, CHUNK)
|
|
70
|
+
if not data:
|
|
71
|
+
break
|
|
72
|
+
await ws.send(data)
|
|
73
|
+
|
|
74
|
+
async def ws_to_stdout():
|
|
75
|
+
async for message in ws:
|
|
76
|
+
if isinstance(message, str):
|
|
77
|
+
message = message.encode()
|
|
78
|
+
_write_all(1, message)
|
|
79
|
+
|
|
80
|
+
await _await_first_completed(stdin_to_ws(), ws_to_stdout())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def run_origin_once(base, host, port):
|
|
84
|
+
url = role_url(base, "origin")
|
|
85
|
+
async with websockets.connect(url, **CONNECT_KWARGS) as ws:
|
|
86
|
+
# Wait for the client's first byte before dialing the local server, so
|
|
87
|
+
# the server's SSH banner is never emitted into a peerless relay.
|
|
88
|
+
try:
|
|
89
|
+
first = await ws.recv()
|
|
90
|
+
except websockets.ConnectionClosed:
|
|
91
|
+
return
|
|
92
|
+
if isinstance(first, str):
|
|
93
|
+
first = first.encode()
|
|
94
|
+
reader, writer = await asyncio.open_connection(host, port)
|
|
95
|
+
writer.write(first)
|
|
96
|
+
await writer.drain()
|
|
97
|
+
|
|
98
|
+
async def tcp_to_ws():
|
|
99
|
+
while True:
|
|
100
|
+
data = await reader.read(CHUNK)
|
|
101
|
+
if not data:
|
|
102
|
+
break
|
|
103
|
+
await ws.send(data)
|
|
104
|
+
|
|
105
|
+
async def ws_to_tcp():
|
|
106
|
+
async for message in ws:
|
|
107
|
+
if isinstance(message, str):
|
|
108
|
+
message = message.encode()
|
|
109
|
+
writer.write(message)
|
|
110
|
+
await writer.drain()
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
await _await_first_completed(tcp_to_ws(), ws_to_tcp())
|
|
114
|
+
finally:
|
|
115
|
+
writer.close()
|
|
116
|
+
try:
|
|
117
|
+
await writer.wait_closed()
|
|
118
|
+
except Exception: # noqa: BLE001 - best-effort teardown
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def run_origin(base, host, port):
|
|
123
|
+
delay = 1
|
|
124
|
+
while True:
|
|
125
|
+
try:
|
|
126
|
+
await run_origin_once(base, host, port)
|
|
127
|
+
delay = 1
|
|
128
|
+
except asyncio.CancelledError:
|
|
129
|
+
raise
|
|
130
|
+
except Exception as exc: # noqa: BLE001 - keep the tunnel alive
|
|
131
|
+
log("origin session ended: " + repr(exc))
|
|
132
|
+
await asyncio.sleep(delay)
|
|
133
|
+
delay = min(delay * 2, 30)
|
|
134
|
+
continue
|
|
135
|
+
await asyncio.sleep(0.5)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def main(argv=None):
|
|
139
|
+
parser = argparse.ArgumentParser(description="Nullgate WebSocket bridge")
|
|
140
|
+
sub = parser.add_subparsers(dest="mode", required=True)
|
|
141
|
+
|
|
142
|
+
client = sub.add_parser("client", help="stdio <-> relay (ssh ProxyCommand)")
|
|
143
|
+
client.add_argument("url", help="base relay URL, e.g. wss://host/relay/ID")
|
|
144
|
+
|
|
145
|
+
origin = sub.add_parser("origin", help="local SSH server <-> relay")
|
|
146
|
+
origin.add_argument("url", help="base relay URL, e.g. wss://host/relay/ID")
|
|
147
|
+
origin.add_argument("--host", default="127.0.0.1")
|
|
148
|
+
origin.add_argument("--port", type=int, required=True)
|
|
149
|
+
|
|
150
|
+
args = parser.parse_args(argv)
|
|
151
|
+
try:
|
|
152
|
+
if args.mode == "client":
|
|
153
|
+
asyncio.run(run_client(args.url))
|
|
154
|
+
else:
|
|
155
|
+
asyncio.run(run_origin(args.url, args.host, args.port))
|
|
156
|
+
except KeyboardInterrupt: # pragma: no cover
|
|
157
|
+
return 130
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""The ssh-config block for Nullgate.
|
|
2
|
+
|
|
3
|
+
``ssh-config`` prints a ``Host`` block so plain ``ssh USER@HOST`` works on the
|
|
4
|
+
connecting machine. With no HOST it prints the default ``Host *.srv.us`` block
|
|
5
|
+
for the srv.us transport; with a HOST it prints a block for that host using the
|
|
6
|
+
currently configured transport's ProxyCommand. ``--write`` appends it to
|
|
7
|
+
~/.ssh/config (never prepends, which would drag any leading global keywords
|
|
8
|
+
under the new Host block), is idempotent, chmods the file to 0600, and then runs
|
|
9
|
+
``ssh -G`` to confirm the block actually took effect.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
SRVUS_PROXY_COMMAND = (
|
|
21
|
+
"openssl s_client -quiet -no_ign_eof -verify_return_error "
|
|
22
|
+
"-verify_hostname %h -connect %h:443 -servername %h 2>/dev/null"
|
|
23
|
+
)
|
|
24
|
+
DEFAULT_HOST = "*.srv.us"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def block(host: str, proxy_command: str) -> str:
|
|
28
|
+
"""Build the ssh-config block for a host and ProxyCommand."""
|
|
29
|
+
return (
|
|
30
|
+
f"Host {host}\n"
|
|
31
|
+
f" ProxyCommand {proxy_command}\n"
|
|
32
|
+
" StrictHostKeyChecking no\n"
|
|
33
|
+
" UserKnownHostsFile /dev/null\n"
|
|
34
|
+
" LogLevel ERROR"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def config_path() -> Path:
|
|
39
|
+
return Path(os.path.expanduser("~/.ssh/config"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def print_block(
|
|
43
|
+
host: str = DEFAULT_HOST, proxy_command: str = SRVUS_PROXY_COMMAND
|
|
44
|
+
) -> None:
|
|
45
|
+
print(block(host, proxy_command))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def write(
|
|
49
|
+
host: str = DEFAULT_HOST, proxy_command: str = SRVUS_PROXY_COMMAND
|
|
50
|
+
) -> int:
|
|
51
|
+
config = config_path()
|
|
52
|
+
ssh_dir = config.parent
|
|
53
|
+
try:
|
|
54
|
+
ssh_dir.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
ssh_dir.chmod(0o700)
|
|
56
|
+
except OSError:
|
|
57
|
+
_error(f"Unable to prepare {ssh_dir}")
|
|
58
|
+
return 1
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
present = config.is_file() and any(
|
|
62
|
+
line.strip().startswith("Host " + host)
|
|
63
|
+
for line in config.read_text().splitlines()
|
|
64
|
+
)
|
|
65
|
+
except OSError:
|
|
66
|
+
present = False
|
|
67
|
+
if present:
|
|
68
|
+
print(f"Already present in {config}")
|
|
69
|
+
else:
|
|
70
|
+
# Appending never changes the meaning of existing lines, unlike
|
|
71
|
+
# prepending, which would pull any leading global keywords under this
|
|
72
|
+
# Host block.
|
|
73
|
+
try:
|
|
74
|
+
with open(config, "a") as handle:
|
|
75
|
+
if config.exists() and config.stat().st_size > 0:
|
|
76
|
+
handle.write("\n")
|
|
77
|
+
handle.write(block(host, proxy_command) + "\n")
|
|
78
|
+
config.chmod(0o600)
|
|
79
|
+
except OSError as error:
|
|
80
|
+
_error(f"Unable to write {config}: {error}")
|
|
81
|
+
return 1
|
|
82
|
+
print(f"Added the {host} block to {config}")
|
|
83
|
+
|
|
84
|
+
# ssh takes the first value it finds for each keyword, so an earlier
|
|
85
|
+
# matching block wins. Ask ssh what it will actually do rather than assuming.
|
|
86
|
+
ssh = shutil.which("ssh")
|
|
87
|
+
if ssh:
|
|
88
|
+
probe = host.replace("*", "example")
|
|
89
|
+
try:
|
|
90
|
+
resolved = subprocess.run(
|
|
91
|
+
[ssh, "-G", probe],
|
|
92
|
+
capture_output=True,
|
|
93
|
+
text=True,
|
|
94
|
+
timeout=15,
|
|
95
|
+
).stdout
|
|
96
|
+
except (OSError, subprocess.SubprocessError):
|
|
97
|
+
return 0
|
|
98
|
+
proxycommand = next(
|
|
99
|
+
(
|
|
100
|
+
line.split(" ", 1)[1].strip()
|
|
101
|
+
for line in resolved.splitlines()
|
|
102
|
+
if line.lower().startswith("proxycommand ")
|
|
103
|
+
),
|
|
104
|
+
"",
|
|
105
|
+
)
|
|
106
|
+
marker = proxy_command.split()[0]
|
|
107
|
+
if marker not in proxycommand:
|
|
108
|
+
_error(
|
|
109
|
+
f"Warning: an earlier block in {config} overrides it; move the "
|
|
110
|
+
f'{host} block above any "Host *" block.'
|
|
111
|
+
)
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _error(message: str) -> None:
|
|
116
|
+
print(message, file=sys.stderr)
|
nullgate/commands.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Command-line parser and entry point dispatch for Nullgate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Sequence
|
|
8
|
+
|
|
9
|
+
from nullgate import session, transports
|
|
10
|
+
|
|
11
|
+
SUBCOMMANDS = ("open", "shut", "inspect", "enter", "trace", "cycle", "upgrade")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="nullgate",
|
|
17
|
+
description="Disposable SSH gateway into confined directory workspaces.",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"-V",
|
|
21
|
+
"--version",
|
|
22
|
+
action="version",
|
|
23
|
+
version=f"nullgate {session._version()}",
|
|
24
|
+
)
|
|
25
|
+
sub = parser.add_subparsers(dest="command", required=True, metavar="command")
|
|
26
|
+
|
|
27
|
+
open_parser = sub.add_parser(
|
|
28
|
+
"open",
|
|
29
|
+
description="Launch gateway daemon and ingress transport.",
|
|
30
|
+
)
|
|
31
|
+
open_parser.add_argument(
|
|
32
|
+
"workspace",
|
|
33
|
+
nargs="?",
|
|
34
|
+
help="Directory exposed by the gateway (default: current directory).",
|
|
35
|
+
)
|
|
36
|
+
open_parser.add_argument(
|
|
37
|
+
"--port",
|
|
38
|
+
type=int,
|
|
39
|
+
default=None,
|
|
40
|
+
help=f"Local SSH listen port (default: {session.DEFAULT_PORT}).",
|
|
41
|
+
)
|
|
42
|
+
open_parser.add_argument(
|
|
43
|
+
"--slot",
|
|
44
|
+
type=int,
|
|
45
|
+
default=None,
|
|
46
|
+
help=f"Ingress slot number (default: {session.DEFAULT_SLOT}).",
|
|
47
|
+
)
|
|
48
|
+
open_parser.add_argument(
|
|
49
|
+
"--transport",
|
|
50
|
+
choices=transports.VALID_TRANSPORTS,
|
|
51
|
+
default=None,
|
|
52
|
+
help=f"Ingress transport provider (default {transports.DEFAULT_TRANSPORT}).",
|
|
53
|
+
)
|
|
54
|
+
open_parser.add_argument(
|
|
55
|
+
"--accept",
|
|
56
|
+
action=argparse.BooleanOptionalAction,
|
|
57
|
+
default=None,
|
|
58
|
+
help="Permit any client without credentials; --no-accept restores authentication.",
|
|
59
|
+
)
|
|
60
|
+
open_parser.add_argument(
|
|
61
|
+
"--allow-tcp-forwarding",
|
|
62
|
+
action=argparse.BooleanOptionalAction,
|
|
63
|
+
default=None,
|
|
64
|
+
help="Allow SSH port forwarding; use --no-allow-tcp-forwarding to disable it.",
|
|
65
|
+
)
|
|
66
|
+
open_parser.add_argument(
|
|
67
|
+
"--confine-sftp",
|
|
68
|
+
action=argparse.BooleanOptionalAction,
|
|
69
|
+
default=None,
|
|
70
|
+
help="Confine SFTP and SCP to the workspace; --no-confine-sftp restores host visibility.",
|
|
71
|
+
)
|
|
72
|
+
open_parser.add_argument(
|
|
73
|
+
"--endpoint",
|
|
74
|
+
help="WebSocket ingress endpoint URL (for upterm or cloudflare transports).",
|
|
75
|
+
)
|
|
76
|
+
open_parser.add_argument(
|
|
77
|
+
"--hostname",
|
|
78
|
+
help="Public hostname (for cloudflared transport).",
|
|
79
|
+
)
|
|
80
|
+
open_parser.add_argument(
|
|
81
|
+
"--token",
|
|
82
|
+
help="Cloudflare Argo tunnel token (for cloudflared transport).",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
sub.add_parser(
|
|
86
|
+
"shut",
|
|
87
|
+
description="Halt running gateway and transport services.",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
sub.add_parser(
|
|
91
|
+
"inspect",
|
|
92
|
+
description="Display service health, access URLs, and authentication status.",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
sub.add_parser(
|
|
96
|
+
"enter",
|
|
97
|
+
description="Show SSH connection strings and client setup commands.",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
trace_parser = sub.add_parser(
|
|
101
|
+
"trace",
|
|
102
|
+
description="View or stream service operational logs.",
|
|
103
|
+
)
|
|
104
|
+
trace_parser.add_argument(
|
|
105
|
+
"target",
|
|
106
|
+
nargs="?",
|
|
107
|
+
default="all",
|
|
108
|
+
choices=["all", "gateway", "transport"],
|
|
109
|
+
help="Target log stream to inspect (default all).",
|
|
110
|
+
)
|
|
111
|
+
trace_parser.add_argument(
|
|
112
|
+
"-f",
|
|
113
|
+
"--follow",
|
|
114
|
+
dest="f",
|
|
115
|
+
action="store_true",
|
|
116
|
+
help="Follow log stream output continuously.",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
sub.add_parser(
|
|
120
|
+
"cycle",
|
|
121
|
+
description="Restart services using active or saved configuration.",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
upgrade_parser = sub.add_parser(
|
|
125
|
+
"upgrade",
|
|
126
|
+
description="Fetch and install latest Nullgate release.",
|
|
127
|
+
)
|
|
128
|
+
upgrade_parser.add_argument(
|
|
129
|
+
"--version",
|
|
130
|
+
help="Install a specific published version instead of the latest stable release.",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
proxy_parser = sub.add_parser(
|
|
134
|
+
"proxy",
|
|
135
|
+
description=argparse.SUPPRESS,
|
|
136
|
+
)
|
|
137
|
+
proxy_parser.add_argument("url")
|
|
138
|
+
|
|
139
|
+
upterm_proxy = sub.add_parser(
|
|
140
|
+
"upterm-proxy",
|
|
141
|
+
description=argparse.SUPPRESS,
|
|
142
|
+
)
|
|
143
|
+
upterm_proxy.add_argument("url")
|
|
144
|
+
|
|
145
|
+
return parser
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
149
|
+
parser = build_parser()
|
|
150
|
+
args = parser.parse_args(argv)
|
|
151
|
+
|
|
152
|
+
handlers = {
|
|
153
|
+
"open": session.cmd_open,
|
|
154
|
+
"shut": session.cmd_shut,
|
|
155
|
+
"inspect": session.cmd_inspect,
|
|
156
|
+
"enter": session.cmd_enter,
|
|
157
|
+
"trace": session.cmd_trace,
|
|
158
|
+
"cycle": session.cmd_cycle,
|
|
159
|
+
"upgrade": session.cmd_upgrade,
|
|
160
|
+
"proxy": transports.cmd_proxy,
|
|
161
|
+
"upterm-proxy": transports.cmd_upterm_proxy,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
handler = handlers.get(args.command)
|
|
165
|
+
if handler is None:
|
|
166
|
+
parser.print_help(sys.stderr)
|
|
167
|
+
return 2
|
|
168
|
+
return handler(args)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
sys.exit(main())
|