phase: 02_tls_control_channel
**Verified / implemented (phase 02 final pass)** - All 6 tasks already in `complete/` → verification pass. Device measurement **found a real defect (README bug 11)**: the cert carried its SAN *value* under the OID written from the ASCII text `"2.5.29.17"` instead of DER content bytes `55 1D 11` (`openssl x509 -text` showed `1.10.46.53…`, hex `30 2f 06 09 32 2e 35…`), i.e. **no SAN** → hostname validation impossible while every host check stayed green (they walked the value, never the label). - Fixed in the pure module: `kSanOidTlv`/`sanOidDer()`, `sanExtensionFullDer()` (pinned to the 43 bytes OpenSSL writes for these names), `derCarriesSan()` (OID *and* value); `tls.cpp` uses them + 3 `static_assert`s; `cert` now warns and names the remedy. 33 new checks in `test_tls_pem.cpp`, incl. the pre-fix extension byte-for-byte (`derContains` yes / `derCarriesSan` no). - Corrected a false finding: the earlier "fingerprint rotated once" was an **OpenSSL `-ext` artifact** (`C8:76…` vs the real `61:54:19:30…`, same DER). Re-measured: serial `cert` == live handshake DER hash; page 51 626 B, `sha256 dd16e195…f04e9` == locally assembled page. **Tests / lint (100 % green)** - `bash .agents/validate.sh` → **GATE_EXIT=0**: `RESULT: all suites passed` in the plain *and* `SANITIZE=1` run, **4826 checks / 0 failures** each; `bridge`/`console`/`rollback` **SUCCESS**; `page bytes: 51626`; `README/AGENTS claims: OK`; `repo hygiene: OK (71 tracked files)`. - Footprint: bridge **RAM 30.4 % (99 616 B) / Flash 39.6 % (1 246 145 B)** vs baseline 26.7 %/87 592 & 36.1 %/1 135 293 → **+12 024 B RAM / +110 852 B flash**; TLS alone +6 160 B / +100 008 B (rollback image byte-identical); session heap 187 936 → ~145.5 k (1 session) → ~107.9 k (2). - `ruff check --select F,E9 tools/ tests/` → All checks passed; `bash -n` clean (gate, runner, probe); `-Wall -Wextra` warning-free. **Completion criteria**: 1 ✓ (gate green + RAM/Flash + session heap quoted); 2 ✓ as recorded (boot prints no fingerprint on the NVS-load path, `cert` prints on demand, identity survived reflashes, nothing about the key is printed anywhere — `nvs erase`→first-boot leg **not re-run**); 3 ✓ re-measured (51 626 == gate; `:80` → 301, no page bytes); 4 partial — 426/401/404 + pinning handshake measured, but arming/typing needs the operator's passphrase (`dflt:0`) and the target is this machine, so no input was sent; 5 ✓ (no `.pem/.key/.crt` tracked or on disk; hygiene block ran); 6 ✓ (`[env:rollback]` built every run + README section); 7 ✓. **Documentation**: README — check count 4793→4826 (intro + Quick start), `test_tls_pem` 829→862 with new pins, footprint/TLS-cost rows re-measured, "Ten bugs"→"Eleven bugs" + new entry + both anchors, `cert` row, Troubleshooting names-warning row, `tls_pem.h` architecture entry, nine/ten pure-module wording. AGENTS.md (staleness only): "Ten bugs"→"Eleven bugs" ×2, 4826, and the certificate-names row extended with the DER-OID rule. `.agents/HW_VERIFICATION.md` — 426 body/headers re-measured, fingerprint row correction + `-ext` rule, new SAN row (`failed → fixed in tree, device leg needs nvs erase`) with closing commands; phase report written to `.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.md`. **Deviations / live-infra**: made **no** reflash and **no** `nvs erase` (erase also destroys the token and Wi-Fi credentials; flashing puts a live HID keyboard into ROM download mode on this machine). Live steps were read-only `curl`/`openssl` plus one capped `console.py cert` (opening the CDC resets the board — permitted, recorded). No phase/PLAN files touched; everything left in the working tree for the harness to commit. **Next pending phase**: `03_session_takeover` — `01_pending_claim_socket.md`.
This commit is contained in:
+268
-27
@@ -1,24 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
"""relayctl -- talk to a keyrelay board from a terminal.
|
||||
|
||||
Stdlib only (socket, hashlib, base64, json): the board's protocol is a WebSocket of
|
||||
tiny text commands, and the point of a keyboard bridge is that it needs nothing
|
||||
installed to be useful.
|
||||
Stdlib only (socket, ssl, hashlib, base64, json): the board's protocol is a
|
||||
WebSocket of tiny text commands, and the point of a keyboard bridge is that it
|
||||
needs nothing installed to be useful.
|
||||
|
||||
relayctl.py --host keyrelay.local status
|
||||
relayctl.py --host 192.168.4.1 arm
|
||||
relayctl.py --host 10.2.1.99 key Enter # one key, press and release
|
||||
relayctl.py --host 10.2.1.99 mouse move 120 -40 # relative movement, split to fit
|
||||
relayctl.py --host 10.2.1.99 mouse click right # press and release
|
||||
relayctl.py --host 10.2.1.99 mouse drag 200 0 # press, move, release, in order
|
||||
relayctl.py --host 10.2.1.99 cad # Ctrl+Alt+Del, with --yes
|
||||
relayctl.py --host keyrelay.local listen # print every status heartbeat
|
||||
relayctl.py --host keyrelay.local disarm
|
||||
wl-paste | relayctl.py paste --yes # type what is on the clipboard
|
||||
A TLS board (the default) needs exactly one trust argument, and refuses to run
|
||||
without one -- this tool will not be the component that quietly downgrades:
|
||||
|
||||
--fingerprint <sha256> pin the board certificate (the `cert` command on the
|
||||
board's serial console prints it; also $KEYRELAY_TLS_FP).
|
||||
Verification is off during the handshake, then the peer's
|
||||
DER is hashed and a mismatch aborts before a single
|
||||
frame -- including T<token> -- is sent. Real pinning,
|
||||
not TOFU, and nothing to distribute.
|
||||
--ca <pem> verify against a CA (or a pinned self-signed cert file)
|
||||
with hostname checking on.
|
||||
--insecure no verification; prints a warning on every run.
|
||||
--plain plaintext ws:// on :80, for -D KR_TLS=0 rollback boards
|
||||
only. Opt-in, because the TLS board refuses that port
|
||||
anyway (301 / 426).
|
||||
|
||||
relayctl.py --fingerprint $FP --host keyrelay.local status
|
||||
relayctl.py --fingerprint $FP --host 192.168.4.1 arm
|
||||
relayctl.py --fingerprint $FP --host 10.2.1.99 key Enter # one key, press+release
|
||||
relayctl.py --fingerprint $FP --host 10.2.1.99 mouse move 120 -40 # split to fit
|
||||
relayctl.py --fingerprint $FP --host 10.2.1.99 mouse click right # press+release
|
||||
relayctl.py --fingerprint $FP --host 10.2.1.99 mouse drag 200 0 # in order
|
||||
relayctl.py --fingerprint $FP --host 10.2.1.99 cad # Ctrl+Alt+Del, --yes
|
||||
relayctl.py --fingerprint $FP --host keyrelay.local listen # status heartbeats
|
||||
relayctl.py --fingerprint $FP --host keyrelay.local disarm
|
||||
wl-paste | relayctl.py --fingerprint $FP paste --yes # type what is on the clipboard
|
||||
|
||||
The passphrase is taken from --token, then $KEYRELAY_TOKEN. It is never written to
|
||||
disk and never put in a URL (the /status HTTP endpoint takes it as a query parameter
|
||||
because curl is the point of that endpoint; this tool uses the WebSocket instead).
|
||||
Over TLS both the passphrase and the keystrokes are encrypted in transit; the
|
||||
fingerprint is what says the other end is *this* board rather than somebody's.
|
||||
|
||||
`paste` is the one command that types more than a single key, because sending text a
|
||||
human copied is a thing a keyboard bridge is for. It is gated accordingly: --yes is
|
||||
@@ -35,6 +53,15 @@ buttons and need --yes for that reason; a movement does not, because moving a cu
|
||||
reversible by definition. There is no gesture recording, no auto-click and no "move to the
|
||||
OK button", because the moment this tool could actuate a target on its own it would be a
|
||||
different and worse device than a keyboard.
|
||||
|
||||
What the TLS in this tool does NOT do is make anything safe by itself. An encrypted session is
|
||||
still only a controller that has to know the passphrase, and a session that drops is a lost link
|
||||
the board answers by releasing every key and every mouse button. The certificate says *which*
|
||||
board, and only if you asked -- which is why this tool's contribution is procedural: never pick
|
||||
the weaker option for the operator. No fallback from wss:// to ws://, no default-on --insecure,
|
||||
no "try :80 first", no quiet downgrade when a pin fails; every one of those is a decision the
|
||||
operator makes in writing, and the failure text names the real choice instead of blaming the
|
||||
passphrase for a transport problem.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -44,11 +71,13 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
DEFAULT_PORT = 80
|
||||
DEFAULT_PORT = 80 # plaintext ws:// -- reachable only via --plain
|
||||
TLS_DEFAULT_PORT = 443 # wss:// on a KR_TLS=1 board
|
||||
WS_PATH = "/ws"
|
||||
# The whole point of `keys` is verifying the keystroke path in one armed session.
|
||||
# A cap keeps it that: enough for a test vector, not a way to type a paragraph.
|
||||
@@ -240,6 +269,141 @@ def expect_accept(key: str) -> str:
|
||||
return base64.b64encode(digest).decode()
|
||||
|
||||
|
||||
def handshake_refusal(status_line: str) -> str:
|
||||
"""Why the board said no, phrased so the operator can act on it.
|
||||
|
||||
These shapes used to collapse into one "refused" and they are not the same problem:
|
||||
|
||||
426 the board refuses to speak plaintext at all. Its controller channel is
|
||||
`wss://` on :443, and a `ws://` upgrade on :80 is answered rather than quietly
|
||||
accepted -- invariant 5, never silently take the weaker option. The fix is a
|
||||
trust argument (--fingerprint from the board's `cert`), not a different port.
|
||||
301 the same refusal, for a request that was not an upgrade: the port redirects.
|
||||
401 the endpoint needs a passphrase this path cannot carry.
|
||||
503 another controller already holds the bridge.
|
||||
|
||||
Saying "wrong passphrase" when the board means "use TLS" is the failure this function
|
||||
exists to prevent: the operator then re-types a passphrase that was never the problem,
|
||||
five times, and locks the token out for kTokenLockMs on a board that was trying to
|
||||
protect them. tests/test_relayctl.py pins that 426 and 401 do not share a sentence.
|
||||
"""
|
||||
code = ""
|
||||
for part in status_line.split():
|
||||
if len(part) == 3 and part.isdigit():
|
||||
code = part
|
||||
break
|
||||
if code == "426":
|
||||
return ("board refuses plaintext: its controller channel is wss:// on :443. "
|
||||
"Re-run with a trust argument (--fingerprint <sha256> from the `cert` "
|
||||
"command on the board's serial console, or --ca); --plain only reaches "
|
||||
"a board built with -D KR_TLS=0")
|
||||
if code == "301":
|
||||
return ("board redirected to https://: it no longer serves content over plaintext "
|
||||
"http -- use the TLS defaults (--fingerprint or --ca)")
|
||||
if code == "401":
|
||||
return "passphrase required (this endpoint does not take one)"
|
||||
if code == "503":
|
||||
return "another controller already holds the bridge"
|
||||
if code == "400":
|
||||
return "malformed request -- not a keyrelay WebSocket endpoint"
|
||||
return "refused"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TLS: how this client decides to trust the board, or refuses to guess
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FINGERPRINT_HEX = 64 # one SHA-256, lowercase hex
|
||||
|
||||
|
||||
def normalize_fingerprint(text: str) -> str:
|
||||
"""Lowercase 64-hex of a SHA-256 fingerprint, whatever shape it was pasted in.
|
||||
|
||||
Accepts what the board's `cert` prints (bare hex) and what OpenSSL prints
|
||||
(`AA:BB:...`); refuses anything else rather than pinning a typo. A pin that
|
||||
silently means nothing is worse than no pin, because it reads like one.
|
||||
"""
|
||||
raw = "".join((text or "").split()).replace(":", "")
|
||||
if len(raw) != FINGERPRINT_HEX or any(c not in "0123456789abcdef" for c in raw.lower()):
|
||||
raise RelayError(
|
||||
f"{text!r} is not a SHA-256 fingerprint: want {FINGERPRINT_HEX} hex "
|
||||
f"digits (the `cert` command on the board's serial console prints it)")
|
||||
return raw.lower()
|
||||
|
||||
|
||||
def select_transport(fingerprint: str, ca: str, insecure: bool,
|
||||
plain: bool) -> str:
|
||||
"""One trust decision from the four switches: "pin" | "ca" | "insecure" | "plain".
|
||||
|
||||
Refuses, in two directions. None of them: refuse to connect at all -- a tool
|
||||
that defaults to plaintext, or to trust-on-first-use, is the component that
|
||||
quietly downgrades (AGENTS.md invariant 5), so the default posture is a
|
||||
refusal that says what to pass. More than one: they are different trust
|
||||
stories (a pin, a chain, no verification, no TLS), and picking one silently
|
||||
is guessing.
|
||||
"""
|
||||
if plain:
|
||||
if fingerprint or ca or insecure:
|
||||
raise RelayError("--plain is the -D KR_TLS=0 rollback path; do not combine "
|
||||
"it with a TLS trust argument (--fingerprint/--ca/--insecure)")
|
||||
return "plain"
|
||||
chosen = [name for name, on in (("--fingerprint", fingerprint), ("--ca", ca),
|
||||
("--insecure", insecure)) if on]
|
||||
if len(chosen) > 1:
|
||||
raise RelayError(f"{' and '.join(chosen)} are different trust decisions; pass "
|
||||
f"exactly one")
|
||||
if not chosen:
|
||||
raise RelayError(
|
||||
"refusing to connect without saying how to trust the board: pass "
|
||||
"--fingerprint <sha256> (or $KEYRELAY_TLS_FP; the `cert` command on the "
|
||||
"board's serial console prints it), or --ca <pem>, or --insecure for an "
|
||||
"unverified run this tool will warn about every time; --plain only for a "
|
||||
"board built with -D KR_TLS=0")
|
||||
if fingerprint:
|
||||
return "pin"
|
||||
return "ca" if ca else "insecure"
|
||||
|
||||
|
||||
def resolve_port(mode: str, port) -> int:
|
||||
"""Explicit --port always wins; otherwise TLS is 443 and --plain is 80.
|
||||
|
||||
One function because "which port did this flag combination pick" must not be
|
||||
folklore spread across call sites -- pinning a certificate on the plaintext
|
||||
port would be a very confident way to verify nothing.
|
||||
"""
|
||||
if port is not None:
|
||||
return int(port)
|
||||
return DEFAULT_PORT if mode == "plain" else TLS_DEFAULT_PORT
|
||||
|
||||
|
||||
def build_tls_context(mode: str, ca: str = "") -> ssl.SSLContext:
|
||||
"""The handshake policy for one TLS mode. Pinning and --insecure share the
|
||||
handshake settings deliberately: the pin does its work AFTER the handshake,
|
||||
on the DER the peer actually presented, so it works against a self-signed
|
||||
certificate with no CA ceremony -- and an empty verification path cannot be
|
||||
mistaken for a filled one. --ca is the opposite shape: OpenSSL verifies the
|
||||
chain AND the hostname, because that is the option for operators who ran
|
||||
their own CA on purpose.
|
||||
"""
|
||||
if mode == "ca":
|
||||
ctx = ssl.create_default_context(cafile=ca)
|
||||
ctx.check_hostname = True # the certificate is issued for keyrelay.local
|
||||
ctx.verify_mode = ssl.CERT_REQUIRED
|
||||
return ctx
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE # the pin hashes the peer cert itself; see above
|
||||
return ctx
|
||||
|
||||
|
||||
def peer_fingerprint(sock) -> str:
|
||||
"""SHA-256 of the DER the peer presented -- the same value `cert` prints."""
|
||||
der = sock.getpeercert(binary_form=True)
|
||||
if not der:
|
||||
raise RelayError("the TLS peer presented no certificate to fingerprint")
|
||||
return hashlib.sha256(der).hexdigest()
|
||||
|
||||
|
||||
def encode_frame(opcode: int, payload: bytes = b"") -> bytes:
|
||||
"""Client frames are always masked (RFC 6455 5.1: a server must reject others)."""
|
||||
out = bytearray([0x80 | opcode])
|
||||
@@ -300,12 +464,62 @@ class Receiver:
|
||||
|
||||
|
||||
class Relay:
|
||||
def __init__(self, host: str, port: int = DEFAULT_PORT, token: str = "",
|
||||
timeout: float = 5.0) -> None:
|
||||
def __init__(self, host: str, port=None, token: str = "", timeout: float = 5.0,
|
||||
fingerprint: str = "", ca: str = "", insecure: bool = False,
|
||||
plain: bool = False) -> None:
|
||||
self.host = host
|
||||
self.timeout = timeout
|
||||
self.token = token
|
||||
self.sock = socket.create_connection((host, port), timeout)
|
||||
|
||||
# The transport decision happens before a socket exists: "no trust
|
||||
# argument" must refuse in the terminal, not connect and find out.
|
||||
self.mode = select_transport(fingerprint, ca, insecure, plain)
|
||||
self.port = resolve_port(self.mode, port)
|
||||
self.pinned = ""
|
||||
if self.mode == "pin":
|
||||
self.pinned = normalize_fingerprint(fingerprint)
|
||||
if self.mode == "insecure":
|
||||
# On every run, not once per install: an opt-in that stops announcing
|
||||
# itself becomes a default.
|
||||
print("WARNING: --insecure -- not verifying the board's certificate. "
|
||||
"Anyone on this network can answer. Use --fingerprint (the `cert` "
|
||||
"command on the serial console prints it) unless you know exactly "
|
||||
"why you cannot.", file=sys.stderr)
|
||||
|
||||
self.sock = socket.create_connection((host, self.port), timeout)
|
||||
if self.mode != "plain":
|
||||
try:
|
||||
ctx = build_tls_context(self.mode, ca=ca)
|
||||
self.sock = ctx.wrap_socket(self.sock, server_hostname=host)
|
||||
except (ssl.SSLError, OSError) as exc:
|
||||
self.sock.close()
|
||||
if self.mode == "ca":
|
||||
raise RelayError(
|
||||
f"TLS to {host} failed verification: {exc} -- a certificate "
|
||||
f"verify failure usually means the board regenerated its "
|
||||
f"identity (`nvs erase`) or you are talking to a different "
|
||||
f"device; compare the fingerprint with `cert`") from exc
|
||||
raise RelayError(
|
||||
f"TLS handshake with {host} failed: {exc} -- if the board answered "
|
||||
f"the plaintext port with a redirect or a 426, you are on the wrong "
|
||||
f"port or the wrong device; a TLS keyrelay is on 443") from exc
|
||||
if self.mode == "pin":
|
||||
# Abort BEFORE a single frame -- above all before T<token>. A pin
|
||||
# checked after the WebSocket handshake is a pin on the passphrase.
|
||||
try:
|
||||
got = peer_fingerprint(self.sock)
|
||||
except RelayError:
|
||||
self.sock.close()
|
||||
raise
|
||||
if got != self.pinned:
|
||||
self.sock.close()
|
||||
raise RelayError(
|
||||
f"certificate fingerprint mismatch: {host} presented "
|
||||
f"{got}, you pinned {self.pinned}. Nothing was sent. Either "
|
||||
f"the board regenerated its identity (`nvs erase` reprints "
|
||||
f"the new one once) or somebody else is answering -- which "
|
||||
f"is exactly the attack this check exists for. Re-read the "
|
||||
f"fingerprint with `cert` on the board's serial console.")
|
||||
self.receiver = Receiver()
|
||||
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
@@ -338,12 +552,8 @@ class Relay:
|
||||
if sep:
|
||||
fields[name.strip().lower()] = value.strip()
|
||||
if " 101 " not in status_line + " ":
|
||||
detail = "refused"
|
||||
if "401" in status_line:
|
||||
detail = "passphrase required (this endpoint does not take one)"
|
||||
elif "503" in status_line:
|
||||
detail = "another controller already holds the bridge"
|
||||
raise RelayError(f"handshake failed: {status_line} ({detail})")
|
||||
raise RelayError(f"handshake failed: {status_line} "
|
||||
f"({handshake_refusal(status_line)})")
|
||||
want = expect_accept(key)
|
||||
got = fields.get("sec-websocket-accept", "")
|
||||
if got != want:
|
||||
@@ -625,16 +835,32 @@ def describe(status: dict) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
"""The CLI surface, apart from main() so tests can parse without running."""
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--host", default="keyrelay.local",
|
||||
help="board address (default keyrelay.local; on its own AP use "
|
||||
"192.168.4.1)")
|
||||
ap.add_argument("--port", type=int, default=DEFAULT_PORT)
|
||||
ap.add_argument("--port", type=int, default=None,
|
||||
help="override the port: default 443 over TLS, 80 with --plain")
|
||||
ap.add_argument("--token", default=os.environ.get("KEYRELAY_TOKEN", ""),
|
||||
help="control passphrase (or $KEYRELAY_TOKEN)")
|
||||
ap.add_argument("--timeout", type=float, default=5.0)
|
||||
ap.add_argument("--fingerprint", default=os.environ.get("KEYRELAY_TLS_FP", ""),
|
||||
metavar="SHA256",
|
||||
help="pin the board certificate: abort before sending anything "
|
||||
"unless the peer's DER hashes to this (or $KEYRELAY_TLS_FP); "
|
||||
"the serial `cert` command prints it")
|
||||
ap.add_argument("--ca", default="", metavar="PEM",
|
||||
help="verify the board certificate against this CA file (or "
|
||||
"pinned self-signed certificate), hostname checking on")
|
||||
ap.add_argument("--insecure", action="store_true",
|
||||
help="connect without any verification; warns on stderr on "
|
||||
"every run -- a one-off, not a habit")
|
||||
ap.add_argument("--plain", action="store_true",
|
||||
help="plaintext ws:// on port 80: only for boards built with "
|
||||
"-D KR_TLS=0 (the rollback); a TLS board refuses this")
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sub.add_parser("status", help="print what the board reports")
|
||||
@@ -701,8 +927,11 @@ def main() -> int:
|
||||
p_keys.add_argument("--keep-armed", action="store_true",
|
||||
help="stay armed afterwards (the board still disarms on "
|
||||
"silence 8 s after this process exits)")
|
||||
return ap
|
||||
|
||||
args = ap.parse_args()
|
||||
|
||||
def main() -> int:
|
||||
args = build_arg_parser().parse_args()
|
||||
if not args.token:
|
||||
print("no passphrase: --token or $KEYRELAY_TOKEN", file=sys.stderr)
|
||||
return 2
|
||||
@@ -752,8 +981,20 @@ def main() -> int:
|
||||
f"({MAX_PASTE_CHARS}); send it in pieces", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# Settled locally too: "you gave me no way to trust the board" is a usage
|
||||
# answer, not a connection failure -- and refusing here means no socket was
|
||||
# ever opened to find that out.
|
||||
try:
|
||||
relay = Relay(args.host, args.port, args.token, args.timeout)
|
||||
if select_transport(args.fingerprint, args.ca, args.insecure, args.plain) == "pin":
|
||||
normalize_fingerprint(args.fingerprint)
|
||||
except RelayError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
relay = Relay(args.host, args.port, args.token, args.timeout,
|
||||
fingerprint=args.fingerprint, ca=args.ca,
|
||||
insecure=args.insecure, plain=args.plain)
|
||||
except (OSError, RelayError) as exc:
|
||||
print(f"cannot use the board at {args.host}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
Reference in New Issue
Block a user