en7523ctl
en7523ctl is a Go utility for maintaining the Airoha EN7523 kernel patch series and running a multi-port UART log collector with an SSH console server.
The project has two independent areas:
- Kernel/OpenWrt migration tools: rebuild, synchronize, and export EN7523 branches as numbered OpenWrt patches.
- Serial daemon: reconnect to USB serial adapters, keep persistent UART logs, split logs at boot boundaries, identify the board from U-Boot, and expose logs or writable consoles over SSH.
The serial daemon does not require Git. Git is only required by the migration commands.
Features
Kernel and OpenWrt migration
- Uses the native
gitexecutable for clone, fetch, switch, reset, apply, commit, format-patch, and push operations. - Applies mailboxes with
git am -3first. - Falls back to
git apply -p1 --3wayand creates a fallback commit when necessary. - Exports branches named
airoha_en7523_*orairoha_an7523_*into the OpenWrt patch directory. - Preserves native
git format-patchoutput, including authorship, trailers, binary diffs, and commit subjects. - Supports the stable and testing kernel versions selected by the Airoha OpenWrt target.
- Handles patch installation across different filesystems. If a direct rename from
/tmpto the OpenWrt tree fails withEXDEV, the exporter copies into a temporary file in the destination directory, syncs it, and performs a local atomic rename.
UART daemon and SSH server
- Supports multiple serial ports.
- Accepts exact device paths or glob patterns such as
/dev/serial/by-id/usb-*_USB_Serial-*. - Automatically reconnects after unplug/replug or serial errors.
- Supports configurable baud rate, data bits, parity, stop bits, timeout, and read-only mode.
- Maintains an in-memory ring buffer for each UART.
- Maintains one per-boot log and one global append-only log for each UART.
- Delays creation of a new log until at least 10 bytes are received or a newline is observed.
- Provides public-key-only SSH authentication.
- Supports interactive PTY sessions, CR/LF handling, echo, Backspace, Ctrl-C, Ctrl-D, and Ctrl-U.
- Allows multiple read-only observers while limiting each UART to one writable SSH console.
Requirements
- Go 1.23 or newer.
- Git in
PATHformigrate,export,export-new, andsync-kernels. - Permission to open the configured TTY devices.
- systemd for the optional user service.
On Debian and Ubuntu, USB serial devices are commonly assigned to dialout. On Arch Linux they may be assigned to uucp. Check the actual device group with:
stat -c '%A %U %G %n' /dev/ttyUSB0
Add the current account to the required group, then log out and back in:
sudo usermod -aG dialout "$USER"
Build
go mod download
go test ./...
go build -trimpath -o en7523ctl ./cmd/en7523ctl
Install the binary for the current user:
install -Dm0755 en7523ctl "$HOME/.local/bin/en7523ctl"
Ensure ~/.local/bin is in PATH when invoking the binary manually.
Commands
en7523ctl [--config file.json] <command>
migrate | main migrate kernel/OpenWrt patch branches
export export main kernel branches to OpenWrt patches
export-new export backport kernel branches to OpenWrt patches
sync-kernels synchronize local EN7523 branches from origin
serve run the serial collector and SSH server
keygen [path] generate an Ed25519 SSH host key
help show command help
Examples:
en7523ctl migrate
en7523ctl export
en7523ctl export-new
en7523ctl sync-kernels
en7523ctl serve
Configuration
The following configuration is suitable for the user service included in this repository. Its paths are relative to the user's home directory because the service sets WorkingDirectory=%h.
Create the directories:
mkdir -p "$HOME/.config/en7523ctl"
mkdir -p "$HOME/.local/state/en7523ctl"
Create ~/.config/en7523ctl/config.json:
{
"migration": {
"root_dir": "/mnt/Data/airoha_en7523",
"kernel_repository": "https://sirherobrine23.com.br/airoha_en7523/kernel.git",
"kernel_repository_upstream": "https://github.com/gregkh/linux.git",
"main_kernel": "linux",
"backport_kernel": "linux_backport",
"openwrt": "openwrt",
"patcher_dir": "tmp_patcher",
"branch_prefix": "airoha_en7523",
"patcher_source": 0,
"ignore_generic": false,
"push": false,
"rewrite_history": false,
"manual_shell": true
},
"daemon": {
"listen": "0.0.0.0:2222",
"host_key": ".config/en7523ctl/ssh_host_ed25519_key",
"authorized_keys": ".ssh/authorized_keys",
"user": "serial",
"log_dir": ".local/state/en7523ctl",
"ring_bytes": 1048576,
"reconnect_delay": "3s",
"ports": [
{
"name": "en7523",
"device": "/dev/serial/by-id/usb-*_USB_Serial-*",
"baud": 115200,
"data_bits": 8,
"parity": "none",
"stop_bits": 1,
"read_only": false,
"reboot_split": true,
"log_file": "{hh}-{mm}_{dd}-{MM}-{yyyy}_en7523_{device}.log",
"global_log_file": "{name}_global.log",
"read_timeout": "500ms"
}
]
}
}
When running the daemon manually with these relative paths, start it from the home directory:
cd "$HOME"
"$HOME/.local/bin/en7523ctl" \
--config "$HOME/.config/en7523ctl/config.json" serve
Migration configuration
| Field | Description |
|---|---|
root_dir |
Parent directory containing the kernel and OpenWrt trees. |
main_kernel |
Main EN7523 kernel repository directory. |
backport_kernel |
Kernel tree used for migration to the selected OpenWrt kernel version. |
openwrt |
OpenWrt source tree directory. |
patcher_dir |
Temporary mailbox directory used during migration. |
branch_prefix |
Base branch prefix, normally airoha_en7523. |
patcher_source |
0 imports commits from kernel branches; 1 rebuilds branches from OpenWrt patches. |
ignore_generic |
Skip generic and target OpenWrt patch application. |
push |
Force-push migrated branches and tags, synchronize, then export patches. |
rewrite_history |
Run the legacy git filter-branch step. Keep disabled unless explicitly required. |
manual_shell |
Open $SHELL with FILE_PATCH set after automatic patch application fails. |
Serial port configuration
| Field | Description |
|---|---|
name |
Stable logical name used by SSH commands and log templates. |
device |
Exact TTY path or glob pattern. Prefer /dev/serial/by-id/*. |
baud |
Serial baud rate. Defaults to 115200. |
data_bits |
Number of data bits. Defaults to 8. |
parity |
none, odd, or even. |
stop_bits |
One or two stop bits. |
read_only |
Disable writable SSH console access for this UART. |
reboot_split |
Split the per-boot log when an EN7523 boot marker is detected. |
log_file |
Template for the current per-boot log. |
global_log_file |
Append-only file containing every received UART byte. |
read_timeout |
Serial read timeout, for example 500ms. |
Log file templates
log_file and global_log_file support these placeholders:
| Placeholder | Value |
|---|---|
{yyyy} |
Four-digit year. |
{yy} |
Two-digit year. |
{MM} |
Month, 01 through 12. |
{dd} |
Day of month. |
{hh} |
Hour in 24-hour format. |
{mm} |
Minute. |
{ss} |
Second. |
{unix} |
Unix timestamp. |
{name} |
Logical port name. |
{device} |
Physical TTY name initially; U-Boot model for finalized per-boot logs. |
{baud} |
Configured baud rate. |
All substituted tokens are sanitized before becoming part of a filename. Slashes, spaces, parentheses, and other unsafe characters are replaced with underscores.
Example template:
"log_file": "{hh}-{mm}_{dd}-{MM}-{yyyy}_en7523_{device}.log"
After detecting:
U-Boot 2026.01-OpenWrt-r35153-918c198643f
Model: MikroTik E60iUGS (hEX S 2025)
it produces a filename similar to:
15-06_29-06-2026_en7523_MikroTik_E60iUGS__hEX_S_2025_.log
Include {ss} or {unix} when multiple boots may occur within the same minute.
Per-boot log splitting
When reboot_split is enabled, the per-boot writer detects a new EN7523 boot at either marker:
Secure key does not exist
EN7523DRAMC
The previous boot log ends immediately before the marker. Bytes starting at the marker are buffered until the daemon observes both:
U-Boot 20...
Model: ...
The daemon then:
- derives
{device}from theModel:value; - opens the final model-based log path;
- writes every buffered byte starting at the boot marker;
- continues appending subsequent UART output to that file.
This preserves the DRAM initialization output and all lines printed before the U-Boot model is known.
If the daemon starts after the boot marker has already passed, it initially writes to a physical-device-based path. When the U-Boot banner and model are detected, it relocates or merges the existing content into the model-based path.
If the process stops before a model is detected, the pending boot is saved under the physical TTY identity rather than discarded.
Global UART log
Every port has an independent global log, normally:
"global_log_file": "{name}_global.log"
The global log:
- contains all UART bytes across every boot;
- is not split on reboot markers;
- is not renamed after U-Boot model detection;
- is opened in append mode;
- survives TTY reconnects and daemon restarts.
The global log is written before the per-boot log processing, providing a continuous raw history even if model detection or per-boot rotation fails.
Lazy log creation
A newly selected log path is not created immediately. The daemon first buffers incoming bytes and materializes the file only when:
- at least 10 bytes have been received; or
- a newline byte (
\n) has been received.
When materialized, the entire buffered prefix is written first. If the UART disconnects or the daemon stops with fewer than 10 bytes and no newline, that fragment is treated as noise and no new file is created.
Existing global logs are reopened in append mode immediately, so a short legitimate fragment is not lost after a daemon restart.
SSH access
Generate an SSH host key from the home directory:
cd "$HOME"
"$HOME/.local/bin/en7523ctl" \
--config "$HOME/.config/en7523ctl/config.json" keygen
The public keys allowed to connect are read from daemon.authorized_keys. The configuration above uses the account's normal ~/.ssh/authorized_keys file.
Connect with the SSH username configured in daemon.user:
ssh -p 2222 serial@localhost
Interactive commands:
help
list
status
tail <name> [bytes]
log <name> [tail-bytes]
global-log <name> [tail-bytes]
all-log <name> [tail-bytes]
attach <name>
console <name>
exit
Command behavior:
listandstatusshow connection state, TTY path, byte count, current per-boot log, global log, and last error.tailreads the in-memory ring buffer.logreads the current per-boot log. Supplying a byte count returns only the end of the file.global-logandall-logread the continuous append-only UART history.attachfollows new UART output without allowing writes. Press Ctrl-C to detach.consoleprovides bidirectional UART access. Press Ctrl-] to detach.consoleis rejected for ports configured withread_only: true.- Only one writable console may be active for a given UART, while multiple read-only observers are allowed.
Commands can also be executed without an interactive shell:
ssh -p 2222 serial@localhost status
ssh -p 2222 serial@localhost 'tail en7523 65536'
ssh -p 2222 serial@localhost 'log en7523 1048576'
ssh -p 2222 serial@localhost 'global-log en7523 1048576'
Authentication is public-key-only. Password and anonymous authentication are not supported.
Running as a systemd user service
The included user unit runs the daemon as the current login account. It does not create a dedicated system user and does not require root during normal operation.
Install the unit and documentation:
mkdir -p "$HOME/.config/systemd/user"
mkdir -p "$HOME/.local/share/doc/en7523ctl"
install -m0644 systemd/en7523ctl-user.service \
"$HOME/.config/systemd/user/en7523ctl.service"
install -m0644 README.md \
"$HOME/.local/share/doc/en7523ctl/README.md"
systemctl --user daemon-reload
systemctl --user enable --now en7523ctl.service
Inspect status and logs:
systemctl --user status en7523ctl.service
journalctl --user -u en7523ctl.service -f
Restart after changing config.json:
systemctl --user restart en7523ctl.service
To keep the user service running after logout and start it during boot, enable lingering once:
sudo loginctl enable-linger "$USER"
Disable it with:
sudo loginctl disable-linger "$USER"
The account must already have permission to open the serial device. A user service cannot bypass TTY ownership or group permissions.
User service hardening
The supplied unit:
- runs from the user's home directory;
- restarts after failures;
- makes the host filesystem read-only through
ProtectSystem=strict; - keeps the home directory read-only except for
~/.local/state/en7523ctl; - does not use
PrivateDevices, because the daemon must access/dev/ttyUSB*and/dev/serial/by-id/*; - permits only Unix, IPv4, and IPv6 sockets;
- prevents privilege escalation.
If your systemd version does not support one of the hardening directives in a user unit, remove only the unsupported directive shown in systemctl --user status and keep the remaining restrictions.
Firewall and exposure
The example listens on 0.0.0.0:2222, which exposes the SSH server on every IPv4 interface. Restrict the firewall to trusted management networks, or bind only to localhost:
"listen": "127.0.0.1:2222"
For remote access through an existing SSH host, bind to localhost and use forwarding:
ssh -L 2222:127.0.0.1:2222 user@uart-host
ssh -p 2222 serial@127.0.0.1
Troubleshooting
Permission denied opening the UART
Check ownership and current group membership:
stat -c '%A %U %G %n' /dev/ttyUSB0
id
After adding the user to dialout or uucp, log out and back in. Restarting only the service does not update the groups of an already running user manager. When necessary, terminate the user manager after logging out or reboot the host.
The SSH port is not listening
systemctl --user status en7523ctl.service
journalctl --user -u en7523ctl.service -n 100 --no-pager
ss -lntp | grep 2222
Verify that the host key exists and the authorized-keys file contains at least one valid public key.
Interactive SSH commands do not submit on Enter
Use the current version of the server. It accepts CR, LF, and CRLF in PTY sessions and performs server-side echo. Older versions that only waited for LF appeared to ignore commands from normal SSH clients.
export-new reports invalid cross-device link
Use the current exporter. It handles EXDEV by copying into the destination filesystem and then performing an atomic local rename. The temporary staging directory and the OpenWrt source tree no longer need to be on the same mount.
No log file is created
This is expected until the UART sends at least 10 bytes or one newline. Check the in-memory state over SSH:
ssh -p 2222 serial@localhost status
ssh -p 2222 serial@localhost 'tail en7523 4096'
Security notes
- Keep
authorized_keysrestricted to trusted administrators. - Prefer
read_only: truewhen remote UART writes are unnecessary. - Bind the SSH server to localhost or a management interface whenever possible.
- Protect the generated host private key with mode
0600. - The writable
consolecommand has the same practical impact as physical access to the UART.