Metadata-Version: 2.4
Name: ser2tcp
Version: 3.2.0
Summary: Serial port proxy to TCP or TELNET
Author-email: Pavel Revak <pavel.revak@gmail.com>
License: MIT
Project-URL: Homepage, https://github.com/cortexm/ser2tcp
Keywords: serial,tcp,telnet,proxy
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Topic :: Software Development :: Embedded Systems
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pyserial>=3.0
Requires-Dist: uhttp-server>=3.2
Requires-Dist: cryptography>=42.0
Provides-Extra: test
Requires-Dist: websocket-client>=1.0; extra == "test"
Dynamic: license-file

# Ser2tcp

Simple proxy for connecting over TCP, TELNET, TLS, WebSocket or Unix socket to serial port

https://github.com/cortexm/ser2tcp

## Features

- can serve multiple serial ports using pyserial library
- each serial port can have multiple servers
- server can use TCP, TELNET, TLS, WebSocket or SOCKET protocol
  - TCP protocol just bridge whole RAW serial stream to TCP
  - TELNET protocol will send every character immediately and not wait for ENTER, it is useful to use standard `telnet` as serial terminal
  - TLS protocol provides an encrypted TCP connection with optional mutual TLS (mTLS) client certificate verification
  - WebSocket protocol connects through the HTTP server with binary frames for data and JSON text frames for signal control
  - SOCKET protocol uses Unix domain socket for local IPC
- servers accepts multiple connections at one time
  - each connected client can sent to serial port
  - serial port send received data to all connected clients
- non-blocking send with configurable timeout and buffer limit
- serial signal control (RTS, DTR, CTS, DSR, RI, CD) via escape protocol or WebSocket JSON
- IP filtering with allow/deny lists (CIDR notation supported)
- built-in HTTP server with REST API for status monitoring
- web interface for viewing configured ports and connections
- web terminal clients (xterm.js VT100 terminal and raw colored view)
- authentication with session management and API tokens
- TLS certificate manager via web UI (upload, paste, drag-and-drop PEM files)
- light/dark mode web UI (follows system preference)

## Installation

```
pip install ser2tcp
```

or from source:

```
pip install .
```

### Uninstall

```
pip uninstall ser2tcp
```

## Command line options

```
  -h, --help            show this help message and exit
  -V, --version         show program's version number and exit
  -v, --verbose         Verbose output (-v: requests, -vv: debug)
  -q, --quiet           Errors only
  -u, --usb             List USB serial devices and exit
  --hash-password PASSWORD
                        Hash password for config file and exit
  -c CONFIG, --config CONFIG
                        configuration in JSON format (default: ~/.config/ser2tcp/config.json)
```

If no config file is specified and default config doesn't exist, creates one with HTTP server on first free port from 20080.

### Logging

One step per flag — each one adds a kind of message to the one below:

| | shows |
|---|---|
| `-q` | errors only — a port that will not open, a server that cannot bind |
| *(default)* | and warnings, which includes every refused request |
| `-v` | and one line per request |
| `-vv` | and debug |

`-q` stops at errors rather than silencing everything: a process that
comes up serving nothing should still say so. Redirect the output if you
want it truly quiet.

A request is logged when it arrives. A request that is refused adds a
second line carrying the status, the method, the path, the client
address and the reason:

```
I: POST /api/login from 192.168.1.5
W: 401 POST /api/login from 192.168.1.5: Login failed: admin
```

An API token is addressed by itself (`/api/tokens/<token>`), so in those
lines its path is printed as `/api/tokens/***`, and a token that was
changed or deleted is named by its `name`. A reverse proxy in front of
ser2tcp keeps its own access log, which will still carry the full path.

That second line is deliberately self-contained, so log-watching tools
can act on it without stitching lines together. A fail2ban filter for
failed logins is just:

```ini
[Definition]
failregex = ^W: 401 POST /api/login from <HOST>: Login failed
```

Stopping an attack is not this program's job — that belongs to a proxy,
a firewall or a blocklist. Being readable by whatever does it is.

## Configuration file example

```json
{
    "ports": [
        {
            "serial": {
                "port": "/dev/ttyUSB0",
                "baudrate": 115200,
                "parity": "NONE",
                "stopbits": "ONE"
            },
            "servers": [
                {
                    "address": "127.0.0.1",
                    "port": 10001,
                    "protocol": "tcp"
                },
                {
                    "address": "0.0.0.0",
                    "port": 10002,
                    "protocol": "telnet",
                    "send_timeout": 5.0,
                    "buffer_limit": 65536
                }
            ]
        }
    ]
}
```

Legacy format (JSON array at root level) is still supported for backward compatibility.

### Serial configuration

