Simple proxy for connecting over TCP, TELNET, TLS, WebSocket or Unix socket to serial port
https://github.com/cortexm/ser2tcp
- 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
telnetas 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)
pip install ser2tcp
or from source:
pip install .
pip uninstall ser2tcp
-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.
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:
[Definition]
failregex = ^W: 401 POST /api/login from <HOST>: Login failedStopping 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.
{
"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 structure pass all parameters to serial.Serial constructor from pyserial library, this allows full control of the serial port.
Instead of specifying port directly, you can use match to find device by USB attributes:
{
"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)
baudrateis optional (default 9600, CDC devices ignore it)
| 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 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 |
{
"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.
datais the older spelling and still works:"data": falsemeans"access": "none","data": truemeans"access": "rw". Giving both and having them disagree is refused rather than resolved silently.
You can also limit total connections across all servers on a port:
{
"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 connections go through the HTTP server — no separate listening port needed:
{
"protocol": "websocket",
"endpoint": "my-device",
"control": {
"rts": true,
"signals": ["rts", "dtr", "cts", "dsr"]
}
}- Accessible at
ws://host:port/ws/my-device(orwss://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.
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
accessallows. 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
Refererheader. 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/denyandmax_connectionson 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.
For socket protocol, address is the path to the Unix domain socket:
{
"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
For tls protocol, reference a certificate bundle managed by the
Certificate Manager (see below).
{
"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 calledssl; they aretlsnow, 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.
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:
{
"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.pemfirst; 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
tlsservers 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.
Restrict client connections by IP address using allow and/or deny lists:
{
"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.
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:
{
"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:
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.
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 file0600 - PEM format validated on upload: every block in the file must belong
there, not just the first.
cert.pemandca.pemhold 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.pemtakes a key, optionally preceded by theEC PARAMETERSblockopenssl ecparam -genkeywrites cert.pemandkey.pemare checked against each other on upload — a key that belongs to a different certificate is rejected instead of failing later at the TLS handshakekey.pemis 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.pemandca.pemare 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 a0644file - Bundles are referenced from the
tlsconfig 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.
A running server holds the certificate it loaded at startup, so replacing the files on disk is only half the job:
-
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:
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 forca.pem, or for the first half of an empty bundle.) -
Tell the running servers to pick it up:
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.pemin place,key.pemstill 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.pemtakes 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):
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.pemNote: 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:
#!/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"Generate CA and server certificate for testing:
# 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.csrFor mTLS (mutual TLS with client certificates):
# 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.csrTesting a TLS connection with openssl s_client:
# 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 -30After connecting, s_client gives you a bidirectional stdin/stdout tunnel
to the serial port. A few quirks worth knowing:
-
Ctrl-C kills
s_clientitself — 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:stty -isig -icanon -echo openssl s_client -connect localhost:10003 -quiet stty sane # restore terminal afterwardsIn raw mode press
Ctrl-D(EOF) to exit. -
-quietsuppresses 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) orncat --ssl(brew install nmap) are more transparent — no built-in command interception, raw mode by default.
Optional HTTP server for monitoring and management:
{
"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):
{
"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 optionaladminflag and per-usersession_timeouttokens: 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 with409without 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
adminapplies 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_timeoutapplies 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:
ser2tcp --hash-password mysecretpasswordA 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:
{
"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:
{
"http": [{
"address": "0.0.0.0",
"port": 8080,
"allow": ["192.168.0.0/16"],
"deny": ["192.168.1.100"]
}]
}| 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.
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:
{
"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.
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.
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.
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:
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.
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:
{"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.
ser2tcp -c ser2tcp.conf
Direct running from repository:
python run.py -c ser2tcp.conf
telnet localhost 10002
(to exit telnet press CTRL + ] and type quit)
- Copy service file:
cp ser2tcp.service ~/.config/systemd/user/ - Configuration file will be created automatically at
~/.config/ser2tcp/config.jsonon first run - Reload user systemd services:
systemctl --user daemon-reload - Start and enable service:
systemctl --user enable --now ser2tcp - 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
- Create system user:
sudo useradd -r -s /usr/sbin/nologin -G dialout ser2tcp - Copy service file:
sudo cp ser2tcp-system.service /etc/systemd/system/ser2tcp.service - Create configuration file
/etc/ser2tcp.conf - Reload systemd and start service:
sudo systemctl daemon-reload sudo systemctl enable --now ser2tcp
# Check status
systemctl --user status ser2tcp
# View logs
journalctl --user-unit ser2tcp -e
# Restart
systemctl --user restart ser2tcp
# Stop
systemctl --user stop ser2tcpFor system service, use sudo systemctl instead of systemctl --user.
- Python 3.8+
- pyserial 3.0+
- uhttp-server 3.0+ (for HTTP/API and WebSocket)
- Linux
- macOS
- Windows
(c) 2016-2026 by Pavel Revak
- Basic support is free over GitHub issues.
- Professional support is available over email: Pavel Revak.