agent-lock-cli 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.
- agent_lock/__init__.py +3 -0
- agent_lock/__main__.py +5 -0
- agent_lock/allow.py +40 -0
- agent_lock/cli.py +181 -0
- agent_lock/completion.py +414 -0
- agent_lock/darwin.py +276 -0
- agent_lock/discover.py +542 -0
- agent_lock/doctor.py +1347 -0
- agent_lock/errors.py +22 -0
- agent_lock/install.py +1024 -0
- agent_lock/linux.py +336 -0
- agent_lock/state.py +275 -0
- agent_lock/windows.py +706 -0
- agent_lock/wrapper.py +259 -0
- agent_lock_cli-0.1.1.dist-info/METADATA +139 -0
- agent_lock_cli-0.1.1.dist-info/RECORD +20 -0
- agent_lock_cli-0.1.1.dist-info/WHEEL +5 -0
- agent_lock_cli-0.1.1.dist-info/entry_points.txt +2 -0
- agent_lock_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
- agent_lock_cli-0.1.1.dist-info/top_level.txt +1 -0
agent_lock/discover.py
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
"""Find the mihomo mixed port. The controller secret is never placed in an error or a log line."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import socket
|
|
7
|
+
import sys
|
|
8
|
+
import urllib.parse
|
|
9
|
+
|
|
10
|
+
from .errors import AgentLockError, UsageError
|
|
11
|
+
from .state import proxy_url, secret_label
|
|
12
|
+
|
|
13
|
+
TIMEOUT = 3
|
|
14
|
+
MAX_BODY = 64 * 1024
|
|
15
|
+
PIPE_PREFIX = "\\\\.\\pipe\\"
|
|
16
|
+
VERGE_DIR = "io.github.clash-verge-rev.clash-verge-rev"
|
|
17
|
+
_STRING_FLAGS = ("d", "f", "ext-ctl", "ext-ctl-unix", "ext-ctl-pipe", "secret")
|
|
18
|
+
_CONTROLLER_FLAGS = ("ext-ctl", "ext-ctl-unix", "ext-ctl-pipe")
|
|
19
|
+
_CONFIG_KEY = re.compile(r"^(external-controller(?:-unix|-pipe)?|secret)\s*:(.*)$")
|
|
20
|
+
_LOOPBACK_BIND = ("127.0.0.1", "::1", "localhost")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DiscoverFailure(AgentLockError):
|
|
24
|
+
def __init__(self, message, secret=None, code=1):
|
|
25
|
+
if secret:
|
|
26
|
+
message = message.replace(secret, secret_label(secret))
|
|
27
|
+
super(DiscoverFailure, self).__init__(message, code)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_secret_stdin(readline):
|
|
31
|
+
"""One line, trailing newline removed. Empty input is exit 2."""
|
|
32
|
+
line = readline()
|
|
33
|
+
if line is None:
|
|
34
|
+
raise UsageError("empty secret on stdin")
|
|
35
|
+
if line.endswith("\n"):
|
|
36
|
+
line = line[:-1]
|
|
37
|
+
if line.endswith("\r"):
|
|
38
|
+
line = line[:-1]
|
|
39
|
+
if line == "":
|
|
40
|
+
raise UsageError("empty secret on stdin")
|
|
41
|
+
if "\n" in line or "\r" in line:
|
|
42
|
+
raise UsageError("secret must be a single line")
|
|
43
|
+
return line
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def effective_port(data):
|
|
47
|
+
"""mixed-port if it is a positive int, else port. SOCKS-only is an error."""
|
|
48
|
+
if not isinstance(data, dict):
|
|
49
|
+
raise DiscoverFailure("controller JSON was not an object")
|
|
50
|
+
mixed = _positive_int(data.get("mixed-port"))
|
|
51
|
+
if mixed:
|
|
52
|
+
return mixed
|
|
53
|
+
http_port = _positive_int(data.get("port"))
|
|
54
|
+
if http_port:
|
|
55
|
+
return http_port
|
|
56
|
+
if _positive_int(data.get("socks-port")):
|
|
57
|
+
raise DiscoverFailure("only a SOCKS port is open; Claude Code does not support SOCKS")
|
|
58
|
+
raise DiscoverFailure("controller JSON has no mixed-port or port")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _positive_int(value):
|
|
62
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
63
|
+
return 0
|
|
64
|
+
if value > 0:
|
|
65
|
+
return value
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def check_lan_and_bind(data):
|
|
70
|
+
if data.get("allow-lan") is True:
|
|
71
|
+
raise DiscoverFailure("allow-lan is true; turn it off before installing")
|
|
72
|
+
bind = data.get("bind-address")
|
|
73
|
+
if bind is not None and str(bind) not in _LOOPBACK_BIND:
|
|
74
|
+
raise DiscoverFailure("bind-address is not loopback ({})".format(bind))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def interpret_response(status, body, secret):
|
|
78
|
+
"""Return parsed JSON or raise. The body is never copied into the error."""
|
|
79
|
+
if status == 401:
|
|
80
|
+
raise DiscoverFailure("needs a secret", secret=secret)
|
|
81
|
+
if status is None:
|
|
82
|
+
raise DiscoverFailure("controller unreachable", secret=secret)
|
|
83
|
+
if status != 200:
|
|
84
|
+
raise DiscoverFailure("controller returned HTTP {}".format(status), secret=secret)
|
|
85
|
+
try:
|
|
86
|
+
if isinstance(body, bytes):
|
|
87
|
+
text = body.decode("utf-8", "replace")
|
|
88
|
+
else:
|
|
89
|
+
text = body or ""
|
|
90
|
+
data = json.loads(text)
|
|
91
|
+
except ValueError:
|
|
92
|
+
raise DiscoverFailure("controller did not return JSON", secret=secret)
|
|
93
|
+
return data
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _is_fatal(text):
|
|
97
|
+
return text.startswith((
|
|
98
|
+
"allow-lan",
|
|
99
|
+
"bind-address",
|
|
100
|
+
"only a SOCKS",
|
|
101
|
+
"refusing the external-controller",
|
|
102
|
+
))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def log_line(address, source, detail):
|
|
106
|
+
"""A controller log line: address, source, and mixed-port. No secret."""
|
|
107
|
+
return "{} ({}) {}".format(address, source, detail)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def parse_flags(args):
|
|
111
|
+
"""Four flag spellings: -name value, -name=value, --name value, --name=value."""
|
|
112
|
+
flags = {}
|
|
113
|
+
index = 0
|
|
114
|
+
while index < len(args):
|
|
115
|
+
token = args[index]
|
|
116
|
+
index += 1
|
|
117
|
+
if not token.startswith("-") or token in ("-", "--"):
|
|
118
|
+
continue
|
|
119
|
+
name = token[2:] if token.startswith("--") else token[1:]
|
|
120
|
+
if "=" in name:
|
|
121
|
+
name, value = name.split("=", 1)
|
|
122
|
+
if name in _STRING_FLAGS:
|
|
123
|
+
flags[name] = value
|
|
124
|
+
elif name in _STRING_FLAGS and index < len(args):
|
|
125
|
+
flags[name] = args[index]
|
|
126
|
+
index += 1
|
|
127
|
+
return flags
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _is_loopback(host):
|
|
131
|
+
if not host:
|
|
132
|
+
return False
|
|
133
|
+
if host.lower() == "localhost":
|
|
134
|
+
return True
|
|
135
|
+
try:
|
|
136
|
+
import ipaddress
|
|
137
|
+
return ipaddress.ip_address(host).is_loopback
|
|
138
|
+
except ValueError:
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def normalize_tcp(value):
|
|
143
|
+
"""Return (display, host, port) or None when the address is not local."""
|
|
144
|
+
try:
|
|
145
|
+
parts = urllib.parse.urlsplit("//" + value)
|
|
146
|
+
port = parts.port
|
|
147
|
+
host = parts.hostname or ""
|
|
148
|
+
except ValueError:
|
|
149
|
+
return None
|
|
150
|
+
if port is None:
|
|
151
|
+
return None
|
|
152
|
+
if host in ("", "0.0.0.0"):
|
|
153
|
+
host = "127.0.0.1"
|
|
154
|
+
elif host == "::":
|
|
155
|
+
host = "::1"
|
|
156
|
+
if not _is_loopback(host):
|
|
157
|
+
return None
|
|
158
|
+
if ":" in host:
|
|
159
|
+
display = "[{}]:{}".format(host, port)
|
|
160
|
+
else:
|
|
161
|
+
display = "{}:{}".format(host, port)
|
|
162
|
+
return display, host, port
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Candidate(object):
|
|
166
|
+
def __init__(self, address, secret, source, kind):
|
|
167
|
+
self.address = address
|
|
168
|
+
self.secret = secret or ""
|
|
169
|
+
self.source = source
|
|
170
|
+
self.kind = kind # tcp, unix, pipe
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def config_owned_by_installer(path, owner_home, owner_uid):
|
|
174
|
+
"""True when the installing user owns the config. stat follows links.
|
|
175
|
+
|
|
176
|
+
A user-owned symlink to a root-owned file is rejected. Windows has no
|
|
177
|
+
trustworthy uid here, so the real path has to stay under the owner home.
|
|
178
|
+
"""
|
|
179
|
+
if not path or not os.path.isabs(path):
|
|
180
|
+
return False
|
|
181
|
+
if sys.platform == "win32":
|
|
182
|
+
if not owner_home:
|
|
183
|
+
return False
|
|
184
|
+
try:
|
|
185
|
+
real = os.path.realpath(path)
|
|
186
|
+
home = os.path.realpath(owner_home)
|
|
187
|
+
return os.path.commonpath([real, home]) == home
|
|
188
|
+
except (OSError, ValueError):
|
|
189
|
+
return False
|
|
190
|
+
if owner_uid is None:
|
|
191
|
+
return False
|
|
192
|
+
try:
|
|
193
|
+
st = os.stat(path)
|
|
194
|
+
except OSError:
|
|
195
|
+
return False
|
|
196
|
+
return int(st.st_uid) == int(owner_uid)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def collect_candidates(argv_list, read_text, owner_home, mihomo_home, verge_paths, owner_uid=None):
|
|
200
|
+
"""Ordered candidates. TCP comes only from a config the installer owns.
|
|
201
|
+
|
|
202
|
+
Process -ext-ctl addresses are not probed. Unix and pipe flags carry no secret.
|
|
203
|
+
"""
|
|
204
|
+
found = []
|
|
205
|
+
seen = set()
|
|
206
|
+
|
|
207
|
+
def add(candidate):
|
|
208
|
+
key = (candidate.kind, candidate.address)
|
|
209
|
+
if key in seen:
|
|
210
|
+
return
|
|
211
|
+
seen.add(key)
|
|
212
|
+
found.append(candidate)
|
|
213
|
+
|
|
214
|
+
for argv in argv_list or []:
|
|
215
|
+
if not argv:
|
|
216
|
+
continue
|
|
217
|
+
flags = parse_flags(argv[1:])
|
|
218
|
+
program = os.path.basename(str(argv[0]).replace("\\", "/")).lower()
|
|
219
|
+
if "mihomo" not in program and "clash" not in program and not any(
|
|
220
|
+
name in flags for name in _CONTROLLER_FLAGS):
|
|
221
|
+
continue
|
|
222
|
+
source = "process " + (os.path.basename(str(argv[0]).replace("\\", "/")) or str(argv[0]))
|
|
223
|
+
home = flags.get("d")
|
|
224
|
+
if home is not None and not os.path.isabs(home):
|
|
225
|
+
home = None
|
|
226
|
+
config_path = None
|
|
227
|
+
if "f" in flags and os.path.isabs(flags["f"]):
|
|
228
|
+
config_path = flags["f"]
|
|
229
|
+
elif home is not None:
|
|
230
|
+
config_path = os.path.join(home, "config.yaml")
|
|
231
|
+
if config_path and config_owned_by_installer(config_path, owner_home, owner_uid):
|
|
232
|
+
_collect_config(add, config_path, home, None, source + " config", read_text)
|
|
233
|
+
if "ext-ctl-unix" in flags:
|
|
234
|
+
_add_unix(add, flags["ext-ctl-unix"], home, source)
|
|
235
|
+
if "ext-ctl-pipe" in flags:
|
|
236
|
+
add(Candidate(flags["ext-ctl-pipe"], "", source, "pipe"))
|
|
237
|
+
for path in verge_paths or []:
|
|
238
|
+
if config_owned_by_installer(path, owner_home, owner_uid):
|
|
239
|
+
_collect_config(add, path, os.path.dirname(path), None, "config " + path, read_text)
|
|
240
|
+
if mihomo_home:
|
|
241
|
+
sock = os.path.join(mihomo_home, "mihomo.sock")
|
|
242
|
+
add(Candidate("unix:" + sock, "", "mihomo-cli socket", "unix"))
|
|
243
|
+
return found
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _tcp_address(host, port):
|
|
247
|
+
if ":" in host:
|
|
248
|
+
return "http://[{}]:{}".format(host, port)
|
|
249
|
+
return "http://{}:{}".format(host, port)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _add_unix(add, path, home, source):
|
|
253
|
+
if not os.path.isabs(path):
|
|
254
|
+
if not home:
|
|
255
|
+
return
|
|
256
|
+
path = os.path.join(home, path)
|
|
257
|
+
add(Candidate("unix:" + path, "", source, "unix"))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _collect_config(add, path, home, secret_override, source, read_text):
|
|
261
|
+
text = read_text(path)
|
|
262
|
+
values = _read_config_text(text)
|
|
263
|
+
if values is None:
|
|
264
|
+
return ""
|
|
265
|
+
file_secret = values.get("secret", "")
|
|
266
|
+
if values.get("external-controller-unix"):
|
|
267
|
+
_add_unix(add, values["external-controller-unix"], home, source)
|
|
268
|
+
if values.get("external-controller-pipe"):
|
|
269
|
+
add(Candidate(values["external-controller-pipe"], "", source, "pipe"))
|
|
270
|
+
if values.get("external-controller"):
|
|
271
|
+
parsed = normalize_tcp(values["external-controller"])
|
|
272
|
+
if parsed:
|
|
273
|
+
_display, host, port = parsed
|
|
274
|
+
secret = secret_override if secret_override is not None else file_secret
|
|
275
|
+
add(Candidate(_tcp_address(host, port), secret, source, "tcp"))
|
|
276
|
+
return file_secret
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _read_config_text(text):
|
|
280
|
+
if text is None:
|
|
281
|
+
return None
|
|
282
|
+
values = {}
|
|
283
|
+
for line in text.splitlines():
|
|
284
|
+
match = _CONFIG_KEY.match(line)
|
|
285
|
+
if match and match.group(1) not in values:
|
|
286
|
+
values[match.group(1)] = _yaml_scalar(match.group(2))
|
|
287
|
+
return values
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _yaml_scalar(raw):
|
|
291
|
+
text = raw.strip()
|
|
292
|
+
if text[:1] == '"':
|
|
293
|
+
out = []
|
|
294
|
+
index = 1
|
|
295
|
+
while index < len(text) and text[index] != '"':
|
|
296
|
+
if text[index] == "\\" and index + 1 < len(text):
|
|
297
|
+
index += 1
|
|
298
|
+
out.append(text[index])
|
|
299
|
+
index += 1
|
|
300
|
+
return "".join(out)
|
|
301
|
+
if text[:1] == "'":
|
|
302
|
+
out = []
|
|
303
|
+
index = 1
|
|
304
|
+
while index < len(text):
|
|
305
|
+
if text[index] == "'":
|
|
306
|
+
if text[index + 1:index + 2] != "'":
|
|
307
|
+
break
|
|
308
|
+
index += 1
|
|
309
|
+
out.append(text[index])
|
|
310
|
+
index += 1
|
|
311
|
+
return "".join(out)
|
|
312
|
+
if text.startswith("#"):
|
|
313
|
+
return ""
|
|
314
|
+
return text.split(" #", 1)[0].strip()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def verge_paths(owner_home, appdata):
|
|
318
|
+
"""macOS and Windows Verge paths only. Linux is not guessed. Root skips /root."""
|
|
319
|
+
if sys.platform == "darwin" and owner_home:
|
|
320
|
+
return [os.path.join(owner_home, "Library", "Application Support", VERGE_DIR, "config.yaml")]
|
|
321
|
+
if sys.platform == "win32" and appdata:
|
|
322
|
+
return [os.path.join(appdata, VERGE_DIR, "config.yaml")]
|
|
323
|
+
return []
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def mihomo_home(owner_home, environ):
|
|
327
|
+
"""MIHOMO_CLI_HOME only when already in the environment. Otherwise the owner home."""
|
|
328
|
+
explicit = (environ or {}).get("MIHOMO_CLI_HOME")
|
|
329
|
+
if explicit:
|
|
330
|
+
return explicit
|
|
331
|
+
if not owner_home:
|
|
332
|
+
return None
|
|
333
|
+
return os.path.join(owner_home, ".local", "share", "mihomo-cli")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def controller_port(address):
|
|
337
|
+
"""TCP port of an http controller, or None for unix and pipes."""
|
|
338
|
+
if not address or not address.startswith("http://"):
|
|
339
|
+
return None
|
|
340
|
+
try:
|
|
341
|
+
parts = urllib.parse.urlsplit(address)
|
|
342
|
+
return parts.port
|
|
343
|
+
except ValueError:
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def select_port(data, controller_address):
|
|
348
|
+
port = effective_port(data)
|
|
349
|
+
check_lan_and_bind(data)
|
|
350
|
+
managed = controller_port(controller_address)
|
|
351
|
+
if managed is not None and port == managed:
|
|
352
|
+
raise DiscoverFailure("refusing the external-controller port as the proxy")
|
|
353
|
+
return port
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def probe_candidate(candidate, fetch):
|
|
357
|
+
"""fetch(address, secret) -> (status, body). Errors do not include the secret.
|
|
358
|
+
|
|
359
|
+
A broken controller may echo the secret inside parsed JSON. Do not log `data`.
|
|
360
|
+
"""
|
|
361
|
+
secret = candidate.secret if candidate.kind == "tcp" else ""
|
|
362
|
+
try:
|
|
363
|
+
status, body = fetch(candidate.address, secret)
|
|
364
|
+
except DiscoverFailure:
|
|
365
|
+
raise
|
|
366
|
+
except Exception as exc:
|
|
367
|
+
message = "controller unreachable"
|
|
368
|
+
# Do not attach the exception text if it swallowed the secret.
|
|
369
|
+
if secret and secret in str(exc):
|
|
370
|
+
raise DiscoverFailure(message, secret=secret)
|
|
371
|
+
detail = str(exc)
|
|
372
|
+
if secret and secret in detail:
|
|
373
|
+
detail = detail.replace(secret, secret_label(secret))
|
|
374
|
+
raise DiscoverFailure("{}: {}".format(message, detail), secret=secret)
|
|
375
|
+
try:
|
|
376
|
+
data = interpret_response(status, body, secret)
|
|
377
|
+
except DiscoverFailure as exc:
|
|
378
|
+
text = str(exc)
|
|
379
|
+
if text == "needs a secret":
|
|
380
|
+
return None, log_line(candidate.address, candidate.source, "needs a secret")
|
|
381
|
+
if _is_fatal(text):
|
|
382
|
+
raise
|
|
383
|
+
return None, log_line(candidate.address, candidate.source, text)
|
|
384
|
+
try:
|
|
385
|
+
port = select_port(data, candidate.address)
|
|
386
|
+
except DiscoverFailure as exc:
|
|
387
|
+
text = str(exc)
|
|
388
|
+
if _is_fatal(text):
|
|
389
|
+
raise
|
|
390
|
+
return None, log_line(candidate.address, candidate.source, text)
|
|
391
|
+
# Unix and pipe candidates do not keep a file secret.
|
|
392
|
+
kept_secret = secret if candidate.kind == "tcp" else ""
|
|
393
|
+
return {
|
|
394
|
+
"port": port,
|
|
395
|
+
"proxy": proxy_url(port),
|
|
396
|
+
"controller": candidate.address,
|
|
397
|
+
"secret": kept_secret,
|
|
398
|
+
"source": candidate.source,
|
|
399
|
+
}, log_line(candidate.address, candidate.source, "mixed-port={}".format(port))
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def controller_candidate(text, secret):
|
|
403
|
+
"""One explicit --controller value. Loopback TCP, or a unix: path."""
|
|
404
|
+
raw = (text or "").strip()
|
|
405
|
+
if not raw:
|
|
406
|
+
raise UsageError("--controller needs a value")
|
|
407
|
+
if raw.startswith("unix:"):
|
|
408
|
+
return Candidate(raw, "", "--controller", "unix")
|
|
409
|
+
if "://" not in raw:
|
|
410
|
+
raw = "http://" + raw
|
|
411
|
+
parts = urllib.parse.urlsplit(raw)
|
|
412
|
+
host = parts.hostname
|
|
413
|
+
port = parts.port
|
|
414
|
+
if host is None or port is None:
|
|
415
|
+
raise UsageError("controller must be host:port or unix:PATH")
|
|
416
|
+
if not _is_loopback(host):
|
|
417
|
+
raise UsageError("controller must be loopback")
|
|
418
|
+
return Candidate(raw, secret or "", "--controller", "tcp")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _port_only(port, note):
|
|
422
|
+
return {
|
|
423
|
+
"port": int(port),
|
|
424
|
+
"proxy": proxy_url(port),
|
|
425
|
+
"controller": "",
|
|
426
|
+
"secret": "",
|
|
427
|
+
"source": "--port",
|
|
428
|
+
"logs": [note],
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def choose_port(forced_port, explicit_controller, candidates, fetch):
|
|
433
|
+
"""Pick the mixed port.
|
|
434
|
+
|
|
435
|
+
An explicit --controller must succeed. Without one, --port is the fallback
|
|
436
|
+
when nothing answers. A successful read that disagrees with --port does not
|
|
437
|
+
install. allow-lan, a bad bind, SOCKS-only, and the controller port stay fatal.
|
|
438
|
+
"""
|
|
439
|
+
if explicit_controller and not candidates:
|
|
440
|
+
raise DiscoverFailure("controller was not readable")
|
|
441
|
+
if not candidates:
|
|
442
|
+
if forced_port is None:
|
|
443
|
+
raise DiscoverFailure(
|
|
444
|
+
"could not read /configs; pass --port, or --controller and --secret-stdin")
|
|
445
|
+
return _port_only(forced_port, "warning: using --port; controller was not found")
|
|
446
|
+
try:
|
|
447
|
+
return discover(candidates, fetch, forced_port=forced_port)
|
|
448
|
+
except DiscoverFailure as exc:
|
|
449
|
+
text = str(exc)
|
|
450
|
+
fallback = (
|
|
451
|
+
forced_port is not None
|
|
452
|
+
and not explicit_controller
|
|
453
|
+
and not _is_fatal(text)
|
|
454
|
+
and "does not match" not in text
|
|
455
|
+
)
|
|
456
|
+
if not fallback:
|
|
457
|
+
raise
|
|
458
|
+
return _port_only(forced_port, "warning: " + text)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def discover(candidates, fetch, forced_port=None):
|
|
462
|
+
"""Try candidates in order. The first JSON with a usable port wins.
|
|
463
|
+
|
|
464
|
+
Logs are a list of strings safe to print. `forced_port` cross-checks.
|
|
465
|
+
"""
|
|
466
|
+
logs = []
|
|
467
|
+
winner = None
|
|
468
|
+
for candidate in candidates:
|
|
469
|
+
found, line = probe_candidate(candidate, fetch)
|
|
470
|
+
logs.append(line)
|
|
471
|
+
if found is not None and winner is None:
|
|
472
|
+
winner = found
|
|
473
|
+
break
|
|
474
|
+
if winner is None:
|
|
475
|
+
raise DiscoverFailure(
|
|
476
|
+
"could not read /configs; pass --port, or --controller and --secret-stdin")
|
|
477
|
+
if forced_port is not None and int(forced_port) != int(winner["port"]):
|
|
478
|
+
raise DiscoverFailure(
|
|
479
|
+
"--port {} does not match controller mixed-port {}".format(forced_port, winner["port"]))
|
|
480
|
+
winner["logs"] = logs
|
|
481
|
+
return winner
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def http_get(address, secret, timeout=TIMEOUT, max_body=MAX_BODY):
|
|
485
|
+
"""GET /configs. Returns (status, body). Raises DiscoverFailure without the secret."""
|
|
486
|
+
try:
|
|
487
|
+
if address.startswith("unix:"):
|
|
488
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
489
|
+
sock.settimeout(timeout)
|
|
490
|
+
sock.connect(address[len("unix:"):])
|
|
491
|
+
host_header = "localhost"
|
|
492
|
+
elif address.lower().startswith(PIPE_PREFIX.lower()) or address.startswith("\\\\.\\pipe\\"):
|
|
493
|
+
raise DiscoverFailure("named pipe controllers are not readable from this process", secret=secret)
|
|
494
|
+
else:
|
|
495
|
+
parts = urllib.parse.urlsplit(address)
|
|
496
|
+
host = parts.hostname
|
|
497
|
+
port = parts.port
|
|
498
|
+
if host is None or port is None:
|
|
499
|
+
raise DiscoverFailure("controller address has no host or port", secret=secret)
|
|
500
|
+
sock = socket.create_connection((host, port), timeout)
|
|
501
|
+
host_header = host
|
|
502
|
+
try:
|
|
503
|
+
header = "GET /configs HTTP/1.0\r\nHost: {}\r\nConnection: close\r\n".format(host_header)
|
|
504
|
+
if secret:
|
|
505
|
+
header += "Authorization: Bearer {}\r\n".format(secret)
|
|
506
|
+
header += "\r\n"
|
|
507
|
+
sock.sendall(header.encode("ascii", "strict"))
|
|
508
|
+
chunks = []
|
|
509
|
+
remaining = max_body + 8192
|
|
510
|
+
while remaining > 0:
|
|
511
|
+
block = sock.recv(min(4096, remaining))
|
|
512
|
+
if not block:
|
|
513
|
+
break
|
|
514
|
+
chunks.append(block)
|
|
515
|
+
remaining -= len(block)
|
|
516
|
+
finally:
|
|
517
|
+
sock.close()
|
|
518
|
+
except DiscoverFailure:
|
|
519
|
+
raise
|
|
520
|
+
except Exception as exc:
|
|
521
|
+
text = str(exc)
|
|
522
|
+
if secret and secret in text:
|
|
523
|
+
text = text.replace(secret, secret_label(secret))
|
|
524
|
+
raise DiscoverFailure("controller unreachable: {}".format(text), secret=secret)
|
|
525
|
+
raw = b"".join(chunks)
|
|
526
|
+
if len(raw) > max_body + 8192:
|
|
527
|
+
raw = raw[:max_body + 8192]
|
|
528
|
+
header_blob, _sep, body = raw.partition(b"\r\n\r\n")
|
|
529
|
+
if not header_blob.startswith(b"HTTP/"):
|
|
530
|
+
raise DiscoverFailure("controller did not return HTTP", secret=secret)
|
|
531
|
+
first = header_blob.split(b"\r\n", 1)[0].decode("ascii", "replace")
|
|
532
|
+
parts = first.split()
|
|
533
|
+
if len(parts) < 2 or not parts[1].isdigit():
|
|
534
|
+
raise DiscoverFailure("controller returned an unreadable status", secret=secret)
|
|
535
|
+
status = int(parts[1])
|
|
536
|
+
if len(body) > max_body:
|
|
537
|
+
body = body[:max_body]
|
|
538
|
+
return status, body
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def default_fetch(address, secret):
|
|
542
|
+
return http_get(address, secret)
|