hello-127 0.1.1__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.
- hello_127/__init__.py +1 -0
- hello_127/args.py +205 -0
- hello_127/cli.py +146 -0
- hello_127/cli_query.py +41 -0
- hello_127/cli_tunnel.py +49 -0
- hello_127/errors.py +9 -0
- hello_127/host.py +67 -0
- hello_127/launch.py +20 -0
- hello_127/query.py +57 -0
- hello_127/wire.py +196 -0
- hello_127-0.1.1.dist-info/METADATA +305 -0
- hello_127-0.1.1.dist-info/RECORD +15 -0
- hello_127-0.1.1.dist-info/WHEEL +4 -0
- hello_127-0.1.1.dist-info/entry_points.txt +3 -0
- hello_127-0.1.1.dist-info/licenses/LICENSE +21 -0
hello_127/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""hello-127: carry TCP over a container's standard streams to Ollama on loopback."""
|
hello_127/args.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""The command line's vocabulary: the parser, its subparsers, and the version line."""
|
|
2
|
+
import argparse
|
|
3
|
+
import ipaddress
|
|
4
|
+
import sys
|
|
5
|
+
from collections import namedtuple
|
|
6
|
+
from importlib import metadata
|
|
7
|
+
|
|
8
|
+
from hello_127 import cli_query, cli_tunnel, launch
|
|
9
|
+
|
|
10
|
+
PROG = "hello-127"
|
|
11
|
+
|
|
12
|
+
PATHS = """\
|
|
13
|
+
paths:
|
|
14
|
+
RUNTIME `docker` or `container`, the runtime that runs CONTAINER.
|
|
15
|
+
CONTAINER the name or id of a running container.
|
|
16
|
+
RUNTIME and CONTAINER come right after COMMAND, then options in any order."""
|
|
17
|
+
|
|
18
|
+
MONOCHROME = {"color": False} if sys.version_info >= (3, 14) else {}
|
|
19
|
+
|
|
20
|
+
LOOPBACK = "127.0.0.1"
|
|
21
|
+
OLLAMA_PORT = 11434
|
|
22
|
+
|
|
23
|
+
Forward = namedtuple("Forward", "bind port host hostport")
|
|
24
|
+
"""Where the helper listens inside, and what the Mac dials for each connection."""
|
|
25
|
+
|
|
26
|
+
Endpoint = namedtuple("Endpoint", "address port")
|
|
27
|
+
"""An address and a port, for `ollama-query` to dial inside the container."""
|
|
28
|
+
|
|
29
|
+
DEFAULT_FORWARD = Forward(LOOPBACK, OLLAMA_PORT, LOOPBACK, OLLAMA_PORT)
|
|
30
|
+
DEFAULT_ENDPOINT = Endpoint(LOOPBACK, OLLAMA_PORT)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def leading_paths(tokens):
|
|
34
|
+
"""Return the tokens ahead of the first flag."""
|
|
35
|
+
paths = []
|
|
36
|
+
for token in tokens:
|
|
37
|
+
if token.startswith("-"):
|
|
38
|
+
break
|
|
39
|
+
paths.append(token)
|
|
40
|
+
return paths
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def installed_version():
|
|
44
|
+
"""Return the version of the installed hello-127 distribution."""
|
|
45
|
+
try:
|
|
46
|
+
return metadata.version("hello-127")
|
|
47
|
+
except metadata.PackageNotFoundError:
|
|
48
|
+
return "unknown (not installed)"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def version_line():
|
|
52
|
+
"""Return the program name and the installed version on one line."""
|
|
53
|
+
return f"{PROG} {installed_version()}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def loopback(text):
|
|
57
|
+
"""Return `text` as a loopback IPv4 address, or refuse it as a bad value."""
|
|
58
|
+
try:
|
|
59
|
+
address = ipaddress.IPv4Address(text)
|
|
60
|
+
except ValueError:
|
|
61
|
+
raise argparse.ArgumentTypeError(f"not an IPv4 address: {text!r}") from None
|
|
62
|
+
if not address.is_loopback:
|
|
63
|
+
raise argparse.ArgumentTypeError(f"not a loopback address: {text!r}")
|
|
64
|
+
return str(address)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def port_number(text):
|
|
68
|
+
"""Return `text` as a TCP port from 1 to 65535, or refuse it as a bad value."""
|
|
69
|
+
if not (text.isascii() and text.isdigit() and 0 < int(text) < 65536):
|
|
70
|
+
raise argparse.ArgumentTypeError(f"not a port: {text!r}")
|
|
71
|
+
return int(text)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def forward_spec(text):
|
|
75
|
+
"""Read `[BIND:]PORT:HOST:HOSTPORT` into a Forward. BIND defaults to loopback."""
|
|
76
|
+
parts = text.split(":")
|
|
77
|
+
if len(parts) == 3:
|
|
78
|
+
parts.insert(0, LOOPBACK)
|
|
79
|
+
if len(parts) != 4:
|
|
80
|
+
raise argparse.ArgumentTypeError(f"expected [BIND:]PORT:HOST:HOSTPORT: {text!r}")
|
|
81
|
+
bind, port, host, hostport = parts
|
|
82
|
+
return Forward(loopback(bind), port_number(port), loopback(host), port_number(hostport))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def endpoint_spec(text):
|
|
86
|
+
"""Read `ADDRESS:PORT` into an Endpoint."""
|
|
87
|
+
parts = text.split(":")
|
|
88
|
+
if len(parts) != 2:
|
|
89
|
+
raise argparse.ArgumentTypeError(f"expected ADDRESS:PORT: {text!r}")
|
|
90
|
+
address, port = parts
|
|
91
|
+
return Endpoint(loopback(address), port_number(port))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class StoreOnce(argparse.Action):
|
|
95
|
+
"""Store a flag's value, and refuse the flag given a second time."""
|
|
96
|
+
|
|
97
|
+
def __call__(self, parser, namespace, values, option_string=None):
|
|
98
|
+
"""Store `values`, unless an earlier occurrence already replaced the default."""
|
|
99
|
+
if getattr(namespace, self.dest) is not self.default:
|
|
100
|
+
raise argparse.ArgumentError(self, "given more than once")
|
|
101
|
+
setattr(namespace, self.dest, values)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class AppendForward(argparse.Action):
|
|
105
|
+
"""Collect each --forward in order, and refuse a listener given twice."""
|
|
106
|
+
|
|
107
|
+
def __call__(self, parser, namespace, values, option_string=None):
|
|
108
|
+
"""Append `values`, starting a fresh list in place of the default."""
|
|
109
|
+
forwards = getattr(namespace, self.dest)
|
|
110
|
+
if forwards is self.default:
|
|
111
|
+
forwards = []
|
|
112
|
+
if any(forward[:2] == values[:2] for forward in forwards):
|
|
113
|
+
raise argparse.ArgumentError(
|
|
114
|
+
self, f"listener {values.bind}:{values.port} given twice"
|
|
115
|
+
)
|
|
116
|
+
setattr(namespace, self.dest, [*forwards, values])
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def add_runtime_slots(command):
|
|
120
|
+
"""Add the RUNTIME and CONTAINER positionals that both commands read."""
|
|
121
|
+
paths = command.add_argument_group("paths")
|
|
122
|
+
paths.add_argument(
|
|
123
|
+
"runtime",
|
|
124
|
+
nargs="?",
|
|
125
|
+
choices=sorted(launch.EXEC),
|
|
126
|
+
metavar="RUNTIME",
|
|
127
|
+
help="`docker` or `container`",
|
|
128
|
+
)
|
|
129
|
+
paths.add_argument(
|
|
130
|
+
"container", nargs="?", metavar="CONTAINER", help="a running container"
|
|
131
|
+
)
|
|
132
|
+
return command
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def add_tunnel(subparsers):
|
|
136
|
+
"""Add the `tunnel` subparser and return it."""
|
|
137
|
+
tunnel = add_runtime_slots(
|
|
138
|
+
subparsers.add_parser(
|
|
139
|
+
"tunnel", help=cli_tunnel.HELP, usage=cli_tunnel.USAGE, allow_abbrev=False
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
tunnel.add_argument(
|
|
143
|
+
"--forward",
|
|
144
|
+
type=forward_spec,
|
|
145
|
+
action=AppendForward,
|
|
146
|
+
default=[DEFAULT_FORWARD],
|
|
147
|
+
dest="forwards",
|
|
148
|
+
metavar="SPEC",
|
|
149
|
+
help="listen on BIND:PORT inside and dial HOST:HOSTPORT on the Mac, "
|
|
150
|
+
"as [BIND:]PORT:HOST:HOSTPORT, all loopback; repeat for more "
|
|
151
|
+
"(default: 127.0.0.1:11434:127.0.0.1:11434)",
|
|
152
|
+
)
|
|
153
|
+
return tunnel
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def add_ollama_query(subparsers):
|
|
157
|
+
"""Add the `ollama-query` subparser and return it."""
|
|
158
|
+
query = add_runtime_slots(
|
|
159
|
+
subparsers.add_parser(
|
|
160
|
+
"ollama-query",
|
|
161
|
+
help=cli_query.HELP,
|
|
162
|
+
usage=cli_query.USAGE,
|
|
163
|
+
allow_abbrev=False,
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
query.add_argument(
|
|
167
|
+
"--connect",
|
|
168
|
+
type=endpoint_spec,
|
|
169
|
+
action=StoreOnce,
|
|
170
|
+
default=DEFAULT_ENDPOINT,
|
|
171
|
+
metavar="ADDRESS:PORT",
|
|
172
|
+
help="where the tunnel listens inside, loopback (default: 127.0.0.1:11434)",
|
|
173
|
+
)
|
|
174
|
+
return query
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def build_parser():
|
|
178
|
+
"""Build the top-level parser with every command."""
|
|
179
|
+
parser = argparse.ArgumentParser(
|
|
180
|
+
prog=PROG,
|
|
181
|
+
usage="\n ".join([cli_tunnel.USAGE, cli_query.USAGE, f"{PROG} version"]),
|
|
182
|
+
epilog=PATHS,
|
|
183
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
184
|
+
allow_abbrev=False,
|
|
185
|
+
**MONOCHROME,
|
|
186
|
+
)
|
|
187
|
+
parser.add_argument(
|
|
188
|
+
"--version",
|
|
189
|
+
action="version",
|
|
190
|
+
version=version_line(),
|
|
191
|
+
help="print the installed version and exit",
|
|
192
|
+
)
|
|
193
|
+
subparsers = parser.add_subparsers(dest="command", metavar="COMMAND", required=True)
|
|
194
|
+
add_tunnel(subparsers)
|
|
195
|
+
add_ollama_query(subparsers)
|
|
196
|
+
subparsers.add_parser(
|
|
197
|
+
"version", help="print the installed version and exit", allow_abbrev=False
|
|
198
|
+
)
|
|
199
|
+
return parser
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def command_help(add_command):
|
|
203
|
+
"""Return one command's own `--help` text, as argparse prints it."""
|
|
204
|
+
subparsers = argparse.ArgumentParser(prog=PROG, **MONOCHROME).add_subparsers()
|
|
205
|
+
return add_command(subparsers).format_help()
|
hello_127/cli.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
# ==============================================
|
|
3
|
+
# East Van AI -- AI for the rest of us!
|
|
4
|
+
# https://github.com/east-van-ai/hello-127
|
|
5
|
+
# contact: east-van-ai@proton.me
|
|
6
|
+
# license: MIT
|
|
7
|
+
# ==============================================
|
|
8
|
+
|
|
9
|
+
hello-127
|
|
10
|
+
|
|
11
|
+
Carries TCP over a container's standard streams, so a container with no
|
|
12
|
+
network reaches Ollama on the Mac's 127.0.0.1. A helper inside listens on
|
|
13
|
+
127.0.0.1:11434; this side dials Ollama for every connection it reports.
|
|
14
|
+
Reach for `tunnel`, then `ollama-query` to try it.
|
|
15
|
+
|
|
16
|
+
Run a command with nothing after it for its own page.
|
|
17
|
+
|
|
18
|
+
piped input:
|
|
19
|
+
hello-127 reads none.
|
|
20
|
+
|
|
21
|
+
exit codes:
|
|
22
|
+
0: success, and documentation
|
|
23
|
+
1: hello-127's own error: a missing CONTAINER, a stray word after it,
|
|
24
|
+
the runtime not on PATH, or the helper or the query exiting
|
|
25
|
+
with an error
|
|
26
|
+
2: an unknown command, an unknown option, or a bad value
|
|
27
|
+
"""
|
|
28
|
+
import sys
|
|
29
|
+
from collections import namedtuple
|
|
30
|
+
|
|
31
|
+
from hello_127 import args, cli_query, cli_tunnel, errors
|
|
32
|
+
|
|
33
|
+
# argparse exits 2 itself, so EXIT_ARGPARSE never returns through main.
|
|
34
|
+
EXIT_OK = 0
|
|
35
|
+
EXIT_ERROR = 1
|
|
36
|
+
EXIT_ARGPARSE = 2
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def command_page(module, add_command):
|
|
40
|
+
"""Return a command's answer to its bare word: its docstring, then its --help."""
|
|
41
|
+
doc = module.__doc__.strip("\n") # not strip(): the title's indent is layout
|
|
42
|
+
return f"{doc}\n\n{args.command_help(add_command).rstrip()}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
Command = namedtuple("Command", "bare usage slots action")
|
|
46
|
+
"""A command word's answer to being typed alone, its usage line, the path slots it
|
|
47
|
+
reads, and the action a full invocation runs.
|
|
48
|
+
|
|
49
|
+
`bare` returns the text for the bare word; `action` runs the command. For `version`,
|
|
50
|
+
both read from `version_line`, since running the command answers the bare word.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
COMMANDS = {
|
|
54
|
+
"tunnel": Command(
|
|
55
|
+
lambda: command_page(cli_tunnel, args.add_tunnel),
|
|
56
|
+
cli_tunnel.USAGE,
|
|
57
|
+
cli_tunnel.SLOTS,
|
|
58
|
+
lambda paths, parsed: cli_tunnel.run(*paths, parsed),
|
|
59
|
+
),
|
|
60
|
+
"ollama-query": Command(
|
|
61
|
+
lambda: command_page(cli_query, args.add_ollama_query),
|
|
62
|
+
cli_query.USAGE,
|
|
63
|
+
cli_query.SLOTS,
|
|
64
|
+
lambda paths, parsed: cli_query.run(*paths, parsed),
|
|
65
|
+
),
|
|
66
|
+
"version": Command(
|
|
67
|
+
args.version_line,
|
|
68
|
+
"hello-127 version",
|
|
69
|
+
(),
|
|
70
|
+
lambda paths, parsed: print(args.version_line()),
|
|
71
|
+
),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def usage_error(usage, message):
|
|
76
|
+
"""Report a command line the tool could not read, with the matching usage."""
|
|
77
|
+
sys.stdout.flush()
|
|
78
|
+
print(f"hello-127: {message}", file=sys.stderr)
|
|
79
|
+
print(f"Usage: {usage}", file=sys.stderr)
|
|
80
|
+
return EXIT_ERROR
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def readiness_error(message):
|
|
84
|
+
"""Report what the run needed and did not find, with no usage line."""
|
|
85
|
+
sys.stdout.flush()
|
|
86
|
+
print(f"hello-127: {message}", file=sys.stderr)
|
|
87
|
+
return EXIT_ERROR
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def runtime_error(message):
|
|
91
|
+
"""Report a run that stopped partway, naming what was written."""
|
|
92
|
+
sys.stdout.flush()
|
|
93
|
+
print(f"hello-127: {message}", file=sys.stderr)
|
|
94
|
+
return EXIT_ERROR
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main(argv=None):
|
|
98
|
+
"""Parse arguments, run the matching command, return an exit code."""
|
|
99
|
+
tokens = list(sys.argv[1:] if argv is None else argv)
|
|
100
|
+
|
|
101
|
+
if not tokens:
|
|
102
|
+
print(f"{__doc__.strip()}\n\n{args.build_parser().format_help().rstrip()}")
|
|
103
|
+
return EXIT_OK
|
|
104
|
+
|
|
105
|
+
# A command word and nothing else is a question, and its own page answers.
|
|
106
|
+
if len(tokens) == 1 and tokens[0] in COMMANDS:
|
|
107
|
+
print(COMMANDS[tokens[0]].bare())
|
|
108
|
+
return EXIT_OK
|
|
109
|
+
|
|
110
|
+
parser = args.build_parser()
|
|
111
|
+
parsed, extras = parser.parse_known_args(tokens)
|
|
112
|
+
|
|
113
|
+
if any(extra.startswith("-") for extra in extras):
|
|
114
|
+
parser.parse_args(tokens) # argparse names the flag better, exit 2
|
|
115
|
+
|
|
116
|
+
paths = args.leading_paths(tokens[1:])
|
|
117
|
+
|
|
118
|
+
command = COMMANDS[parsed.command]
|
|
119
|
+
|
|
120
|
+
if len(paths) < len(command.slots):
|
|
121
|
+
needed = " and ".join(command.slots)
|
|
122
|
+
if len(command.slots) > 1:
|
|
123
|
+
needed = f"both {needed}"
|
|
124
|
+
return usage_error(command.usage, f"{parsed.command} needs {needed}")
|
|
125
|
+
|
|
126
|
+
# A word past the slots is a stray, and so is one argparse left over after a flag.
|
|
127
|
+
strays = paths[len(command.slots) :] or extras
|
|
128
|
+
if strays:
|
|
129
|
+
last = command.slots[-1] if command.slots else "it"
|
|
130
|
+
return usage_error(
|
|
131
|
+
command.usage,
|
|
132
|
+
f"{parsed.command} takes nothing after {last}: {strays[0]!r}",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
command.action(paths, parsed)
|
|
137
|
+
except errors.ReadinessError as failure:
|
|
138
|
+
return readiness_error(str(failure))
|
|
139
|
+
except errors.RuntimeFailure as failure:
|
|
140
|
+
return runtime_error(str(failure))
|
|
141
|
+
|
|
142
|
+
return EXIT_OK
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
sys.exit(main())
|
hello_127/cli_query.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
hello-127 -- Ollama Query
|
|
3
|
+
|
|
4
|
+
https://github.com/east-van-ai/hello-127
|
|
5
|
+
|
|
6
|
+
Asks Ollama one question from inside a running container, and prints the
|
|
7
|
+
answer as it streams. The program runs in the container and dials
|
|
8
|
+
127.0.0.1:11434, or the loopback ADDRESS:PORT given to --connect, so it
|
|
9
|
+
reaches Ollama only through a running `tunnel` listening there.
|
|
10
|
+
|
|
11
|
+
The model is Cohere's Tiny Aya, hf.co/CohereLabs/tiny-aya-global-GGUF:Q4_K_M,
|
|
12
|
+
and the prompt is fixed. It is a probe, not a client.
|
|
13
|
+
|
|
14
|
+
RUNTIME `docker` runs the program with `docker exec`, and `container` with
|
|
15
|
+
Apple Container's `container exec`. The container must have python3, 3.10 or
|
|
16
|
+
newer.
|
|
17
|
+
|
|
18
|
+
With no tunnel running, the program says nothing answers on that
|
|
19
|
+
address and port, and exits 1.
|
|
20
|
+
"""
|
|
21
|
+
import subprocess
|
|
22
|
+
|
|
23
|
+
from hello_127 import errors, launch, query
|
|
24
|
+
|
|
25
|
+
HELP = "ask Ollama one question from inside a container, through the tunnel"
|
|
26
|
+
USAGE = "hello-127 ollama-query RUNTIME CONTAINER [--connect ADDRESS:PORT]"
|
|
27
|
+
SLOTS = ("RUNTIME", "CONTAINER")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run(runtime, name, parsed):
|
|
31
|
+
"""Run the query inside container `name`, streaming its answer to stdout."""
|
|
32
|
+
program = launch.source(query, "ask", *parsed.connect)
|
|
33
|
+
command = launch.exec_command(runtime, name, program)
|
|
34
|
+
try:
|
|
35
|
+
code = subprocess.run(command).returncode
|
|
36
|
+
except FileNotFoundError:
|
|
37
|
+
raise errors.ReadinessError(f"{command[0]} is not on PATH") from None
|
|
38
|
+
except KeyboardInterrupt:
|
|
39
|
+
raise errors.RuntimeFailure("the query was interrupted") from None
|
|
40
|
+
if code:
|
|
41
|
+
raise errors.RuntimeFailure(f"the query exited with code {code}")
|
hello_127/cli_tunnel.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
hello-127 -- Tunnel
|
|
3
|
+
|
|
4
|
+
https://github.com/east-van-ai/hello-127
|
|
5
|
+
|
|
6
|
+
Starts a helper inside a running container and relays its connections to
|
|
7
|
+
a service on the Mac's loopback. By default the helper listens on
|
|
8
|
+
127.0.0.1:11434 inside, and the Mac dials Ollama on 127.0.0.1:11434, so a
|
|
9
|
+
program in the container reaches Ollama as if it were local, even with
|
|
10
|
+
--network none.
|
|
11
|
+
|
|
12
|
+
--forward changes both ends, in `ssh -L`'s shape: BIND:PORT is where the
|
|
13
|
+
helper listens inside, and HOST:HOSTPORT is what the Mac dials. BIND may be
|
|
14
|
+
left out, and is then 127.0.0.1. Every address must be loopback, 127.0.0.1
|
|
15
|
+
to 127.255.255.255. The Mac answers only on the 127.x addresses its lo0
|
|
16
|
+
carries, by default 127.0.0.1 alone. Another HOST leaves each connection
|
|
17
|
+
hanging until macOS gives up, after about 75 seconds.
|
|
18
|
+
|
|
19
|
+
--forward may repeat, once per listener, and one helper carries them all.
|
|
20
|
+
Two forwards may share a HOST:HOSTPORT, but not a BIND:PORT. A HOST that
|
|
21
|
+
hangs stalls every forward, not just its own, until macOS gives up.
|
|
22
|
+
|
|
23
|
+
RUNTIME `docker` runs the helper with `docker exec -i`, and `container` with
|
|
24
|
+
Apple Container's `container exec -i`. The container must already be running
|
|
25
|
+
and have python3, 3.10 or newer.
|
|
26
|
+
|
|
27
|
+
The tunnel runs until Ctrl-C, which closes the helper's input so it exits.
|
|
28
|
+
If the helper lingers for 5 seconds, the exec is killed.
|
|
29
|
+
"""
|
|
30
|
+
import asyncio
|
|
31
|
+
|
|
32
|
+
from hello_127 import errors, host, launch, wire
|
|
33
|
+
|
|
34
|
+
HELP = "relay a container's loopback port to the Mac's, 11434 by default"
|
|
35
|
+
USAGE = "hello-127 tunnel RUNTIME CONTAINER [--forward SPEC]..."
|
|
36
|
+
SLOTS = ("RUNTIME", "CONTAINER")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run(runtime, name, parsed):
|
|
40
|
+
"""Run the tunnel into container `name` until Ctrl-C."""
|
|
41
|
+
listeners = [(forward.bind, forward.port) for forward in parsed.forwards]
|
|
42
|
+
program = launch.source(wire, "listen", listeners)
|
|
43
|
+
command = launch.exec_command(runtime, name, program, interactive=True)
|
|
44
|
+
try:
|
|
45
|
+
code, stopped = asyncio.run(host.run_until_signal(command, parsed.forwards))
|
|
46
|
+
except FileNotFoundError:
|
|
47
|
+
raise errors.ReadinessError(f"{command[0]} is not on PATH") from None
|
|
48
|
+
if code and not stopped:
|
|
49
|
+
raise errors.RuntimeFailure(f"the helper exited with code {code}")
|
hello_127/errors.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""The two failures a command can raise. Grammar errors need no class."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ReadinessError(Exception):
|
|
5
|
+
"""Something the run needed was missing, found before the first write."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RuntimeFailure(Exception):
|
|
9
|
+
"""A run that stopped partway, after the first write."""
|
hello_127/host.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""The Mac side: the relay."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import signal
|
|
5
|
+
|
|
6
|
+
from hello_127 import wire
|
|
7
|
+
|
|
8
|
+
STOP_GRACE = 5.0
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def run(command, forwards, stop=None):
|
|
12
|
+
"""Run the helper command and relay each connection to its forward's target.
|
|
13
|
+
|
|
14
|
+
An OPEN frame names its forward by index in `forwards`, and the connection goes to
|
|
15
|
+
that forward's `host:hostport`.
|
|
16
|
+
|
|
17
|
+
The helper runs in its own session, so Ctrl-C reaches only this process. Setting
|
|
18
|
+
`stop` closes the helper's stdin, which tells it to exit. Return the helper's exit
|
|
19
|
+
code and whether `stop` ended the run.
|
|
20
|
+
"""
|
|
21
|
+
stop = stop or asyncio.Event()
|
|
22
|
+
process = await asyncio.create_subprocess_exec(
|
|
23
|
+
*command,
|
|
24
|
+
stdin=asyncio.subprocess.PIPE,
|
|
25
|
+
stdout=asyncio.subprocess.PIPE,
|
|
26
|
+
start_new_session=True,
|
|
27
|
+
)
|
|
28
|
+
tunnel = wire.Tunnel(process.stdout, process.stdin, {}, {})
|
|
29
|
+
|
|
30
|
+
async def dial(payload):
|
|
31
|
+
"""Open a connection to the target of the forward an OPEN frame names."""
|
|
32
|
+
(index,) = wire.INDEX.unpack(payload)
|
|
33
|
+
if index >= len(forwards):
|
|
34
|
+
raise OSError(f"no forward number {index}")
|
|
35
|
+
return await asyncio.open_connection(forwards[index].host, forwards[index].hostport)
|
|
36
|
+
|
|
37
|
+
relay = asyncio.create_task(wire.dispatch(tunnel, dial=dial))
|
|
38
|
+
stopped = asyncio.create_task(stop.wait())
|
|
39
|
+
await asyncio.wait({relay, stopped}, return_when=asyncio.FIRST_COMPLETED)
|
|
40
|
+
|
|
41
|
+
if stop.is_set():
|
|
42
|
+
wire.log("outside: stopping, closing the helper's input")
|
|
43
|
+
process.stdin.close()
|
|
44
|
+
try:
|
|
45
|
+
await asyncio.wait_for(process.wait(), STOP_GRACE)
|
|
46
|
+
except TimeoutError:
|
|
47
|
+
wire.log(f"outside: helper still running after {STOP_GRACE:g} s, killing exec")
|
|
48
|
+
process.kill()
|
|
49
|
+
await process.wait()
|
|
50
|
+
await relay
|
|
51
|
+
stopped.cancel()
|
|
52
|
+
return process.returncode, stop.is_set()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def run_until_signal(command, forwards):
|
|
56
|
+
"""Run the tunnel until SIGINT or SIGTERM, and return what `run` returns."""
|
|
57
|
+
stop = asyncio.Event()
|
|
58
|
+
loop = asyncio.get_running_loop()
|
|
59
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
60
|
+
loop.add_signal_handler(sig, stop.set)
|
|
61
|
+
for forward in forwards:
|
|
62
|
+
wire.log(
|
|
63
|
+
f"outside: relaying {forward.bind}:{forward.port}"
|
|
64
|
+
f" to {forward.host}:{forward.hostport}"
|
|
65
|
+
)
|
|
66
|
+
wire.log("outside: Ctrl-C to stop")
|
|
67
|
+
return await run(command, forwards, stop=stop)
|
hello_127/launch.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""The exec commands that run a program from this package inside a container."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
EXEC = {"docker": ["docker", "exec"], "container": ["container", "exec"]}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def source(module, entry, *arguments):
|
|
9
|
+
"""Return `module`'s own file, then one call to `entry(*arguments)`, for `python3 -c`."""
|
|
10
|
+
call = ", ".join(map(repr, arguments))
|
|
11
|
+
return f"{Path(module.__file__).read_text()}\n{entry}({call})\n"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def exec_command(runtime, name, program, interactive=False):
|
|
15
|
+
"""Return the exec command that runs `program` with python3 inside container `name`.
|
|
16
|
+
|
|
17
|
+
`interactive` adds `-i`, which keeps the program's stdin connected. The helper
|
|
18
|
+
needs it for its frames and its stop. Without it, the helper never exits.
|
|
19
|
+
"""
|
|
20
|
+
return [*EXEC[runtime], *(["-i"] if interactive else []), name, "python3", "-c", program]
|
hello_127/query.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Ask Ollama one question from inside a container, and print the answer as it streams.
|
|
2
|
+
|
|
3
|
+
Sent into the container as `python3 -c`, like the helper, so it stays standard library
|
|
4
|
+
only and runs on Python 3.10. It dials only the address it is given.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import http.client
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
|
|
13
|
+
MODEL = "hf.co/CohereLabs/tiny-aya-global-GGUF:Q4_K_M"
|
|
14
|
+
PROMPT = "Say hello from a container in five sentences."
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fail(message):
|
|
18
|
+
"""Print one line to stderr and exit 1."""
|
|
19
|
+
print(f"hello-127: {message}", file=sys.stderr, flush=True)
|
|
20
|
+
raise SystemExit(1)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def ask(address, port):
|
|
24
|
+
"""Post the prompt to address:port and print the streamed answer.
|
|
25
|
+
|
|
26
|
+
The answer counts as finished only at Ollama's `done` line. A stream that ends
|
|
27
|
+
before it fails, since `http.client` ends quietly when a cut falls between chunks.
|
|
28
|
+
"""
|
|
29
|
+
url = f"http://{address}:{port}/api/chat"
|
|
30
|
+
body = json.dumps(
|
|
31
|
+
{"model": MODEL, "messages": [{"role": "user", "content": PROMPT}]}
|
|
32
|
+
).encode()
|
|
33
|
+
try:
|
|
34
|
+
reply = urllib.request.urlopen(url, body)
|
|
35
|
+
except urllib.error.HTTPError as error:
|
|
36
|
+
fail(f"Ollama answered {error.code}: {json.load(error).get('error', '')}")
|
|
37
|
+
except urllib.error.URLError as error:
|
|
38
|
+
if isinstance(error.reason, ConnectionRefusedError):
|
|
39
|
+
fail(f"nothing answers on {address}:{port} here. Is the tunnel running?")
|
|
40
|
+
fail(f"cannot reach {address}:{port}: {error.reason}")
|
|
41
|
+
except http.client.RemoteDisconnected:
|
|
42
|
+
fail("the tunnel closed the connection. Is Ollama running on the Mac?")
|
|
43
|
+
with reply:
|
|
44
|
+
try:
|
|
45
|
+
for line in reply:
|
|
46
|
+
part = json.loads(line)
|
|
47
|
+
if "error" in part:
|
|
48
|
+
print()
|
|
49
|
+
fail(f"Ollama stopped with an error: {part['error']}")
|
|
50
|
+
print(part["message"]["content"], end="", flush=True)
|
|
51
|
+
if part.get("done"):
|
|
52
|
+
print()
|
|
53
|
+
return
|
|
54
|
+
except http.client.IncompleteRead:
|
|
55
|
+
pass
|
|
56
|
+
print()
|
|
57
|
+
fail("the answer was cut off before Ollama finished it")
|
hello_127/wire.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Carry TCP connections as frames over a pair of pipes.
|
|
2
|
+
|
|
3
|
+
Both ends of the tunnel run this file. The Mac side imports it. The container side
|
|
4
|
+
receives its source through `python3 -c`, so it stays standard library only, runs on
|
|
5
|
+
Python 3.10, and never writes to stdout, which is the wire.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import errno
|
|
10
|
+
import itertools
|
|
11
|
+
import struct
|
|
12
|
+
import sys
|
|
13
|
+
from collections import namedtuple
|
|
14
|
+
|
|
15
|
+
OPEN, DATA, CLOSE = 1, 2, 3
|
|
16
|
+
HEADER = struct.Struct("!BII")
|
|
17
|
+
INDEX = struct.Struct("!H")
|
|
18
|
+
CHUNK = 64 * 1024
|
|
19
|
+
|
|
20
|
+
Tunnel = namedtuple("Tunnel", "pipe_in pipe_out writers remaining")
|
|
21
|
+
"""One end of the tunnel: the pipe both ways, and its open connections.
|
|
22
|
+
|
|
23
|
+
`writers` maps a connection number to its socket's writer. `remaining` counts the
|
|
24
|
+
directions of that connection still open, from 2 down to 0.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def log(message):
|
|
29
|
+
"""Print one line to stderr, since stdout belongs to the frames."""
|
|
30
|
+
print(f"hello-127: {message}", file=sys.stderr, flush=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def frame(kind, conn, payload=b""):
|
|
34
|
+
"""Return one frame: kind, connection number, payload length, then the payload."""
|
|
35
|
+
return HEADER.pack(kind, conn, len(payload)) + payload
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def read_frame(pipe):
|
|
39
|
+
"""Return the next (kind, conn, payload) from the pipe, or None at its end."""
|
|
40
|
+
try:
|
|
41
|
+
kind, conn, length = HEADER.unpack(await pipe.readexactly(HEADER.size))
|
|
42
|
+
payload = await pipe.readexactly(length)
|
|
43
|
+
except asyncio.IncompleteReadError:
|
|
44
|
+
return None
|
|
45
|
+
return kind, conn, payload
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def open_link(tunnel, conn, writer):
|
|
49
|
+
"""Record a new connection with both of its directions open."""
|
|
50
|
+
tunnel.writers[conn] = writer
|
|
51
|
+
tunnel.remaining[conn] = 2
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def finish_half(tunnel, conn):
|
|
55
|
+
"""Count one direction of a connection as done, and drop it once both are."""
|
|
56
|
+
if conn not in tunnel.remaining:
|
|
57
|
+
return
|
|
58
|
+
tunnel.remaining[conn] -= 1
|
|
59
|
+
if tunnel.remaining[conn] == 0:
|
|
60
|
+
del tunnel.remaining[conn]
|
|
61
|
+
tunnel.writers.pop(conn).close()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def pump(tunnel, conn, reader):
|
|
65
|
+
"""Copy one socket's bytes into DATA frames, then send CLOSE at its end."""
|
|
66
|
+
try:
|
|
67
|
+
while True:
|
|
68
|
+
chunk = await reader.read(CHUNK)
|
|
69
|
+
if not chunk:
|
|
70
|
+
break
|
|
71
|
+
tunnel.pipe_out.write(frame(DATA, conn, chunk))
|
|
72
|
+
await tunnel.pipe_out.drain()
|
|
73
|
+
except ConnectionError:
|
|
74
|
+
pass
|
|
75
|
+
if not tunnel.pipe_out.is_closing():
|
|
76
|
+
tunnel.pipe_out.write(frame(CLOSE, conn))
|
|
77
|
+
finish_half(tunnel, conn)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def deliver(tunnel, conn, payload):
|
|
81
|
+
"""Write a DATA frame's payload to its socket, if the socket is still there."""
|
|
82
|
+
writer = tunnel.writers.get(conn)
|
|
83
|
+
if writer is None or writer.is_closing():
|
|
84
|
+
return
|
|
85
|
+
writer.write(payload)
|
|
86
|
+
try:
|
|
87
|
+
await writer.drain()
|
|
88
|
+
except ConnectionError:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def half_close(tunnel, conn):
|
|
93
|
+
"""Act on a CLOSE frame: end the socket's write side and count the direction."""
|
|
94
|
+
writer = tunnel.writers.get(conn)
|
|
95
|
+
if writer is None:
|
|
96
|
+
return
|
|
97
|
+
try:
|
|
98
|
+
if writer.can_write_eof():
|
|
99
|
+
writer.write_eof()
|
|
100
|
+
except OSError:
|
|
101
|
+
pass
|
|
102
|
+
finish_half(tunnel, conn)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def dispatch(tunnel, dial=None):
|
|
106
|
+
"""Act on frames from the pipe until it ends, then close every connection.
|
|
107
|
+
|
|
108
|
+
`dial` takes an OPEN frame's payload and opens its far end. Only the Mac side
|
|
109
|
+
passes one. It runs inline, so no DATA frame overtakes the dial for its own
|
|
110
|
+
connection.
|
|
111
|
+
"""
|
|
112
|
+
pumps = set()
|
|
113
|
+
while True:
|
|
114
|
+
got = await read_frame(tunnel.pipe_in)
|
|
115
|
+
if got is None:
|
|
116
|
+
break
|
|
117
|
+
kind, conn, payload = got
|
|
118
|
+
if kind == OPEN and dial is not None:
|
|
119
|
+
try:
|
|
120
|
+
reader, writer = await dial(payload)
|
|
121
|
+
except OSError as error:
|
|
122
|
+
log(f"connection {conn}: dial failed: {error}")
|
|
123
|
+
tunnel.pipe_out.write(frame(CLOSE, conn))
|
|
124
|
+
continue
|
|
125
|
+
open_link(tunnel, conn, writer)
|
|
126
|
+
task = asyncio.ensure_future(pump(tunnel, conn, reader))
|
|
127
|
+
pumps.add(task)
|
|
128
|
+
task.add_done_callback(pumps.discard)
|
|
129
|
+
elif kind == DATA:
|
|
130
|
+
await deliver(tunnel, conn, payload)
|
|
131
|
+
elif kind == CLOSE:
|
|
132
|
+
half_close(tunnel, conn)
|
|
133
|
+
for writer in tunnel.writers.values():
|
|
134
|
+
writer.close()
|
|
135
|
+
tunnel.writers.clear()
|
|
136
|
+
tunnel.remaining.clear()
|
|
137
|
+
for task in pumps:
|
|
138
|
+
task.cancel()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def stdio_pipes():
|
|
142
|
+
"""Return this process's stdin and stdout as an asyncio reader and writer."""
|
|
143
|
+
loop = asyncio.get_running_loop()
|
|
144
|
+
pipe_in = asyncio.StreamReader()
|
|
145
|
+
await loop.connect_read_pipe(
|
|
146
|
+
lambda: asyncio.StreamReaderProtocol(pipe_in), sys.stdin.buffer
|
|
147
|
+
)
|
|
148
|
+
transport, protocol = await loop.connect_write_pipe(
|
|
149
|
+
asyncio.streams.FlowControlMixin, sys.stdout.buffer
|
|
150
|
+
)
|
|
151
|
+
pipe_out = asyncio.StreamWriter(transport, protocol, None, loop)
|
|
152
|
+
return pipe_in, pipe_out
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def serve_inside(listeners):
|
|
156
|
+
"""Listen on each (address, port) and send every connection through stdout.
|
|
157
|
+
|
|
158
|
+
An OPEN frame carries its listener's index in `listeners`, so the Mac side knows
|
|
159
|
+
where to dial. A listener that cannot bind ends the helper, with no partial tunnel.
|
|
160
|
+
"""
|
|
161
|
+
pipe_in, pipe_out = await stdio_pipes()
|
|
162
|
+
tunnel = Tunnel(pipe_in, pipe_out, {}, {})
|
|
163
|
+
numbers = itertools.count(1)
|
|
164
|
+
|
|
165
|
+
def acceptor(index):
|
|
166
|
+
"""Return the accept callback for the listener at `index`."""
|
|
167
|
+
|
|
168
|
+
async def accept(reader, writer):
|
|
169
|
+
"""Number a new connection, announce it with its listener, and pump it."""
|
|
170
|
+
conn = next(numbers)
|
|
171
|
+
open_link(tunnel, conn, writer)
|
|
172
|
+
pipe_out.write(frame(OPEN, conn, INDEX.pack(index)))
|
|
173
|
+
await pump(tunnel, conn, reader)
|
|
174
|
+
|
|
175
|
+
return accept
|
|
176
|
+
|
|
177
|
+
servers = []
|
|
178
|
+
for index, (address, port) in enumerate(listeners):
|
|
179
|
+
try:
|
|
180
|
+
servers.append(await asyncio.start_server(acceptor(index), address, port))
|
|
181
|
+
except OSError as error:
|
|
182
|
+
reason = error.strerror
|
|
183
|
+
if error.errno == errno.EADDRINUSE:
|
|
184
|
+
reason = "already in use in this container"
|
|
185
|
+
log(f"inside: cannot listen on {address}:{port}: {reason}")
|
|
186
|
+
raise SystemExit(1) from None
|
|
187
|
+
log(f"inside: listening on {address}:{port}")
|
|
188
|
+
await dispatch(tunnel)
|
|
189
|
+
for server in servers:
|
|
190
|
+
server.close()
|
|
191
|
+
log("inside: input ended, helper exiting")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def listen(listeners):
|
|
195
|
+
"""Run the inside half until stdin ends. The container's entry point."""
|
|
196
|
+
asyncio.run(serve_inside(listeners))
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hello-127
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Carry TCP over a container's standard streams to a service on the Mac's loopback
|
|
5
|
+
Author: Go Nakamaru
|
|
6
|
+
Author-email: Go Nakamaru <gonakamaru@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Operating System :: MacOS
|
|
10
|
+
Requires-Python: >=3.14
|
|
11
|
+
Project-URL: Repository, https://github.com/east-van-ai/hello-127
|
|
12
|
+
Project-URL: Changelog, https://github.com/east-van-ai/hello-127/blob/main/CHANGELOG.md
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# hello-127
|
|
16
|
+
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
Can a Docker or Apple Container with no network access still reach a service on
|
|
20
|
+
the Mac's 127.0.0.1, such as Ollama? Yes, through a pipe: the standard streams
|
|
21
|
+
of `docker exec -i` or `container exec -i`. hello-127 is a small command that
|
|
22
|
+
carries TCP through that pipe.
|
|
23
|
+
|
|
24
|
+
hello-127 is a reusable CLI tool for this purpose. This page covers its design,
|
|
25
|
+
limitations, install, behaviour, and known failure modes.
|
|
26
|
+
|
|
27
|
+
## How a container reaches loopback today
|
|
28
|
+
|
|
29
|
+
A program on a Mac binds to 127.0.0.1 to stay private. Ollama on a Mac does
|
|
30
|
+
this by default. Only programs on the same machine should reach it.
|
|
31
|
+
|
|
32
|
+
Docker Desktop bends that. When a Docker container connects to the Mac, Docker
|
|
33
|
+
Desktop's own process, `com.docker.backend`, opens a fresh connection to
|
|
34
|
+
127.0.0.1 on the Mac. Ollama sees a local client. The macOS firewall sees an
|
|
35
|
+
outgoing connection, and never asks.
|
|
36
|
+
|
|
37
|
+
Measured on macOS with Docker Desktop 4.92.0 and Ollama 0.33.3:
|
|
38
|
+
|
|
39
|
+
- During a run, `lsof` shows `com.docker.backend` as Ollama's only client.
|
|
40
|
+
- A test web server bound to 127.0.0.1 refused the Mac's own LAN address.
|
|
41
|
+
A container on Docker's default network read from it anyway.
|
|
42
|
+
|
|
43
|
+
That is a gap. "Bound to loopback" stops meaning "local programs only" the
|
|
44
|
+
moment Docker Desktop is running.
|
|
45
|
+
|
|
46
|
+
Apple Container has no such hop. Its traffic arrives over a network
|
|
47
|
+
interface, so reaching Ollama there needs a firewall exception or sudo.
|
|
48
|
+
|
|
49
|
+
## The same job, on purpose
|
|
50
|
+
|
|
51
|
+
Every container runtime can already run a program inside a container and
|
|
52
|
+
hand you its standard input and output: `docker exec -i`, or
|
|
53
|
+
`container exec -i`. That is a pipe. hello-127 carries TCP through it.
|
|
54
|
+
|
|
55
|
+
```text
|
|
56
|
+
container (--network none) Mac
|
|
57
|
+
┌──────────────────────────────┐ ┌──────────────────────────┐
|
|
58
|
+
│ your program │ │ ┌──────────┐ │
|
|
59
|
+
│ │ connects to │ │ ┌─ │ Ollama │◄─┐ │
|
|
60
|
+
│ ▼ 127.0.0.1:11434 │ stdin │ ▼ └──────────┘ │ │
|
|
61
|
+
│ helper ◄─────────────────┼─────────┼── hello-127 │ │
|
|
62
|
+
│ └─────────────────────────►┼─────────┼──► dials 127.0.0.1 ─┘ │
|
|
63
|
+
│ │ stdout │ │
|
|
64
|
+
└──────────────────────────────┘ └──────────────────────────┘
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- **Inside the container**, a small helper listens on 127.0.0.1:11434. It
|
|
68
|
+
shares the container's network, so your program sees Ollama as local.
|
|
69
|
+
- **Over the pipe**, each connection becomes frames: open, data, close, each
|
|
70
|
+
tagged with a connection number. Many connections share one pipe.
|
|
71
|
+
- **On the Mac**, hello-127 reads the frames and dials Ollama on loopback.
|
|
72
|
+
Every connection goes out, none comes in. No firewall prompt, no sudo.
|
|
73
|
+
|
|
74
|
+
The helper is not installed in the image. hello-127 sends its source as an
|
|
75
|
+
argument to `python3 -c`, so the image needs only `python3`, 3.10 or newer.
|
|
76
|
+
|
|
77
|
+
None of this is new. SSH's `ProxyCommand` carries one TCP stream over a
|
|
78
|
+
program's standard streams. `ssh -L` and `kubectl port-forward` carry many
|
|
79
|
+
connections through one channel.
|
|
80
|
+
|
|
81
|
+
## What it is not
|
|
82
|
+
|
|
83
|
+
It carries bytes, and never reads them. Whatever the service lets a local
|
|
84
|
+
client do, a program in the container can do too. The tunnel opens the whole
|
|
85
|
+
service, not one part of it.
|
|
86
|
+
|
|
87
|
+
It also changes how a connection ends. TCP has two endings: a clean close,
|
|
88
|
+
and a reset when something breaks. The pipe has no frame for a reset, so
|
|
89
|
+
every ending arrives as a clean close. A protocol that marks its own end,
|
|
90
|
+
as HTTP does, still notices a cut. A protocol that simply stops at the end
|
|
91
|
+
of the connection cannot tell a crash from a finish.
|
|
92
|
+
|
|
93
|
+
A gatekeeper is the other design. It understands each request and decides
|
|
94
|
+
what to allow. hello-127 makes the pipe on purpose and leaves the deciding
|
|
95
|
+
out.
|
|
96
|
+
|
|
97
|
+
## Install
|
|
98
|
+
|
|
99
|
+
- A Mac.
|
|
100
|
+
- [uv](https://docs.astral.sh/uv/), which installs hello-127 and fetches
|
|
101
|
+
its Python.
|
|
102
|
+
- [Docker Desktop](https://www.docker.com/products/docker-desktop/), or
|
|
103
|
+
[Apple Container](https://github.com/apple/container).
|
|
104
|
+
- hello-127, from PyPI:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
uv tool install hello-127
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
hello-127 itself needs nothing beyond Python's standard library.
|
|
111
|
+
|
|
112
|
+
## Try it yourself
|
|
113
|
+
|
|
114
|
+
A container started with `--network none` streams a chat from Ollama
|
|
115
|
+
through hello-127. Without the tunnel, the same chat is refused.
|
|
116
|
+
|
|
117
|
+
It needs [Ollama](https://ollama.com), running, with Cohere's Tiny Aya
|
|
118
|
+
pulled, a 2.1 GB download:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
ollama pull hf.co/CohereLabs/tiny-aya-global-GGUF:Q4_K_M
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
It takes three terminals. In the first, start a container with no network,
|
|
125
|
+
then the tunnel into it:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
docker run -d --rm --name h127 --network none python:3.12-slim sleep infinity
|
|
129
|
+
hello-127 tunnel docker h127
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- `-d` runs the container in the background.
|
|
133
|
+
- `--rm` deletes the container once it stops.
|
|
134
|
+
- `--name h127` gives it the name the other commands use.
|
|
135
|
+
- `--network none` leaves it with loopback only.
|
|
136
|
+
- `sleep infinity` keeps it running with nothing else to do.
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
hello-127: outside: relaying 127.0.0.1:11434 to 127.0.0.1:11434
|
|
140
|
+
hello-127: outside: Ctrl-C to stop
|
|
141
|
+
hello-127: inside: listening on 127.0.0.1:11434
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
In the second, chat from inside the container:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
hello-127 ollama-query docker h127
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
```text
|
|
151
|
+
Containerized applications are a revolutionary way to deploy and manage software, ...
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`ollama-query` runs a small Python program in the container with
|
|
155
|
+
`docker exec`. It asks Cohere's Tiny Aya one fixed question at
|
|
156
|
+
127.0.0.1:11434 and prints the answer as it streams. It knows nothing else
|
|
157
|
+
about the Mac.
|
|
158
|
+
|
|
159
|
+
Run it before the tunnel is up, and the same program is refused:
|
|
160
|
+
|
|
161
|
+
```text
|
|
162
|
+
hello-127: nothing answers on 127.0.0.1:11434 here. Is the tunnel running?
|
|
163
|
+
hello-127: the query exited with code 1
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
In the third, while the answer streams, ask who holds Ollama's port:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
lsof -nP -iTCP:11434 -sTCP:ESTABLISHED
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
- `-n` and `-P` print addresses and ports as numbers, not names.
|
|
173
|
+
- `-iTCP:11434` picks TCP sockets on Ollama's port, 11434.
|
|
174
|
+
- `-sTCP:ESTABLISHED` keeps only live connections, in the ESTABLISHED state.
|
|
175
|
+
|
|
176
|
+
Once the answer ends, only Ollama's listening socket is left, so the list
|
|
177
|
+
comes back empty.
|
|
178
|
+
|
|
179
|
+
```text
|
|
180
|
+
COMMAND PID USER FD TYPE ... NAME
|
|
181
|
+
ollama 803 you 7u IPv4 ... TCP 127.0.0.1:11434->127.0.0.1:52235 (ESTABLISHED)
|
|
182
|
+
python3.1 55300 you 6u IPv4 ... TCP 127.0.0.1:52235->127.0.0.1:11434 (ESTABLISHED)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Ollama's only client is hello-127's Python, which `lsof` cuts to
|
|
186
|
+
`python3.1`. It is not `com.docker.backend`.
|
|
187
|
+
|
|
188
|
+
Back in the first terminal, Ctrl-C closes the helper's input, and the helper
|
|
189
|
+
exits:
|
|
190
|
+
|
|
191
|
+
```text
|
|
192
|
+
hello-127: outside: stopping, closing the helper's input
|
|
193
|
+
hello-127: inside: input ended, helper exiting
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Then `docker stop h127` removes the container.
|
|
197
|
+
|
|
198
|
+
Apple Container works the same way, with the same flags:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
container run -d --rm --name h127 --network none python:3.14-slim sleep infinity
|
|
202
|
+
hello-127 tunnel container h127
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The chat is `hello-127 ollama-query container h127`, and `lsof` again
|
|
206
|
+
names hello-127's Python. Then `container stop h127` removes the container.
|
|
207
|
+
|
|
208
|
+
Measured on macOS with Docker 29.8.0, Apple Container 1.6.0, and Ollama
|
|
209
|
+
0.33.3.
|
|
210
|
+
|
|
211
|
+
## Other ports
|
|
212
|
+
|
|
213
|
+
Ollama is only the specimen. The tunnel carries any TCP service on the
|
|
214
|
+
Mac's loopback. `--forward` picks both ends, in the shape `ssh -L` uses:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
hello-127 tunnel docker h127 --forward 127.0.0.2:80:127.0.0.1:11434
|
|
218
|
+
hello-127 ollama-query docker h127 --connect 127.0.0.2:80
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
- `127.0.0.2:80` is where the helper listens inside. Leave the address out,
|
|
222
|
+
and it is 127.0.0.1.
|
|
223
|
+
- `127.0.0.1:11434` is what the Mac dials.
|
|
224
|
+
- `--connect` points `ollama-query` at the helper.
|
|
225
|
+
|
|
226
|
+
`--forward` repeats, and one helper carries every listener:
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
hello-127 tunnel docker h127 --forward 11434:127.0.0.1:11434 --forward 8080:127.0.0.1:8000
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Two forwards may dial the same target. Two listeners on the same address
|
|
233
|
+
and port would never both bind, so hello-127 refuses them.
|
|
234
|
+
|
|
235
|
+
Every address must be loopback, on both ends. A wider address on the Mac
|
|
236
|
+
would make it a relay to the LAN for a container with no network. A wider
|
|
237
|
+
one inside, such as 0.0.0.0, would publish the Mac's service to any
|
|
238
|
+
network the container does have. hello-127 refuses both before anything
|
|
239
|
+
runs:
|
|
240
|
+
|
|
241
|
+
```text
|
|
242
|
+
usage: hello-127 tunnel RUNTIME CONTAINER [--forward SPEC]...
|
|
243
|
+
hello-127 tunnel: error: argument --forward: not a loopback address: '10.0.0.5'
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## How it fails
|
|
247
|
+
|
|
248
|
+
Each of these was run on both runtimes. The tunnel is left as it is.
|
|
249
|
+
|
|
250
|
+
- **One slow reader stalls everyone.** All connections share one pipe. A
|
|
251
|
+
client that leaves about a megabyte unread freezes every other connection.
|
|
252
|
+
When it reads again, they all finish, and nothing is lost.
|
|
253
|
+
- **A cut-off stream can look complete.** If the tunnel dies mid-stream,
|
|
254
|
+
the client sees a clean close, not an error. Whether it notices depends on
|
|
255
|
+
the protocol. Against Ollama, the stock `ollama` library raises an error,
|
|
256
|
+
and Python's built-in `http.client` does not. So `ollama-query` also
|
|
257
|
+
waits for Ollama's own `done` line.
|
|
258
|
+
- **A crash on the Mac reads as a finish.** A test server sent 64 KiB, then
|
|
259
|
+
reset the connection. A client that reads until the end got an error on
|
|
260
|
+
the Mac itself. Through the tunnel, it got 64 KiB and a clean end: a
|
|
261
|
+
short file that looks complete. Docker's default network does the same,
|
|
262
|
+
so `com.docker.backend` hides the reset too.
|
|
263
|
+
- **A service that is down looks like one that hung up.** The helper
|
|
264
|
+
accepts a connection before the Mac dials. With nothing listening on the
|
|
265
|
+
Mac, Python's `smtplib` is refused on the Mac and on Docker's default
|
|
266
|
+
network. Through the tunnel, it connects, then reads "Connection
|
|
267
|
+
unexpectedly closed". With the server up, its greeting arrives as usual.
|
|
268
|
+
- **No `python3` in the image.** The tunnel stops at once. Apple Container
|
|
269
|
+
says why. Docker prints its reason on the exec's output, which is the
|
|
270
|
+
pipe, so hello-127 swallows it and shows only "code 127".
|
|
271
|
+
- **The container is killed mid-stream.** The tunnel notices, reports it,
|
|
272
|
+
and exits. Nothing is left connected on the Mac.
|
|
273
|
+
- **A Mac address nobody answers on.** Linux answers on all of 127.x, so
|
|
274
|
+
the helper can listen on 127.0.0.2 inside. macOS answers only on the
|
|
275
|
+
addresses its loopback carries, by default 127.0.0.1 alone. A forward to
|
|
276
|
+
127.0.0.2 on the Mac holds each connection for 76 seconds, until macOS
|
|
277
|
+
gives up. `ollama-query` then blames Ollama: "the tunnel closed the
|
|
278
|
+
connection".
|
|
279
|
+
- **A dead forward stalls the live ones.** The Mac finishes each dial
|
|
280
|
+
before it reads the next frame, so no data can overtake its own
|
|
281
|
+
connection. A dial that hangs holds up every forward. Behind a forward to
|
|
282
|
+
127.0.0.2, a chat on a healthy forward waited about 75 seconds for its
|
|
283
|
+
first word, then streamed in full.
|
|
284
|
+
|
|
285
|
+
And one thing that works: a client that has never heard of hello-127. It
|
|
286
|
+
connects to 127.0.0.1 as usual, and the tunnel does the rest. The stock
|
|
287
|
+
`ollama` Python library, with its default settings, works unchanged from a
|
|
288
|
+
container with no network.
|
|
289
|
+
|
|
290
|
+
## More documentation
|
|
291
|
+
|
|
292
|
+
- [Changelog](https://github.com/east-van-ai/hello-127/blob/main/CHANGELOG.md): project history and releases
|
|
293
|
+
|
|
294
|
+
## Use of AI
|
|
295
|
+
|
|
296
|
+
Code and documentation are written in collaboration with remote and local AI; design
|
|
297
|
+
decisions, code review, semantic and auditory review, and final judgement stay human.
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
**East Van AI** · AI for the rest of us! · Vancouver, BC, Canada
|
|
302
|
+
|
|
303
|
+
[github.com/east-van-ai](https://github.com/east-van-ai) · <east-van-ai@proton.me>
|
|
304
|
+
|
|
305
|
+
Copyright (c) 2026 Go Nakamaru
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
hello_127/__init__.py,sha256=xa3Wg63lu49a6PPA3LyCyLS0inkXzIw0uJE96y995bs,86
|
|
2
|
+
hello_127/args.py,sha256=jvbXpna5u4SCd5HH5dfO02zl1Sn8n4lY1rU7O7plZQc,6823
|
|
3
|
+
hello_127/cli.py,sha256=Fb74YvSv5sxMO7q73jqDMOHpsGgKmux51hFxB582DRA,4685
|
|
4
|
+
hello_127/cli_query.py,sha256=eN5I956WYSJ8dsPJGOCU_XLapAVESZFXZ42b21mxrJc,1591
|
|
5
|
+
hello_127/cli_tunnel.py,sha256=jjrPlY8pj9f6daNcvsSqpzANXPxR61b5OmUKF8tSAxM,2188
|
|
6
|
+
hello_127/errors.py,sha256=PL0QuRDua2rHxGN5dR5nKScD7GRP3kZyiwLWWLslB7o,283
|
|
7
|
+
hello_127/host.py,sha256=6fkx8WadMmWfNIpyqKNmgRmMBW4HLLFsBaM20k5RiX4,2391
|
|
8
|
+
hello_127/launch.py,sha256=tSqDOG_MFWEGo3-0C1eV0KR7UG-kmR5y9cBxvz2Ek9w,845
|
|
9
|
+
hello_127/query.py,sha256=ow0idsh74KYm-lQx4JlPh0BQ2uHX3gFdeG8JH4jsOKs,2134
|
|
10
|
+
hello_127/wire.py,sha256=c1EAYEPTCr2kfkcLfUCq5uyndA_2JYi_1T7iWS37QEQ,6549
|
|
11
|
+
hello_127-0.1.1.dist-info/licenses/LICENSE,sha256=0Ncj7dClY9i2bXd_Bdl2h6o1HTAPKn07GS363J81zbc,1068
|
|
12
|
+
hello_127-0.1.1.dist-info/WHEEL,sha256=si2p8vW03RnhyBnhWl4tj-ObJa78SpE-c9HsEtblGR0,80
|
|
13
|
+
hello_127-0.1.1.dist-info/entry_points.txt,sha256=yLVIzRdUKzPccYIZubT40FdSTIwz1vCwFnhQ5WRhj8M,50
|
|
14
|
+
hello_127-0.1.1.dist-info/METADATA,sha256=U5ePdqnzkW_gfdXjdp19g9haaKahFDGBNCuP4tArReA,12585
|
|
15
|
+
hello_127-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Go Nakamaru
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|