Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
865 lines
36 KiB
Python
865 lines
36 KiB
Python
#!/usr/bin/env python3
|
|
"""Hayes AT modem emulator with a socket DTE side.
|
|
|
|
The emulated machine's serial port connects here (unix socket or TCP). Dialling
|
|
either opens a TCP connection (BBS) or hands the raw line to pppd -- i.e. an ISP
|
|
terminal server, which is what a period PC actually dialled.
|
|
|
|
It also answers. --line gives the modem a phone number: an inbound TCP connection
|
|
rings the DTE, and the guest picks up with ATA (or automatically, once it has set
|
|
S0). That is the half NT4's Remote Access Server needs to *receive* calls, and it
|
|
is what lets one retro guest dial another.
|
|
|
|
Why not tcpser: its only socket DTE transport is ip232, which doubles 0xFF and
|
|
steals FF 00 / FF 01 to carry DTR. Every PPP frame starts FF 03, so ip232 eats
|
|
the link. This is 8-bit clean, and speaking unix sockets natively removes the
|
|
socat + pty sandwich (PVE's `-serial0 socket` plugs straight in).
|
|
|
|
One process is one modem on one line, because that is what a modem is. An ISP's
|
|
T1 into a rack of them is N processes on N ports; a single number in front of the
|
|
rack is a hunt group, which is a dispatcher this does not have.
|
|
|
|
No modem control lines: a QEMU socket chardev carries none, so DCD reads as
|
|
permanently asserted in the guest and the NO CARRIER result code is the only
|
|
carrier signal available. Guests set &C1 (DCD follows carrier) and &D2 (drop DTR
|
|
to hang up) anyway; neither can be honoured. Terminal software parses the result
|
|
code and copes. PPP cannot see the drop and falls back to LCP echo timeouts.
|
|
|
|
qm set 102 -serial0 socket # QEMU LISTENS on the socket, so we connect to it
|
|
atmodem.py --connect /var/run/qemu-server/102.serial0 --phonebook pb.txt \
|
|
--line 6102 --pppd 'pppd notty 10.62.0.1:10.62.0.2 require-pap lock'
|
|
"""
|
|
|
|
import argparse
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import re
|
|
import shlex
|
|
import socket
|
|
import tempfile
|
|
import time
|
|
|
|
# Hayes guard time: the silence required either side of +++ so that binary
|
|
# payloads containing +++ do not drop the link.
|
|
GUARD = 1.0
|
|
RING = 4.0 # seconds between RING results, as on a real line
|
|
RINGS = 8 # then the caller has given up
|
|
|
|
log = logging.getLogger("atmodem")
|
|
|
|
CODES = {"OK": 0, "CONNECT": 1, "RING": 2, "NO CARRIER": 3, "ERROR": 4, "BUSY": 7}
|
|
|
|
|
|
def digits(s):
|
|
"""Dialers emit ATDT9,1-555-1212 and friends; only the digits identify it."""
|
|
return "".join(c for c in s if c.isdigit())
|
|
|
|
|
|
def load_phonebook(path):
|
|
"""86Box's own format on purpose -- <number><whitespace><target>, one per
|
|
line -- so a single file serves both emulators. Target is host:port, or the
|
|
literal 'ppp' to hand the call to pppd."""
|
|
book = {}
|
|
if not path:
|
|
return book
|
|
with open(path) as f:
|
|
for line in f:
|
|
parts = line.split("#", 1)[0].split()
|
|
if len(parts) >= 2:
|
|
book[digits(parts[0])] = parts[1]
|
|
return book
|
|
|
|
|
|
class Phonebook:
|
|
"""A phonebook that re-reads itself when the file changes.
|
|
|
|
Drop-in for the dict it replaces (both answer .get). Without this, editing a
|
|
number means restarting the modem, which drops whatever call is up -- the
|
|
single most annoying thing about operating this, and three lines to fix.
|
|
"""
|
|
|
|
def __init__(self, path):
|
|
self.path, self.mtime, self.book = path, None, {}
|
|
|
|
def get(self, number):
|
|
try:
|
|
mtime = os.stat(self.path).st_mtime if self.path else None
|
|
except OSError:
|
|
mtime = None # deleted mid-flight: keep serving the last good copy
|
|
if mtime is not None and mtime != self.mtime:
|
|
self.mtime, self.book = mtime, load_phonebook(self.path)
|
|
log.info("phonebook reloaded: %d numbers", len(self.book))
|
|
return self.book.get(number)
|
|
|
|
|
|
def parse(body):
|
|
"""Split an AT command line into (cmd, arg) pairs.
|
|
|
|
The one subtlety worth code: extended commands are &X / %X / \\X, so a naive
|
|
search for 'D' fires on the &D2 in every dialer's init string and tries to
|
|
dial "2". Consume the prefix first. D itself swallows the rest of the line,
|
|
per Hayes.
|
|
"""
|
|
out, i = [], 0
|
|
while i < len(body):
|
|
c = body[i]
|
|
if c in " \t":
|
|
i += 1
|
|
continue
|
|
prefix = ""
|
|
if c in "&%\\":
|
|
prefix, i = c, i + 1
|
|
if i >= len(body):
|
|
break
|
|
c = body[i]
|
|
c, i = c.upper(), i + 1
|
|
if not prefix and c == "D":
|
|
out.append(("D", body[i:].strip()))
|
|
break
|
|
if not prefix and c == "S":
|
|
# Sn=v is the one command whose register number matters (S0 is
|
|
# auto-answer), so keep them apart: ("S0", "1"), not ("S", "1").
|
|
m = re.match(r"(\d+)\s*=\s*(\d+)", body[i:])
|
|
if m:
|
|
i += m.end()
|
|
out.append(("S" + m.group(1), m.group(2)))
|
|
continue
|
|
m = re.match(r"(\d*)\s*(?:=\s*(\d+))?", body[i:])
|
|
i += m.end()
|
|
out.append((prefix + c, m.group(2) if m.group(2) is not None else m.group(1)))
|
|
return out
|
|
|
|
|
|
class Telnet:
|
|
"""A minimal telnet client, wrapping a peer stream with read/write/drain.
|
|
|
|
A real telnetd opens with `IAC WILL ECHO, IAC WILL SGA`; raw-piped, those
|
|
six bytes land on the guest's screen as garbage before the login prompt, and
|
|
an un-doubled 0xFF corrupts any 8-bit transfer. Opt in per phonebook entry
|
|
('telnet:host[:port]') -- it MUST stay off for PPP and guest-to-guest links,
|
|
where 0xFF is data, which is the same toggle 86Box gets wrong by default.
|
|
"""
|
|
|
|
IAC, SE, SB, WILL, WONT, DO, DONT = 255, 240, 250, 251, 252, 253, 254
|
|
AGREE = (1, 3) # ECHO and SUPPRESS-GO-AHEAD: what a dumb terminal wants
|
|
|
|
def __init__(self, reader, writer):
|
|
self.r, self.w = reader, writer
|
|
self.iac = self.sb = self.sb_iac = False
|
|
self.verb = 0
|
|
|
|
def _filter(self, data):
|
|
"""Split a chunk into (data for the guest, negotiation for the host)."""
|
|
out, reply = bytearray(), bytearray()
|
|
for b in data:
|
|
if self.sb: # skip subnegotiation payload until IAC SE
|
|
if self.sb_iac and b == self.SE:
|
|
self.sb = False
|
|
self.sb_iac = b == self.IAC and not self.sb_iac
|
|
elif self.verb: # this byte is the option the verb applies to
|
|
if self.verb == self.WILL:
|
|
yes = self.DO if b in self.AGREE else self.DONT
|
|
reply += bytes((self.IAC, yes, b))
|
|
elif self.verb == self.DO:
|
|
reply += bytes((self.IAC, self.WONT, b)) # we offer nothing
|
|
self.verb = 0 # WONT/DONT need no answer
|
|
elif self.iac:
|
|
self.iac = False
|
|
if b == self.IAC:
|
|
out.append(self.IAC) # doubled 0xFF is literal data
|
|
elif b in (self.WILL, self.WONT, self.DO, self.DONT):
|
|
self.verb = b
|
|
elif b == self.SB:
|
|
self.sb, self.sb_iac = True, False
|
|
elif b == self.IAC:
|
|
self.iac = True
|
|
else:
|
|
out.append(b)
|
|
return bytes(out), bytes(reply)
|
|
|
|
async def read(self, n):
|
|
while True: # a chunk of pure negotiation yields nothing; go round again
|
|
data = await self.r.read(n)
|
|
if not data:
|
|
return b""
|
|
out, reply = self._filter(data)
|
|
if reply:
|
|
self.w.write(reply)
|
|
await self.w.drain()
|
|
if out:
|
|
return out
|
|
|
|
def write(self, data):
|
|
self.w.write(data.replace(b"\xff", b"\xff\xff"))
|
|
|
|
async def drain(self):
|
|
await self.w.drain()
|
|
|
|
|
|
class Modem:
|
|
def __init__(self, dte_r, dte_w, book, pppd, guard=GUARD, ring=RING, hook=None):
|
|
self.dte_r, self.dte_w = dte_r, dte_w
|
|
self.book, self.pppd, self.guard = book, pppd, guard
|
|
self.ring_gap = ring # not self.ring -- that name is the method below
|
|
self.hook = hook # called whenever the line goes on/off hook
|
|
self.echo, self.verbose = True, True
|
|
self.s = {} # S registers; only S0 (auto-answer) is honoured
|
|
self.staged = "" # digits from ATD...; dialled in stages
|
|
self.pushback = b"" # bytes after a "+++ATH"-style escape
|
|
self.board = None # set when this modem is one line of a switchboard
|
|
self.peer = None # (reader, writer, close) while off-hook
|
|
self.incoming = None # same, while the phone is ringing
|
|
self.autoanswer = asyncio.Event()
|
|
|
|
# -- DTE plumbing ------------------------------------------------------
|
|
|
|
async def reply(self, text):
|
|
# V1 is verbose words, V0 is the numeric code. Both are in the wild.
|
|
log.info("DCE< %s", text)
|
|
out = f"\r\n{text}\r\n" if self.verbose else f"{CODES[text]}\r"
|
|
self.dte_w.write(out.encode())
|
|
await self.dte_w.drain()
|
|
|
|
async def _read_byte(self):
|
|
"""One byte from the DTE, but interruptible by auto-answer.
|
|
|
|
Without the race, S0 auto-answer could never fire: between calls the
|
|
modem sits blocked here, and a guest that has set S0 sends nothing at
|
|
all -- it just waits for CONNECT. Returns None to mean "answer now".
|
|
"""
|
|
if self.pushback: # command that arrived glued to a +++ escape
|
|
b, self.pushback = self.pushback[:1], self.pushback[1:]
|
|
return b
|
|
read = asyncio.ensure_future(self.dte_r.read(1))
|
|
ring = asyncio.ensure_future(self.autoanswer.wait())
|
|
done, pending = await asyncio.wait(
|
|
{read, ring}, return_when=asyncio.FIRST_COMPLETED)
|
|
for task in pending:
|
|
task.cancel() # safe: unread bytes stay in the StreamReader buffer
|
|
if read in done:
|
|
return read.result() # a real byte wins; the event survives for next time
|
|
self.autoanswer.clear()
|
|
return None
|
|
|
|
async def read_command(self):
|
|
"""Collect one CR-terminated line, honouring echo and backspace."""
|
|
buf = bytearray()
|
|
while True:
|
|
b = await self._read_byte()
|
|
if b is None:
|
|
return "ATA"
|
|
if not b:
|
|
return None
|
|
if self.echo:
|
|
self.dte_w.write(b)
|
|
await self.dte_w.drain()
|
|
if b in (b"\r", b"\n"):
|
|
if buf:
|
|
return buf.decode("latin-1")
|
|
buf.clear()
|
|
elif b == b"\x08":
|
|
buf[-1:] = b""
|
|
else:
|
|
buf += b
|
|
|
|
# -- command mode ------------------------------------------------------
|
|
|
|
async def command(self, line):
|
|
log.info("DTE> %s", line)
|
|
if not line[:2].upper() == "AT":
|
|
return await self.reply("ERROR")
|
|
for cmd, arg in parse(line[2:]):
|
|
if cmd == "D":
|
|
return await self.dial(arg)
|
|
if cmd == "A":
|
|
return await self.answer()
|
|
if cmd == "H":
|
|
await self.hangup()
|
|
elif cmd == "O" and self.peer:
|
|
return await self.online()
|
|
elif cmd == "E":
|
|
self.echo = arg != "0"
|
|
elif cmd == "V":
|
|
self.verbose = arg != "0"
|
|
elif cmd.startswith("S") and cmd[1:].isdigit():
|
|
self.s[int(cmd[1:])] = int(arg or 0)
|
|
# Everything else (Z, &F, &C1, &D2, S0=0, X4, ...) is accepted and
|
|
# ignored on purpose: answering OK to unknown setup commands is what
|
|
# makes an emulated modem work with dialers you have never seen.
|
|
await self.reply("OK")
|
|
|
|
async def dial(self, num):
|
|
# A trailing ';' means "dial, then return to command state" -- Windows
|
|
# TAPI opens every call with a bare `ATDT;` and only then sends the
|
|
# digits, so answering NO CARRIER here kills the call before it starts.
|
|
# Accumulate the staged digits and answer OK, as a real modem does.
|
|
num = num.rstrip()
|
|
if num.endswith(";"):
|
|
self.staged += num[:-1]
|
|
return await self.reply("OK")
|
|
num, self.staged = self.staged + num, ""
|
|
|
|
# D takes an optional dial modifier -- T(one) or P(ulse). Strip exactly
|
|
# one, never lstrip(): dialling a host called "telnet.example.com" must
|
|
# keep its 't'. Harmless for phonebook lookups, which go through
|
|
# digits(), but it lands inside the hostname when dialling an address.
|
|
if num[:1] in ("T", "P", "t", "p"):
|
|
num = num[1:].strip()
|
|
|
|
# Exact-digit lookup, same as 86Box: an outside-line prefix (ATDT9,555...)
|
|
# is part of the number and will miss. Turn the prefix off in the dialer.
|
|
target = self.book.get(digits(num)) or (num if ":" in num else None)
|
|
if not target:
|
|
return await self.reply("NO CARRIER")
|
|
try:
|
|
if target.startswith("vm:"):
|
|
# Internal call to another line of this switchboard: no TCP hop,
|
|
# no per-line port, and the callee really rings rather than
|
|
# being handed raw bytes.
|
|
name = target[len("vm:"):]
|
|
if self.board is None or name not in self.board.lines:
|
|
return await self.reply("NO CARRIER")
|
|
got = await self.board.call(name)
|
|
if got is None:
|
|
return await self.reply("BUSY")
|
|
# ponytail: CONNECT lands as soon as the callee starts RINGing,
|
|
# not when it answers -- parity with the TCP path, where the
|
|
# kernel completes connect() before anyone picks up. Data just
|
|
# buffers until the callee's ATA. Wait on an answer event if a
|
|
# guest ever objects.
|
|
self.peer = (got[0], got[1], got[1].close)
|
|
elif target.startswith("ssh:"):
|
|
# Same subprocess shape as ppp. Deliberately NOT a generic
|
|
# "exec:" target: a phonebook is the kind of file that ends up
|
|
# editable by something other than root, and arbitrary argv in
|
|
# it would be remote root execution wearing a phone number.
|
|
dest = target[len("ssh:"):]
|
|
host, sep, port = dest.rpartition(":")
|
|
cmd = ["ssh", "-tt"] + (["-p", port] if sep else []) + [host if sep else dest]
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdin=asyncio.subprocess.PIPE,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
)
|
|
self.peer = (proc.stdout, proc.stdin, proc.kill)
|
|
elif target == "ppp":
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*self.pppd,
|
|
stdin=asyncio.subprocess.PIPE,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
)
|
|
self.peer = (proc.stdout, proc.stdin, proc.kill)
|
|
elif target.startswith("telnet:"):
|
|
addr = target[len("telnet:"):]
|
|
host, sep, port = addr.rpartition(":")
|
|
if not sep: # "telnet:bbs.example.com" -- the default port is the point
|
|
host, port = addr, "23"
|
|
r, w = await asyncio.open_connection(host, int(port))
|
|
tn = Telnet(r, w)
|
|
self.peer = (tn, tn, w.close)
|
|
else:
|
|
host, _, port = target.rpartition(":")
|
|
r, w = await asyncio.open_connection(host, int(port))
|
|
self.peer = (r, w, w.close)
|
|
except ConnectionRefusedError:
|
|
# The far end is engaged: a busy line closes its listener, so the
|
|
# refusal at connect() IS the busy tone. Must be caught before
|
|
# OSError, which it subclasses.
|
|
return await self.reply("BUSY")
|
|
except (OSError, ValueError):
|
|
return await self.reply("NO CARRIER")
|
|
await self._hook()
|
|
await self.reply("CONNECT")
|
|
await self.online()
|
|
|
|
async def _hook(self):
|
|
"""Tell the owner whether the line is engaged. A ringing line counts as
|
|
engaged, same as a real one -- you do not get two calls on one pair."""
|
|
if self.hook:
|
|
await self.hook()
|
|
|
|
async def ring(self, r, w):
|
|
"""An inbound call. Ring the DTE until it answers or the caller tires."""
|
|
call = self.incoming = (r, w, w.close)
|
|
await self._hook()
|
|
for n in range(1, RINGS + 1):
|
|
if self.incoming is not call:
|
|
return # answered, or hung up on
|
|
await self.reply("RING")
|
|
if self.s.get(0) and n >= self.s[0]:
|
|
self.autoanswer.set()
|
|
return
|
|
await asyncio.sleep(self.ring_gap)
|
|
if self.incoming is call: # nobody picked up
|
|
self.incoming = None
|
|
w.close()
|
|
await self._hook()
|
|
|
|
async def answer(self):
|
|
if not self.incoming:
|
|
return await self.reply("NO CARRIER")
|
|
self.peer, self.incoming = self.incoming, None
|
|
await self.reply("CONNECT")
|
|
await self.online()
|
|
|
|
async def hangup(self):
|
|
# ATH while ringing rejects the call, which is also how a real one behaves.
|
|
for slot in ("peer", "incoming"):
|
|
call = getattr(self, slot)
|
|
if call:
|
|
try:
|
|
call[2]()
|
|
except (OSError, ProcessLookupError):
|
|
pass
|
|
setattr(self, slot, None)
|
|
await self._hook()
|
|
|
|
# -- data mode ---------------------------------------------------------
|
|
|
|
async def online(self):
|
|
"""Pump both directions until +++ escapes or the far end drops.
|
|
|
|
Returns to command mode with the call still up on escape (ATO resumes,
|
|
ATH hangs up), exactly as a real modem does.
|
|
"""
|
|
peer_r, peer_w, _ = self.peer
|
|
up = asyncio.ensure_future(self._dte_to_peer(peer_w))
|
|
down = asyncio.ensure_future(self._peer_to_dte(peer_r))
|
|
# Whichever direction ends first ends the call: waiting only on the DTE
|
|
# side left the modem pumping into a dead peer after NO CARRIER, so it
|
|
# never came back to command mode and swallowed every later command.
|
|
done, pending = await asyncio.wait(
|
|
{up, down}, return_when=asyncio.FIRST_COMPLETED)
|
|
for task in pending:
|
|
task.cancel()
|
|
if up in done and up.result(): # +++ : the call stays up, ATO resumes it
|
|
await self.reply("OK")
|
|
|
|
async def _dte_to_peer(self, peer_w):
|
|
"""Guest -> far end. True if the guest escaped with +++."""
|
|
held, last = b"", time.monotonic()
|
|
while True:
|
|
try:
|
|
data = await asyncio.wait_for(
|
|
self.dte_r.read(4096), self.guard if held else None)
|
|
except asyncio.TimeoutError:
|
|
return True # trailing guard elapsed: escaped
|
|
if not data:
|
|
return False
|
|
if held: # +++ was followed by traffic, so it was just data
|
|
peer_w.write(held)
|
|
held = b""
|
|
if data.startswith(b"+++") and time.monotonic() - last >= self.guard:
|
|
# NT4's RAS sends the escape and the command as ONE write --
|
|
# "+++ATH" -- so matching only a bare b"+++" forwarded the whole
|
|
# thing to the far end and the line could never be hung up.
|
|
# A bare +++ still needs its trailing guard time; +++ followed by
|
|
# a command is unambiguous, so escape at once and hand the rest
|
|
# to the command reader.
|
|
# ponytail: still assumes +++ arrives in one read. A dialer that
|
|
# dribbles it byte-by-byte needs a per-byte timer instead.
|
|
if len(data) == 3:
|
|
held = data
|
|
continue
|
|
self.pushback = data[3:]
|
|
return True
|
|
try:
|
|
peer_w.write(data)
|
|
await peer_w.drain()
|
|
except (ConnectionError, BrokenPipeError):
|
|
return False
|
|
last = time.monotonic()
|
|
|
|
async def _peer_to_dte(self, peer_r):
|
|
"""Far end -> guest, until carrier drops."""
|
|
while True:
|
|
data = await peer_r.read(4096)
|
|
if not data:
|
|
break
|
|
self.dte_w.write(data)
|
|
await self.dte_w.drain()
|
|
await self.hangup()
|
|
await self.reply("NO CARRIER")
|
|
|
|
async def run(self):
|
|
while True:
|
|
line = await self.read_command()
|
|
if line is None:
|
|
return await self.hangup()
|
|
await self.command(line)
|
|
|
|
|
|
class Switchboard:
|
|
"""The lines this process owns, so a call between two of them never leaves it.
|
|
|
|
Dialling a VM cannot be "just another target type": the answering guest needs
|
|
a MODEM to hear RING and reply ATA, so wiring the caller straight to its
|
|
serial socket would hand RAS raw bytes and it would never pick up. Only a
|
|
process holding both ends can ring one on behalf of the other -- which is
|
|
also where the hunt group and a real busy signal come from.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.lines = {} # name -> Modem, while attached
|
|
|
|
def free(self, name):
|
|
modem = self.lines.get(name)
|
|
return modem is not None and not modem.peer and not modem.incoming
|
|
|
|
async def call(self, name):
|
|
"""Ring line `name`. Returns the caller's end, or None if it is engaged.
|
|
|
|
A socketpair is the whole implementation: each modem gets one end and
|
|
every path below -- the pump, 8-bit cleanliness, +++, NO CARRIER -- is
|
|
the same code that carries an external call.
|
|
"""
|
|
if not self.free(name):
|
|
return None
|
|
left, right = socket.socketpair()
|
|
caller_r, caller_w = await asyncio.open_connection(sock=left)
|
|
callee_r, callee_w = await asyncio.open_connection(sock=right)
|
|
asyncio.ensure_future(self.lines[name].ring(callee_r, callee_w))
|
|
return caller_r, caller_w
|
|
|
|
|
|
async def connect_dte(target):
|
|
"""Attach to a DTE that is listening: a unix socket path, or host:port."""
|
|
if ":" in target:
|
|
host, _, port = target.rpartition(":")
|
|
return await asyncio.open_connection(host, int(port))
|
|
return await asyncio.open_unix_connection(target)
|
|
|
|
|
|
async def serve(book, pppd, listen=None, connect=None, line=None, guard=GUARD,
|
|
ring=RING, board=None, name=None):
|
|
"""Bring up one modem and, if --line was given, its phone line.
|
|
|
|
Returns (dte_server_or_None, phone), where phone["port"] is the line's real
|
|
port once it has been bound. With a `board`, the modem registers itself so
|
|
other lines can ring it without going out over TCP.
|
|
"""
|
|
state = {}
|
|
phone = {"port": None if line is None else int(line), "srv": None}
|
|
|
|
async def call(r, w):
|
|
modem = state.get("modem")
|
|
if modem is None or modem.peer or modem.incoming:
|
|
return w.close() # lost a race with hook(); refuse politely
|
|
await modem.ring(r, w)
|
|
|
|
async def hook():
|
|
"""Listen only while a modem is attached and on-hook.
|
|
|
|
This is the busy signal. Closing the listener means a second caller is
|
|
refused by the kernel at connect(), before anything can pretend the call
|
|
went through -- whereas accepting and then closing would make the caller
|
|
report CONNECT and immediately NO CARRIER. A ringing line is engaged too.
|
|
"""
|
|
if phone["port"] is None:
|
|
return
|
|
modem = state.get("modem")
|
|
free = modem is not None and not modem.peer and not modem.incoming
|
|
if free and not phone["srv"]:
|
|
# 0.0.0.0, not "": an unspecified host binds one socket per family,
|
|
# and with port 0 each gets a *different* ephemeral port. v4-only is
|
|
# honest here -- retronet has no IPv6 and the laptop's is broken.
|
|
phone["srv"] = await asyncio.start_server(call, "0.0.0.0", phone["port"])
|
|
phone["port"] = phone["srv"].sockets[0].getsockname()[1]
|
|
elif not free and phone["srv"]:
|
|
phone["srv"].close()
|
|
phone["srv"] = None
|
|
|
|
async def attach(r, w):
|
|
modem = Modem(r, w, book, pppd, guard, ring, hook)
|
|
modem.board = board
|
|
state["modem"] = modem
|
|
if board is not None:
|
|
board.lines[name] = modem
|
|
await hook()
|
|
try:
|
|
await modem.run()
|
|
finally:
|
|
state.pop("modem", None)
|
|
if board is not None:
|
|
board.lines.pop(name, None)
|
|
await hook()
|
|
w.close()
|
|
|
|
if connect:
|
|
# PVE's `-serial0 socket` leaves QEMU listening, so for a VM we are the
|
|
# client. 86Box and plain TCP want the opposite; hence both modes.
|
|
async def keep_attached():
|
|
"""Reattach forever: a guest reboot takes the chardev peer with it,
|
|
and a switchboard that needs restarting after every VM reboot is not
|
|
a service."""
|
|
while True:
|
|
try:
|
|
r, w = await connect_dte(connect)
|
|
except OSError as exc:
|
|
log.warning("line %s: %s", name or connect, exc)
|
|
await asyncio.sleep(5)
|
|
continue
|
|
log.info("line %s attached", name or connect)
|
|
await attach(r, w)
|
|
log.info("line %s dropped, reattaching", name or connect)
|
|
await asyncio.sleep(1)
|
|
|
|
asyncio.ensure_future(keep_attached())
|
|
return None, phone
|
|
|
|
if ":" in listen:
|
|
host, _, port = listen.rpartition(":")
|
|
dte = await asyncio.start_server(attach, host or "127.0.0.1", int(port))
|
|
else:
|
|
dte = await asyncio.start_unix_server(attach, listen)
|
|
return dte, phone
|
|
|
|
|
|
async def selftest():
|
|
"""Covers the three things that are easy to get wrong: the link must be
|
|
8-bit clean (what ip232 fails, and the reason this file exists), &D2 must
|
|
not read as a dial command, and an inbound call must ring and be answerable
|
|
both ways -- ATA, and S0 auto-answer."""
|
|
# start_server only schedules the callback if it is a coroutine FUNCTION --
|
|
# a lambda returning a coroutine is silently dropped. Pass _echo itself.
|
|
echo = await asyncio.start_server(_echo, "127.0.0.1", 0)
|
|
port = echo.sockets[0].getsockname()[1]
|
|
dead = _closed_port() # nothing listening: dialling it must give BUSY
|
|
book = {"5551212": f"127.0.0.1:{port}", "5559999": f"127.0.0.1:{dead}"}
|
|
dte, phone = await serve(book, [], listen="127.0.0.1:0", line=0,
|
|
guard=0.05, ring=0.05)
|
|
r, w = await asyncio.open_connection(*dte.sockets[0].getsockname()[:2])
|
|
|
|
# unknown setup commands must not be mistaken for a dial (&D2!)
|
|
assert parse("&F&C1&D2S0=0") == [("&F", ""), ("&C", "1"), ("&D", "2"), ("S0", "0")]
|
|
assert parse("DT9,1-555-1212") == [("D", "T9,1-555-1212")]
|
|
assert digits("9,1-555-1212") == "915551212"
|
|
|
|
w.write(b"AT&F&C1&D2S0=0\r")
|
|
await w.drain()
|
|
assert b"OK" in await r.readuntil(b"OK\r\n")
|
|
line = phone["port"] # only bound once a modem attached, which OK just proved
|
|
assert line, "line never opened"
|
|
|
|
w.write(b"ATDT555-1212\r")
|
|
await w.drain()
|
|
assert b"CONNECT" in await r.readuntil(b"CONNECT\r\n")
|
|
|
|
payload = bytes(range(256)) * 4 # 0xFF and FF 03 included, deliberately
|
|
w.write(payload)
|
|
await w.drain()
|
|
assert await r.readexactly(len(payload)) == payload, "link is not 8-bit clean"
|
|
|
|
await asyncio.sleep(0.2) # leading guard
|
|
w.write(b"+++")
|
|
await w.drain()
|
|
assert b"OK" in await r.readuntil(b"OK\r\n"), "+++ did not escape"
|
|
w.write(b"ATH\r")
|
|
await w.drain()
|
|
assert b"OK" in await r.readuntil(b"OK\r\n")
|
|
|
|
# the real Win98 dial sequence: init string, then a staged dial
|
|
w.write(b"ATE0V1&C1&D2S0=0\r")
|
|
await w.drain()
|
|
assert b"OK" in await r.readuntil(b"OK\r\n"), "Win98 init string rejected"
|
|
w.write(b"ATDT;\r") # TAPI opens the call with no digits at all
|
|
await w.drain()
|
|
assert b"OK" in await r.readuntil(b"OK\r\n"), "ATDT; must be OK, not NO CARRIER"
|
|
w.write(b"ATDT555-1212\r") # ...and only then dials
|
|
await w.drain()
|
|
assert b"CONNECT" in await r.readuntil(b"CONNECT\r\n")
|
|
await asyncio.sleep(0.2) # data mode now: escape before ATH is a command
|
|
w.write(b"+++")
|
|
await w.drain()
|
|
assert b"OK" in await r.readuntil(b"OK\r\n")
|
|
w.write(b"ATH\r")
|
|
await w.drain()
|
|
assert b"OK" in await r.readuntil(b"OK\r\n")
|
|
|
|
# "+++ATH" in one write is how NT4 RAS hangs up: escape, then the command
|
|
w.write(b"ATDT555-1212\r")
|
|
await w.drain()
|
|
assert b"CONNECT" in await r.readuntil(b"CONNECT\r\n")
|
|
await asyncio.sleep(0.2)
|
|
w.write(b"+++ATH\r")
|
|
await w.drain()
|
|
assert b"OK" in await r.readuntil(b"OK\r\n"), "+++ATH did not escape"
|
|
assert b"OK" in await r.readuntil(b"OK\r\n"), "ATH after +++ was not obeyed"
|
|
|
|
# an address dialled straight, with no phonebook entry: the T modifier
|
|
# must not end up inside the hostname
|
|
w.write(f"ATDT127.0.0.1:{port}\r".encode())
|
|
await w.drain()
|
|
assert b"CONNECT" in await r.readuntil(b"CONNECT\r\n"), "direct address dial failed"
|
|
await asyncio.sleep(0.2)
|
|
w.write(b"+++ATH\r")
|
|
await w.drain()
|
|
assert b"OK" in await r.readuntil(b"OK\r\n")
|
|
assert b"OK" in await r.readuntil(b"OK\r\n")
|
|
|
|
# telnet: mode must swallow the IAC handshake and answer it, so the guest
|
|
# sees only the prompt -- and must double 0xFF on the way out
|
|
seen = []
|
|
|
|
async def fake_telnetd(tr, tw):
|
|
tw.write(b"\xff\xfb\x01\xff\xfb\x03login: ") # WILL ECHO, WILL SGA
|
|
await tw.drain()
|
|
while (d := await tr.read(100)):
|
|
seen.append(d)
|
|
|
|
td = await asyncio.start_server(fake_telnetd, "127.0.0.1", 0)
|
|
book["5552323"] = f"telnet:127.0.0.1:{td.sockets[0].getsockname()[1]}"
|
|
w.write(b"ATDT5552323\r")
|
|
await w.drain()
|
|
assert b"CONNECT" in await r.readuntil(b"CONNECT\r\n")
|
|
assert await r.readexactly(7) == b"login: ", "IAC negotiation reached the guest"
|
|
w.write(b"\xff\x01") # guest sends a literal 0xFF
|
|
await w.drain()
|
|
await asyncio.sleep(0.3)
|
|
got = b"".join(seen)
|
|
assert got.startswith(b"\xff\xfd\x01\xff\xfd\x03"), f"no DO ECHO / DO SGA: {got}"
|
|
assert got.endswith(b"\xff\xff\x01"), f"0xFF was not doubled: {got}"
|
|
await asyncio.sleep(0.2)
|
|
w.write(b"+++ATH\r")
|
|
await w.drain()
|
|
assert b"OK" in await r.readuntil(b"OK\r\n")
|
|
assert b"OK" in await r.readuntil(b"OK\r\n")
|
|
|
|
# dialling a line with nobody on it is BUSY, not NO CARRIER
|
|
w.write(b"ATDT5559999\r")
|
|
await w.drain()
|
|
assert b"BUSY" in await r.readuntil(b"BUSY\r\n")
|
|
|
|
# inbound, answered by hand -- NT4 RAS / TAPI issue ATA on RING
|
|
cr, cw = await asyncio.open_connection("127.0.0.1", line)
|
|
assert b"RING" in await r.readuntil(b"RING\r\n")
|
|
|
|
# ...and while it rings, the line is engaged: the second caller is refused
|
|
# by the kernel, so its modem reports BUSY instead of a phantom CONNECT
|
|
try:
|
|
await asyncio.open_connection("127.0.0.1", line)
|
|
raise AssertionError("second caller was not given a busy line")
|
|
except ConnectionRefusedError:
|
|
pass
|
|
|
|
w.write(b"ATA\r")
|
|
await w.drain()
|
|
assert b"CONNECT" in await r.readuntil(b"CONNECT\r\n")
|
|
cw.write(b"\xff\x03hello") # a PPP-shaped frame, inbound this time
|
|
await cw.drain()
|
|
assert await r.readexactly(7) == b"\xff\x03hello"
|
|
cw.close()
|
|
assert b"NO CARRIER" in await r.readuntil(b"NO CARRIER\r\n")
|
|
|
|
# inbound, auto-answered: the guest sets S0 and then says nothing at all
|
|
w.write(b"ATS0=2\r")
|
|
await w.drain()
|
|
assert b"OK" in await r.readuntil(b"OK\r\n")
|
|
cr, cw = await asyncio.open_connection("127.0.0.1", line)
|
|
assert b"CONNECT" in await r.readuntil(b"CONNECT\r\n"), "S0 did not auto-answer"
|
|
# switchboard: two lines in one process, an internal call between them
|
|
tmp = tempfile.mkdtemp()
|
|
ends = {}
|
|
|
|
async def fake_qemu(nm): # stands in for QEMU's listening serial socket
|
|
async def cb(qr, qw):
|
|
ends[nm] = (qr, qw)
|
|
await asyncio.start_unix_server(cb, f"{tmp}/{nm}.sock")
|
|
|
|
await fake_qemu("A")
|
|
await fake_qemu("B")
|
|
board, book2 = Switchboard(), {"5551111": "vm:B", "5552222": "vm:NOPE"}
|
|
for nm in ("A", "B"):
|
|
await serve(book2, [], connect=f"{tmp}/{nm}.sock", board=board, name=nm,
|
|
guard=0.05, ring=0.05)
|
|
for _ in range(60):
|
|
if len(board.lines) == 2 and len(ends) == 2:
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
assert len(board.lines) == 2, f"lines did not attach: {board.lines}"
|
|
ar, aw = ends["A"]
|
|
br, bw = ends["B"]
|
|
|
|
aw.write(b"ATDT5551111\r")
|
|
await aw.drain()
|
|
assert b"RING" in await br.readuntil(b"RING\r\n"), "callee never rang"
|
|
bw.write(b"ATA\r")
|
|
await bw.drain()
|
|
assert b"CONNECT" in await ar.readuntil(b"CONNECT\r\n")
|
|
assert b"CONNECT" in await br.readuntil(b"CONNECT\r\n")
|
|
both = bytes(range(256)) # the internal hop must be 8-bit clean too
|
|
aw.write(both)
|
|
await aw.drain()
|
|
assert await br.readexactly(256) == both, "internal call is not 8-bit clean"
|
|
|
|
# an unknown line is NO CARRIER; an engaged one is BUSY
|
|
await asyncio.sleep(0.2)
|
|
bw.write(b"+++")
|
|
await bw.drain()
|
|
assert b"OK" in await br.readuntil(b"OK\r\n")
|
|
bw.write(b"ATDT5552222\r")
|
|
await bw.drain()
|
|
assert b"NO CARRIER" in await br.readuntil(b"NO CARRIER\r\n"), "unknown line"
|
|
bw.write(b"ATDT5551111\r") # line B calling... a line that is engaged (itself)
|
|
await bw.drain()
|
|
assert b"BUSY" in await br.readuntil(b"BUSY\r\n"), "engaged line was not BUSY"
|
|
|
|
print("ok")
|
|
|
|
|
|
def _closed_port():
|
|
"""A port the OS just handed back, so nothing is listening on it."""
|
|
with socket.socket() as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
async def _echo(r, w):
|
|
while (data := await r.read(4096)):
|
|
w.write(data)
|
|
await w.drain()
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
dte = ap.add_mutually_exclusive_group()
|
|
dte.add_argument("--listen", help="unix socket path, or host:port, to listen on")
|
|
dte.add_argument("--connect", help="unix socket path, or host:port, to dial into "
|
|
"(PVE's -serial0 socket leaves QEMU listening)")
|
|
ap.add_argument("--phonebook", help="86Box-format number->target map")
|
|
ap.add_argument("--pppd", default="", help="command run for a 'ppp' target")
|
|
ap.add_argument("--line", help="TCP port to accept incoming calls on")
|
|
ap.add_argument("--vm", action="append", default=[], metavar="VMID[:PORT]",
|
|
help="switchboard line on a PVE VM's serial0 socket; repeatable. "
|
|
"PORT accepts calls from off-box (86Box on another host); "
|
|
"other lines reach it as the phonebook target vm:VMID")
|
|
ap.add_argument("--debug", action="store_true", help="log the AT conversation")
|
|
ap.add_argument("--selftest", action="store_true")
|
|
a = ap.parse_args()
|
|
logging.basicConfig(level=logging.INFO if a.debug else logging.WARNING,
|
|
format="%(asctime)s %(message)s", datefmt="%H:%M:%S")
|
|
if a.selftest:
|
|
return asyncio.run(selftest())
|
|
if not (a.listen or a.connect or a.vm):
|
|
ap.error("one of --listen, --connect or --vm is required")
|
|
asyncio.run(_run(a))
|
|
|
|
|
|
async def _run(a):
|
|
book, pppd = Phonebook(a.phonebook), shlex.split(a.pppd)
|
|
if a.vm:
|
|
board = Switchboard()
|
|
for spec in a.vm:
|
|
vmid, _, port = spec.partition(":")
|
|
await serve(book, pppd, board=board, name=vmid, line=port or None,
|
|
connect=f"/var/run/qemu-server/{vmid}.serial0")
|
|
log.info("line %s%s", vmid, f" answering on :{port}" if port else "")
|
|
if a.listen or a.connect:
|
|
await serve(book, pppd, listen=a.listen, connect=a.connect, line=a.line)
|
|
await asyncio.Event().wait()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|