`serial` structure pass all parameters to [serial.Serial](https://pythonhosted.org/pyserial/pyserial_api.html#classes) constructor from pyserial library, this allows full control of the serial port.

#### USB device matching

Instead of specifying `port` directly, you can use `match` to find device by USB attributes:

```json
{
    "serial": {
        "match": {
            "vid": "0x303A",
            "pid": "0x4001",
            "serial_number": "dcda0c2004bc0000"
        },
        "baudrate": 115200
    }
}
```

Use `ser2tcp --usb` to list available USB devices with their attributes:

```
$ ser2tcp --usb
/dev/cu.usbmodem1101
  vid: 0x303A
  pid: 0x4001
  serial_number: dcda0c2004bc0000
  manufacturer: Espressif Systems
  product: Espressif Device
  location: 1-1
```

Match attributes: `vid`, `pid`, `serial_number`, `manufacturer`, `product`, `location`, `description`, `hwid`

- Wildcard `*` supported (e.g. `"product": "CP210*"`)
- Matching is case-insensitive
- Error if multiple devices match the criteria
- Device is resolved when client connects, not at startup (device does not need to exist at startup)
- `baudrate` is optional (default 9600, CDC devices ignore it)

### Server configuration

| Parameter | Description | Default |
|-----------|-------------|---------|
| `address` | Bind address (IP for tcp/telnet/tls, path for socket) | required* |
| `port` | TCP port (not used for socket/websocket) | required* |
| `protocol` | `tcp`, `telnet`, `tls`, `websocket` or `socket` | required |
| `endpoint` | WebSocket URL path (websocket only), must be unique | required* |
| `token` | Per-server auth token (websocket only) | - |
| `tls` | TLS configuration (required for `tls` protocol) | - |
| `access` | Which way data may flow: `rw`, `ro`, `wo`, `none` | `rw` |
| `data` | Older spelling of `access` — `false` means `none` | true |
| `control` | Signal control configuration | - |
| `send_timeout` | Disconnect client if data cannot be sent within this time (seconds) | 5.0 |
| `buffer_limit` | Maximum send buffer size per client (bytes), `null` for unlimited | null |
| `max_connections` | Maximum clients per server (0 = unlimited) | 0 |

\* `address`/`port` required for tcp/telnet/tls; `address` for socket; `endpoint` for websocket

#### Access mode

`access` says which way data may flow on a server. A port can carry
several servers with different modes at once — a read-write one for the
application, a read-only one for a logger, and so on.

| `access` | Receives what the device sends | May write to the device |
|----------|--------------------------------|-------------------------|
| `rw` *(default)* | yes | yes |
| `ro` | yes | **no** |
| `wo` | **no** | yes |
| `none` | no | no |

```json
{
    "servers": [
        {"protocol": "tcp", "address": "0.0.0.0", "port": 10001},
        {"protocol": "tcp", "address": "0.0.0.0", "port": 10002,
         "access": "ro"}
    ]
}
```

Anything a read-only client sends is discarded — it does not reach the
device and it is not passed on to the other clients either. A write-only
client is never sent the device's output, while the others still are.

`none` is for a server that exists only for its control protocol, so it
requires `control` to be configured; without it the server would do
nothing at all and is refused at startup.

Works on TCP, TELNET, TLS, WebSocket and Unix socket servers.

> `data` is the older spelling and still works: `"data": false` means
> `"access": "none"`, `"data": true` means `"access": "rw"`. Giving both
> and having them disagree is refused rather than resolved silently.

#### Port-level connection limit

You can also limit total connections across all servers on a port:

```json
{
    "ports": [{
        "max_connections": 10,
        "serial": {"port": "/dev/ttyUSB0"},
        "servers": [
            {"protocol": "tcp", "address": "0.0.0.0", "port": 10001, "max_connections": 5},
            {"protocol": "websocket", "endpoint": "device"}
        ]
    }]
}
```

- Port-level `max_connections`: limits total clients across all servers (default 0 = unlimited)
- Server-level `max_connections`: limits clients on that specific server (default 0 = unlimited)
- Both limits are checked — if either is reached, new connections are rejected

#### WebSocket configuration

WebSocket connections go through the HTTP server — no separate listening port needed:

```json
{
    "protocol": "websocket",
    "endpoint": "my-device",
    "control": {
        "rts": true,
        "signals": ["rts", "dtr", "cts", "dsr"]
    }
}
```

- Accessible at `ws://host:port/ws/my-device` (or `wss://` for HTTPS)
- Available on all configured HTTP servers
- Binary frames carry raw serial data (bidirectional)
- Text frames carry JSON: the port it reached, what the client may do,
  whether the device is there, signal states, and errors
- The first frame carries everything; later ones only what changed
- A client can let go of the serial port without closing the socket
  (`{"attach": false}`) and keeps being told about it
- The device going away does not close the WebSocket — the client is
  told, and told again when it comes back. A port is retried for as
  long as somebody is attached, so a device that is not plugged in yet
  is something to wait for rather than a connection error
- Auth: per-server `token`, global user session, or both accepted
- Web terminals available at `/xterm/<endpoint>` (VT100) and `/raw/<endpoint>` (colored hex)
- A terminal can be opened before the device is plugged in: the page is
  not refused by a port that will not open, it is told, and the port is
  retried for as long as somebody is waiting — so the first byte the
  board sends is already on screen. The page picks the link itself back
  up too, after a server restart or a sleeping laptop, unless you
  pressed Disconnect

The full message format, for both `/ws/<endpoint>` and
`/ws/monitor/<port-name>`, is in [README_WS_API.md](README_WS_API.md).

#### Share link

An endpoint that has a `token` can be handed to somebody who has no
account here — "this is our device, try it". In the web UI, the share
icon beside a WebSocket endpoint gives three URLs:

```
http://host:8080/xterm/my-device#token=SECRET   VT100 terminal
http://host:8080/raw/my-device#token=SECRET     raw view
ws://host:8080/ws/my-device?token=SECRET        for a program
```

- The token is the whole credential and it opens **that endpoint only**,
  as far as its `access` allows. It is not an account: it reaches no
  other endpoint, no API and no web UI
- The browser links carry it in the URL **fragment**, which is never
  sent to the server: it stays out of the request log, out of a reverse
  proxy's log and out of the `Referer` header. The page reads it once,
  keeps it for that browser tab and clears it from the address bar
- The WebSocket URL uses `?token=` instead, since a program has no
  fragment to read — that one does appear in server logs
- To revoke, generate a new token for the endpoint: every link handed
  out so far stops working
- Limit the audience further with `allow`/`deny` and `max_connections`
  on the same server

Sharing sends somebody to an HTTP server that also serves the web UI
and the whole API. On a LAN or a VPN that is the point; do not take it
as a reason to expose that server to the internet.

#### Socket configuration

For `socket` protocol, `address` is the path to the Unix domain socket:

```json
{
    "address": "/tmp/ser2tcp.sock",
    "protocol": "socket"
}
```

- Socket file is created on startup and removed on shutdown
- If socket file already exists, it is replaced
- Connect with: `socat - UNIX-CONNECT:/tmp/ser2tcp.sock`
- Not available on Windows

#### TLS configuration

For `tls` protocol, reference a certificate bundle managed by the
Certificate Manager (see [below](#managing-certificates-via-web-ui)).

```json
{
    "address": "0.0.0.0",
    "port": 10003,
    "protocol": "tls",
    "tls": {
        "bundle": "main",
        "require_client_cert": false
    }
}
```

> **Renamed from `ssl`.** The protocol value and the config block were
> both called `ssl`; they are `tls` now, with no fallback. A config
> written for an older version starts with *unknown protocol: ssl* —
> rename the two keys, or set the server up again in the web UI.

| Parameter | Description | Required |
|-----------|-------------|----------|
| `bundle` | Name of bundle in `{config_dir}/certs/<bundle>/` (must contain `cert.pem` + `key.pem`) | yes |
| `require_client_cert` | Enable mTLS — requires `ca.pem` in the bundle | no (default false) |
| `allow_client_cn` | List of client Common Names allowed on this server | no (default: any client the CA signed) |

When `require_client_cert: true`, clients must provide a valid certificate signed by `ca.pem` from the bundle.

##### Limiting mTLS to named clients

A CA answers *"is this a valid client"*, never *"is this that client"* —
every certificate it signs is accepted. Where one server is meant for
one client, name it:

```json
{
    "address": "0.0.0.0",
    "port": 10003,
    "protocol": "tls",
    "tls": {
        "bundle": "main",
        "require_client_cert": true,
        "allow_client_cn": ["operator"]
    }
}
```

- The certificate is verified against `ca.pem` first; the name is
  checked after that, and a client failing it is dropped right after
  the handshake, with the reason in the log
- Matching is exact, case included — a CN is an arbitrary string, not a
  hostname
- Requires `require_client_cert: true`. Without it a client need not
  present a certificate at all, so the list would enforce nothing;
  ser2tcp refuses that configuration rather than appearing to honour it
- Serial `tls` servers only. HTTP servers do not take this key — their
  access control is users, tokens and IP filters

The alternative, when clients come and go, is a CA per group of clients:
a port trusting only `ca-service.pem` accepts exactly the certificates
that CA signed, and nothing else has to be edited when one is added.

#### IP filtering

Restrict client connections by IP address using `allow` and/or `deny` lists:

```json
{
    "address": "0.0.0.0",
    "port": 10001,
    "protocol": "tcp",
    "allow": ["192.168.1.0/24", "10.0.0.5"],
    "deny": ["192.168.1.100"]
}
```

| Parameter | Description |
|-----------|-------------|
| `allow` | List of allowed IP addresses/networks (CIDR notation supported) |
| `deny` | List of denied IP addresses/networks (CIDR notation supported) |

Filter logic:
- **No config**: all IPs allowed
- **Only `deny`**: all IPs allowed except those in deny list
- **Only `allow`**: only IPs in allow list are allowed
- **Both**: deny takes precedence, then allow list is checked

Works on TCP, TELNET, TLS, WebSocket and HTTP servers. Not applicable to Unix socket (no IP addresses). Rejected connections are logged.

**A rule that cannot be read stops the server it belongs to.** `allow`
and `deny` must be lists, and every entry must parse as an address or a
network — a typo is refused rather than dropped with a warning, because
a filter that silently enforces less than it says is worse than one that
refuses to start. The server is not lost: it keeps its place in the
configuration, reports the reason through the API and shows up as a red
card in the web UI, so fixing the rule and saving starts it. The same
check runs on the API, so a bad rule is answered with 400 instead of
being written to `config.json`.

**IPv4 and IPv6 rules match whichever form the client arrives in.** A
server listening on an address containing a colon (`"::"`) accepts IPv4
clients too, and they arrive as IPv4-mapped addresses like
`::ffff:192.168.1.100`. Rules written the ordinary way (`192.168.1.100`,
`192.168.1.0/24`) apply to them; so does a rule written in the mapped
form. Real IPv6 clients are matched against IPv6 rules as usual.

#### Behind a reverse proxy

Without `trusted_proxies`, every client behind nginx looks like nginx:
the filter stops distinguishing anyone and the log names the proxy. List
the proxy's address and `X-Forwarded-For` is used instead:

```json
{
    "http": [{
        "address": "127.0.0.1",
        "port": 8080,
        "trusted_proxies": ["127.0.0.1"],
        "allow": ["192.168.0.0/16"]
    }]
}
```

- Exact addresses only — **CIDR is not accepted here**, and neither is a
  bare string instead of a list. The list of proxies is short and known,
  and a range would quietly widen who may claim to be someone else
- An entry that is not an address is refused: the server keeps its place
  in the configuration and reports the reason, rather than starting up
  and silently going on filtering the proxy
- The header is read **only** for connections that arrive from a listed
  address. Anyone else could have written it themselves

The client is resolved by walking the chain from the right, stopping at
the first address that is not a listed proxy — the same rule as nginx's
`real_ip_recursive on`. That matters with the usual nginx snippet:

```nginx
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
```

which *appends*, so a client sending `X-Forwarded-For: 10.0.0.1` ends up
with its forged entry on the left and its real address on the right.
Reading from the left would take the forged one and let it through an
allow list.

Only HTTP and WebSocket servers have a proxy in front of them. TCP,
TELNET and TLS servers carry no headers, so their filters always compare
the socket address.

##### Managing certificates via web UI

The web UI has a **Certificates** tab (admin only) for managing TLS
certificate bundles without shell access. A bundle is a directory under
`{config_dir}/certs/{bundle_name}/` containing a fixed set of PEM files:

```
~/.config/ser2tcp/certs/
  main/
    cert.pem      # server certificate (or full chain)
    key.pem       # private key (file mode 0600, never served via API)
    ca.pem        # optional — CA cert(s) for mTLS client verification
```

- Bundle names: letters, digits, dot, underscore, dash (no leading dot)
- Directory mode `0700`, key file `0600`
- PEM format validated on upload: **every** block in the file must belong
  there, not just the first. `cert.pem` and `ca.pem` hold certificates and
  nothing else, so a combined file — the certificate with the private key
  concatenated, which is what several tools hand you — is refused and has
  to be split. `key.pem` takes a key, optionally preceded by the
  `EC PARAMETERS` block `openssl ecparam -genkey` writes
- `cert.pem` and `key.pem` are checked against each other on upload — a key
  that belongs to a different certificate is rejected instead of failing
  later at the TLS handshake
- `key.pem` is never downloadable via API — only filesystem access
- A bundle is a directory, so files also arrive by `scp`, by symlink or
  from an editor, without passing the upload checks. `cert.pem` and
  `ca.pem` are therefore checked again when served: one found to contain
  a private key is refused (403) and flagged in the Certificates tab,
  rather than handed to any signed-in user from a `0644` file
- Bundles are referenced from the `tls` config via `"bundle": "<name>"`
  (both port TLS servers and HTTPS servers); a bundle cannot be deleted while
  any server still references it

**Web UI operations** (Certificates tab):
- Create / delete bundles
- Upload PEM file via file picker
- Paste PEM content into a textarea
- Drag-and-drop PEM file onto the file row
- Download public files (cert.pem / ca.pem)
- Delete individual files within a bundle — if the bundle is in use, the
  confirmation names the servers that will fail on their next reload
- **Replace cert + key** — upload both halves as one set (see *Renewing a
  certificate* below)
- **Reload** — apply a renewed certificate to running servers without a
  restart
- **Generate certificates** — self-signed server, CA, server signed by CA, client cert
  (downloads cert+key+ca for installation on the mTLS client, separately or
  as one combined PEM; private key is not stored on the server)

Server certificates are given a Subject Alternative Name: the ones typed
into the form, or the CN itself when none are. A certificate with no SAN
at all still satisfies `curl` and OpenSSL, which fall back to the CN, and
is refused by every browser — an easy trap to walk into and an annoying
one to diagnose. Fill in every name and address the server will be
reached at: verifying `https://192.168.1.10` needs that address in
**SAN IP**, a DNS name will not do.

For each bundle, the UI displays the parsed certificate metadata: CN, issuer
(or "self-signed"), Subject Alternative Names, expiry with color-coded warnings
(green > 30 days, orange < 30 days, red < 7 days or expired), key type/size,
SHA-256 fingerprint, and a "CA" badge for CA bundles.

> The built-in generator is intended for testing and internal use (lab,
> embedded, industrial LAN). For production PKI use a dedicated tool
> like smallstep, HashiCorp Vault, AWS ACM, or your existing infrastructure.

#### Renewing a certificate

A running server holds the certificate it loaded at startup, so replacing
the files on disk is only half the job:

1. Put the new pair in the bundle. Because the two files are validated
   against each other, send them together — in the UI use **Replace cert +
   key**, over the API use the set form:

   ```bash
   curl -X POST http://localhost:8080/api/certs/main/files \
       -H 'Authorization: Bearer <token>' \
       -H 'Content-Type: application/json' \
       -d '{"files": [
             {"filename": "cert.pem", "content": "-----BEGIN CERTIFICATE..."},
             {"filename": "key.pem",  "content": "-----BEGIN PRIVATE KEY..."}
           ]}'
   ```

   (The single-file form `{"filename": ..., "content": ...}` still works for
   `ca.pem`, or for the first half of an empty bundle.)

2. Tell the running servers to pick it up:

   ```bash
   curl -X POST http://localhost:8080/api/certs/main/reload \
       -H 'Authorization: Bearer <token>'
   ```

   Every server using that bundle — HTTPS servers and port TLS servers
   alike — re-reads the files into its existing `SSLContext`. Connections
   in flight keep the certificate they negotiated with; every handshake
   from that moment on uses the new one. The response lists the servers
   that were reloaded.

   A reload that cannot finish changes nothing: the files are loaded
   into a throwaway context first, and only repeated on the running one
   once that worked. Catching a bundle halfway through a renewal — the
   new `cert.pem` in place, `key.pem` still the old one — answers 400
   and leaves the server serving what it was serving, so a deploy hook
   that fires mid-copy is a failed reload rather than an outage.

`generate` refuses to overwrite an existing `cert.pem`, so regenerating
into a bundle in use means deleting `cert.pem` first, then generating,
then reloading.

> **CA changes still need a restart.** OpenSSL can add certificates to a
> context's trust store but not remove them, so a CA *added* to `ca.pem`
> takes effect on reload while one *removed* from it stays trusted until
> the process restarts.

**Let's Encrypt** — point a bundle at LE's `live/` directory using
symlinks (no native LE handling in code):

```bash
mkdir -p ~/.config/ser2tcp/certs/elhome.sk
ln -s /etc/letsencrypt/live/elhome.sk/fullchain.pem \
    ~/.config/ser2tcp/certs/elhome.sk/cert.pem
ln -s /etc/letsencrypt/live/elhome.sk/privkey.pem \
    ~/.config/ser2tcp/certs/elhome.sk/key.pem
```

Note: `privkey.pem` in `/etc/letsencrypt/live/` is owned by `root:root`
with mode `0600`, so ser2tcp needs to either run as root or use an LE
`--deploy-hook` to copy files into the bundle dir with appropriate
ownership/permissions on renewal.

Symlinks make the renewed files visible on disk, but a running server is
still serving the old certificate — have the deploy hook finish the job:

```bash
#!/bin/sh
# /etc/letsencrypt/renewal-hooks/deploy/ser2tcp.sh
curl -fsS -X POST https://localhost:8443/api/certs/elhome.sk/reload \
    -H "Authorization: Bearer $SER2TCP_TOKEN"
```

##### Creating self-signed certificates

Generate CA and server certificate for testing:

```bash
# Create CA key and certificate
openssl genrsa -out ca.key 2048
openssl req -new -x509 -days 365 -key ca.key -out ca.crt -subj "/CN=ser2tcp CA" \
    -addext "basicConstraints=critical,CA:TRUE" \
    -addext "keyUsage=critical,keyCertSign,cRLSign"

# Create server key and certificate signing request
openssl genrsa -out server.key 2048
openssl req -new -key server.key -out server.csr -subj "/CN=localhost"

# Sign server certificate with CA
openssl x509 -req -days 365 -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt

# For certificate bound to specific domain/IP (SAN - Subject Alternative Name):
openssl req -new -key server.key -out server.csr -subj "/CN=myserver.example.com" -addext "subjectAltName=DNS:myserver.example.com,DNS:localhost,IP:192.168.1.100"
openssl x509 -req -days 365 -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -copy_extensions copy

# Clean up CSR
rm server.csr
```

For mTLS (mutual TLS with client certificates):

```bash
# Create client key and certificate
openssl genrsa -out client.key 2048
openssl req -new -key client.key -out client.csr -subj "/CN=client"
openssl x509 -req -days 365 -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt
rm client.csr
```

Testing a TLS connection with `openssl s_client`:

```bash
# Plain TLS — skip cert validation (quick smoke test)
openssl s_client -connect localhost:10003

# With CA cert verification
openssl s_client -connect localhost:10003 -CAfile ca.pem -verify_return_error

# With hostname check against SAN (matches "server.local" against SAN DNS)
openssl s_client -connect server.local:10003 -CAfile ca.pem -verify_hostname server.local

# mTLS (client certificate required)
openssl s_client -connect localhost:10003 -CAfile ca.pem \
    -cert client-cert.pem -key client-key.pem

# Inspect what cert the server actually presents (no interactive session)
openssl s_client -connect localhost:10003 -showcerts </dev/null 2>/dev/null \
    | openssl x509 -text -noout | head -30
```

After connecting, `s_client` gives you a bidirectional stdin/stdout tunnel
to the serial port. A few quirks worth knowing:

- **Ctrl-C kills `s_client` itself** — it does not pass through to the
  remote serial. To send byte 0x03 (ETX / serial Ctrl-C) over the tunnel,
  either pipe it in: `printf '\x03' | openssl s_client … -quiet -ign_eof`,
  or put the terminal in raw mode first:

  ```bash
  stty -isig -icanon -echo
  openssl s_client -connect localhost:10003 -quiet
  stty sane                # restore terminal afterwards
  ```

  In raw mode press `Ctrl-D` (EOF) to exit.

- **`-quiet`** suppresses the verbose session info banner. Useful when
  forwarding binary data so the banner doesn't pollute the stream.

- For purely binary serial protocols, `socat` (`brew install socat`) or
  `ncat --ssl` (`brew install nmap`) are more transparent — no built-in
  command interception, raw mode by default.

### HTTP server and API

Optional HTTP server for monitoring and management:

```json
{
    "http": [
        {"name": "main", "address": "0.0.0.0", "port": 8080}
    ]
}
```

- `name`: optional label for the server (displayed in web UI Settings tab)
- HTTP servers can be added/removed/modified via web UI without restart

With authentication (configured at root level, shared across all HTTP servers):

```json
{
    "http": [
        {"address": "0.0.0.0", "port": 8080}
    ],
    "users": [
        {"login": "admin", "password": "sha256:...", "admin": true}
    ],
    "tokens": [
        {"token": "my-api-key", "name": "monitoring", "admin": false}
    ],
    "session_timeout": 3600
}
```

- `users`: login credentials with optional `admin` flag and per-user `session_timeout`
- `tokens`: permanent API tokens for automation (no expiration)
- `session_timeout`: global default session timeout in seconds (3600
  when absent). Set in the web UI at the top of **Settings**; a new
  value holds from the next request of every session, no restart
- First user added (via CLI or web UI) is automatically admin
- Cannot delete the last admin while other accounts remain — that would
  leave authentication on with nobody able to administer it
- Deleting the very last account (user or token) is allowed and **turns
  authentication off**: everyone who can reach the server then has full
  admin access without signing in. The web UI asks first; over the API
  it takes `?disable_auth=1`, and is refused with `409` without it

A change to an account reaches whoever is signed in on it right away,
without waiting for their session to lapse:

- granting or withdrawing `admin` applies to their open session and to
  the live status stream behind their browser tab, so the parts of the
  UI they may no longer use disappear on the spot
- changing `session_timeout` applies to their open session
- **changing a password signs that account out everywhere.** That is
  what makes it useful against an account that got out: nothing keeps
  working on the old password, and the web UI drops back to the login
  screen within a couple of seconds
- deleting a user signs them out the same way

API tokens are checked against the configuration on every request, so
editing or deleting one takes effect immediately as well.

Generate password hash:

```bash
ser2tcp --hash-password mysecretpassword
```

A password given to the API in plain text is hashed before it is stored,
so `users` in `config.json` never holds one. Whether a value is already
a hash is decided on its whole shape — `sha256:<salt>:<digest>` — not on
the `sha256:` prefix, so a password that happens to start with those
characters is hashed like any other rather than stored verbatim.

> **The hash is salted SHA-256, single pass.** That stops rainbow tables
> and stops one leaked password from unlocking the other accounts, but
> it is fast to compute — so anyone who gets hold of `config.json` (a
> backup, a shared machine) can run a dictionary attack against it at
> speed. Keep the file readable only by the user running ser2tcp, and
> treat a leaked config as a reason to change every password in it.

A failed login takes the same work whether the account exists or not, so
the response time does not say which logins are real.

HTTPS — uses the same bundle-based config as port TLS servers:

```json
{
    "http": [
        {"address": "0.0.0.0", "port": 8080},
        {"address": "0.0.0.0", "port": 8443, "tls": {
            "bundle": "main", "require_client_cert": false
        }}
    ]
}
```

With IP filtering:

```json
{
    "http": [{
        "address": "0.0.0.0",
        "port": 8080,
        "allow": ["192.168.0.0/16"],
        "deny": ["192.168.1.100"]
    }]
}
```

#### API endpoints

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/login` | no | Authenticate, returns session token |
| POST | `/api/logout` | no | Invalidate session |
| GET | `/api/status` | yes | Runtime status (serial ports, servers, connections) |
| GET | `/api/detect` | yes | Available serial ports with USB/device attributes |
| GET | `/api/signals` | yes | Signal states for all ports |
| GET | `/api/settings` | yes | Get settings (session_timeout + `rev` + `defaults`, http servers) |
| GET | `/api/ports/<id>` | admin | Port configuration (what to edit) |
| DELETE | `/api/ports/<id>/connections/<conn_id>` | yes | Disconnect client |
| POST | `/api/ports` | admin | Add new port configuration |
| PUT | `/api/ports/<id>` | admin | Update port configuration |
| DELETE | `/api/ports/<id>` | admin | Delete port configuration |
| POST | `/api/ports/<id>/move` | admin | Put the port `{"before": id}` or `{"after": id}` another one |
| PUT | `/api/ports/<id>/signals` | admin | Set RTS/DTR signals |
| GET | `/api/users` | admin | List users |
| POST | `/api/users` | admin | Add user |
| PUT | `/api/users/<login>` | admin | Update user |
| DELETE | `/api/users/<login>` | admin | Delete user |
| GET | `/api/tokens` | admin | List API tokens |
| POST | `/api/tokens` | admin | Add API token |
| PUT | `/api/tokens/<token>` | admin | Update API token |
| DELETE | `/api/tokens/<token>` | admin | Delete API token |
| PUT | `/api/settings` | admin | Update session_timeout (`null` = default; optional `rev` → 409) |
| POST | `/api/settings/http` | admin | Add HTTP server |
| PUT | `/api/settings/http/<id>` | admin | Update HTTP server |
| DELETE | `/api/settings/http/<id>` | admin | Delete HTTP server |
| POST | `/api/settings/http/<id>/move` | admin | Put the HTTP server `{"before": id}` or `{"after": id}` another one |
| GET | `/api/certs` | yes | List certificate bundles |
| POST | `/api/certs` | admin | Create empty bundle |
| GET | `/api/certs/<bundle>` | yes | Bundle detail (files, mtime, symlink target) |
| DELETE | `/api/certs/<bundle>` | admin | Delete bundle and all its files |
| POST | `/api/certs/<bundle>/files` | admin | Upload one file, or a set via `{"files": [...]}` |
| POST | `/api/certs/<bundle>/reload` | admin | Re-read bundle into running servers' TLS contexts |
| GET | `/api/certs/<bundle>/files/<filename>` | yes | Download public file (cert.pem / ca.pem) |
| DELETE | `/api/certs/<bundle>/files/<filename>` | admin | Delete single file from bundle |
| POST | `/api/certs/<bundle>/generate` | admin | Generate cert+key into bundle (modes: self_signed / ca / signed_by) |
| POST | `/api/certs/generate-client` | admin | Generate mTLS client cert (returns PEM, not stored on server) |
| GET | `/xterm/<endpoint>` | no | WebSocket VT100 terminal |
| GET | `/raw/<endpoint>` | no | WebSocket raw terminal |
| GET | `/monitor/<port-name>` | no | Read-only traffic monitor |
| GET | `/ws/<endpoint>` | yes | WebSocket serial endpoint |
| GET | `/ws/monitor/<port-name>` | yes | WebSocket traffic monitor |

Auth levels: `no` = public, `yes` = any authenticated user, `admin` = admin user/token only.

Authentication: `Authorization: Bearer <token>` header or `?token=<token>` query parameter. Without users/tokens configured, all endpoints are accessible without authentication.

### Identifiers

Ports and HTTP servers are addressed by `id`, not by their position in
the configuration. ser2tcp writes an `id` into every port and HTTP
server entry the first time it reads a configuration that lacks one, so
an existing config gains them on the next start and keeps them from
then on:

```json
{
  "ports": [
    {"id": "my-device", "name": "my-device", "serial": {"port": "/dev/ttyUSB0"}, "servers": []}
  ],
  "http": [
    {"id": "main", "name": "main", "address": "0.0.0.0", "port": 8080}
  ]
}
```

An id survives edits, so a bookmarked URL or an open editor keeps
pointing at the same port. A position would not: adding or removing an
entry renumbers everything after it, and a port that fails to start
would shift the rest.

One that is not given is derived from the entry's name: lowercase,
digits and hyphens, with every other character becoming a hyphen, runs
of them collapsing to one and neither end keeping one. `My Port ##2`
gives `my-port-2`. With no name, a port falls back to its device
(`/dev/ttyUSB0` → `ttyusb0`); with neither, to `port` or `http`. An id
already in use takes the first free `-1`, `-2` and so on — ports and
HTTP servers share one namespace.

Choosing your own is still the point of the field being there: an id
may be any of `A-Z a-z 0-9 . _ -`, up to 64 characters. **Ids already
written into a config are never rewritten**, so one that was handed out
before this — or by hand — stays exactly as it is.

Both are reported by the API — `id` in each entry of `/api/status` and
of `/api/settings` — so a client never has to guess one.

Connections carry an `id` too, in `/api/status`, and that is what
`DELETE /api/ports/<id>/connections/<conn_id>` takes. Counting them
would not work: a client hanging up moves every connection after it.

### Concurrent edits

`GET /api/ports/<id>` and `GET /api/settings` report a `rev` alongside
each entry — a fingerprint of what that entry currently says. In
`/api/settings` that is one for each HTTP server and one at the top
level for the settings themselves (`session_timeout`), which
`PUT /api/settings` takes. Send it
back in the `PUT` and the change is refused with **409** if the entry
was saved by somebody else in between:

```
$ curl -s localhost:8080/api/ports/9f3c1a20
{"id": "9f3c1a20", "name": "my-device", ..., "rev": "bfe02568399c9e7f"}

$ curl -sX PUT -d '{..., "rev": "bfe02568399c9e7f"}' \
      localhost:8080/api/ports/9f3c1a20
{"error": "It has changed since you opened it - reload it and apply your change again"}
```

Re-read the entry, apply the change to what it says now, and save that.
The web UI does this for you: it sends the `rev` it loaded and leaves
your editor open with the message rather than discarding what you typed.

`rev` is optional. A request without one is not checked, so a script
that writes a whole entry without reading it first keeps working. It is
derived from the content rather than stored, so it never appears in
`config.json` and an entry edited by hand is covered as well.

### What a save disconnects

`PUT /api/ports/<id>` takes the whole port, and changes only what is
different:

- **Only servers changed** — a server that is the same as before keeps
  running, and its clients stay connected. A server that was changed,
  added or removed is the only one rebuilt. A new order of the servers
  moves them and nothing else.
- **Nothing changed** — nothing happens; saving the editor without
  touching anything no longer drops anybody.
- **The port itself changed** — serial settings, name, limit, USB
  match — the whole port is rebuilt, and every client on it reconnects.

A server is matched by its configuration, not by an id or a position:
change anything about one, and to the process it is a server removed
and another added.

In the web UI's port editor the servers can be put in another order by
dragging a server by the grip after its delete icon; the new order is
saved with the rest of the form, and moves no client anywhere.

A server added in the editor that is switched to WEBSOCKET is offered
the port's id as its endpoint, with `-1`, `-2` added if that is taken.
It is only a starting value: renaming the port later does not move the
endpoint, which is what links and devices connect to.

### Port order

Ports are listed in the order `config.json` holds them. In the web UI
an admin changes it by dragging a port's card by its name; on a touch
screen, hold the name still for a moment first. A card takes the place
of the one it is dragged onto as soon as the pointer is a little way
into it — or, onto a bigger one, far enough in to be over the card
once it has moved. From a script, put one port before or after another:

```bash
curl -X POST http://localhost:8080/api/ports/esp32/move \
  -H 'Content-Type: application/json' -d '{"before": "rpi"}'
```

It takes exactly one of `before` or `after`, and an id, never a number:
"before rpi" still means what you meant if somebody else has added a
port in the meantime, where "to position 2" would not. If the port you
anchored to has been deleted since, the answer is **409**. Only the
order changes — no port is closed or rebuilt, and clients stay
connected. The answer carries the new order as a list of ids.

HTTP servers are put in order the same way: by dragging their cards
under **Settings**, or with `POST /api/settings/http/<id>/move` and the
same `before` / `after`. None of them stops listening.

### When something will not start

A serial port whose device is missing, or an HTTP server whose address
is already taken, does not stop ser2tcp and does not disappear. It stays
in the list where it was configured and carries an `error` saying why —
in `/api/status` for a port, in `/api/settings` for an HTTP server — and
the web UI shows it as a red card with that reason on it:

```json
{"id": "4b7e0d55", "address": "0.0.0.0", "port": 8080,
 "error": "HTTP 0.0.0.0:8080: failed to bind: Address already in use"}
```

Editing it is how you fix it: a save that succeeds starts it there and
then, with no restart. Like `rev`, `error` is reported rather than
configured and is never written to `config.json`.

## Usage examples

```
ser2tcp -c ser2tcp.conf
```

Direct running from repository:

```
python run.py -c ser2tcp.conf
```

### Connecting using telnet

```
telnet localhost 10002
```

(to exit telnet press `CTRL + ]` and type `quit`)

## Installation as service

### Linux - systemd user service

1. Copy service file:
    ```
    cp ser2tcp.service ~/.config/systemd/user/
    ```
2. Configuration file will be created automatically at `~/.config/ser2tcp/config.json` on first run
3. Reload user systemd services:
    ```
    systemctl --user daemon-reload
    ```
4. Start and enable service:
    ```
    systemctl --user enable --now ser2tcp
    ```
5. To allow user services running after boot you need to enable linger (if this is not configured, then service will start after user login and stop after logout):
    ```
    sudo loginctl enable-linger $USER
    ```

### Linux - systemd system service

1. Create system user:
    ```
    sudo useradd -r -s /usr/sbin/nologin -G dialout ser2tcp
    ```
2. Copy service file:
    ```
    sudo cp ser2tcp-system.service /etc/systemd/system/ser2tcp.service
    ```
3. Create configuration file `/etc/ser2tcp.conf`
4. Reload systemd and start service:
    ```
    sudo systemctl daemon-reload
    sudo systemctl enable --now ser2tcp
    ```

### Useful commands

```bash
# Check status
systemctl --user status ser2tcp

# View logs
journalctl --user-unit ser2tcp -e

# Restart
systemctl --user restart ser2tcp

# Stop
systemctl --user stop ser2tcp
```

For system service, use `sudo systemctl` instead of `systemctl --user`.

## Requirements

- Python 3.8+
- pyserial 3.0+
- uhttp-server 3.0+ (for HTTP/API and WebSocket)

### Running on

- Linux
- macOS
- Windows

## Credits

(c) 2016-2026 by Pavel Revak

### Support

- Basic support is free over GitHub issues.
- Professional support is available over email: [Pavel Revak](mailto:pavel.revak@gmail.com?subject=[GitHub]%20ser2tcp).
