-
-
Notifications
You must be signed in to change notification settings - Fork 0
Developer Guide
Internal API reference for developers who want to understand, modify, or extend the framework. This page documents every public function's signature, parameters, return types, exceptions, and calling conventions across all 21 source modules.
The framework is organized into these function groups:
-
Validation (
validation.py) -- 9 whitelist input validators at trust boundary -
Session Management (
session.py) -- register, read, find, lock, cleanup, migrate -
Command Builders (
commands.py) -- construct socat argument lists -
Process Management (
process.py) -- launch (setsid), 9-step stop, port checks -
Watchdog (
watchdog.py) -- monitor-first auto-restart -
Certificates (
certs.py) -- TLS self-signed cert generation -
Logging (
logging_setup.py) -- structured dual-output logging -
Configuration (
config.py) -- constants, frozen dataclasses, protocol maps -
CLI (
cli.py) -- argparse with 10 subcommands -
Menu (
menu.py) -- interactive TUI with guided input -
Mode Handlers (
modes/*.py) -- one per operational mode
All validation functions raise ValidationError (subclass of ValueError) on invalid input. They never silently correct or sanitize. Called by mode handlers and menu prompts before any command construction.
validate_port(port: str | int) -> intValidate a port number is numeric and within valid range (1-65535).
- port: Port number as string or integer. Returns: Validated port number as integer.
- ValidationError: If port is non-numeric or out of range.
validate_port_range(range_str: str) -> list[int]Validate a port range string and return the list of ports.
- range_str: Port range string (e.g., "8000-8010"). Returns: List of integer port numbers in the range [START, END].
- ValidationError: If format is invalid, ports are invalid,
validate_port_list(list_str: str) -> list[int]Validate a comma/semicolon-separated port list.
- list_str: Comma or semicolon separated port numbers Returns: List of valid integer port numbers.
- ValidationError: If no valid ports are found in the list.
validate_hostname(host: str) -> strValidate a hostname or IP address for use as a network target.
- host: Hostname or IP address string. Returns: The validated hostname string (unchanged).
- ValidationError: If hostname is empty, contains forbidden
is_ipv6_literal(host: str) -> boolReport whether a validated host is an IPv6 literal. Used to select an IPv6 connector for an IPv6 remote target. Hostnames and IPv4 literals return False.
validate_protocol(proto: str) -> strValidate and normalize a protocol string.
- proto: Protocol string to validate. Returns: Normalized protocol string (tcp4, tcp6, udp4, or udp6).
- ValidationError: If protocol is not in the accepted whitelist.
validate_writable_path(path: str) -> strValidate a path that will be written to, such as a capture or log file. The file need not exist, so existence and readability are not required, but a non-empty path, no traversal component, and no forbidden characters are enforced using the shared config.FILEPATH_FORBIDDEN_CHARS set. validate_file_path() builds on this and adds the existence and readability checks for input files, so both validators reject the same character set from one definition.
validate_file_path(path: str) -> strValidate a file path is safe for use.
- path: File path string to validate. Returns: The validated path string (unchanged).
- ValidationError: If path is empty, contains traversal sequences,
validate_socat_opts(opts: str) -> strValidate user-provided socat address options for safety.
- opts: Socat options string to validate. Returns: The validated options string (unchanged).
- ValidationError: If options contain forbidden characters.
validate_session_name(name: str) -> strValidate a user-provided session name for safety.
- name: Session name string to validate. Returns: The validated session name (unchanged).
- ValidationError: If name is empty, too long, or contains
validate_session_id(sid: str) -> strValidate a session ID is a valid 8-character lowercase hex string.
- sid: Session ID string to validate. Returns: The validated session ID (unchanged).
- ValidationError: If session ID is empty or not exactly 8
Session files use KEY=VALUE text format. All functions use exact-key matching (line.split('=', 1)[0] == field) to prevent field confusion attacks (e.g., PID matching LAUNCHER_PID).
generate_session_id() -> strGenerate a unique 8-character hex session ID.
session_lock() -> Generator[None, None, None]Acquire an advisory file lock on the session directory.
session_register(sid: str, name: str, pid: int, pgid: int, mode: str, proto: str, lport: int, socat_cmd: str, rhost: str, rport: str) -> NoneRegister a new socat session by writing a session file.
session_update_process(sid: str, pid: int, pgid: int) -> boolUpdate the tracked process identity of an existing session.
Rewrites the PID and PGID fields of a session file and preserves every other field: session name, mode, protocol, ports, socat command, correlation ID, launcher PID, and the STARTED creation timestamp. STARTED is the session creation time and is not changed by a restart. Returns True when the record was updated, False when the session file does not exist or cannot be rewritten.
The session file is the authoritative record of which process a session owns. Any component that replaces the process behind a session calls this so that session_is_alive() and stop_session() act on the running process rather than a terminated predecessor. The watchdog calls it after every successful restart.
The read-modify-write cycle runs under the advisory session lock. The new record is written to a temporary file with 0o600 permissions and renamed over the original, which is atomic within a directory, so a concurrent reader never observes a partially written session record.
session_unregister(sid: str) -> NoneRemove a session file and all associated signal files.
session_read_field(session_file: Path, field: str) -> strRead a specific field from a session file using exact key match.
session_find_by_name(target_name: str) -> list[str]Find session IDs matching an exact session name.
session_find_by_port(target_port: int | str) -> list[str]Find session IDs matching a local port.
session_find_by_pid(target_pid: int | str) -> list[str]Find session IDs matching a PID.
process_alive(pid_str: str, pgid_str: str) -> boolDecide liveness from a recorded PID and PGID that the caller already holds. Checks the PID first, then the process group. The primary check routes through process_is_running(), so an exited-but-uncollected socat child is reported as dead rather than as a live zombie. The listing and detail views call this with the fields they have already read, so they do not re-open the session file for the alive check.
session_is_alive(sid: str) -> boolCheck if a registered session's process is still running.
session_get_all_ids() -> list[str]List all registered session IDs.
session_count() -> intCount the number of active session files.
session_list() -> boolList all registered sessions with their status.
session_read_all_fields(session_file: Path) -> dict[str, str]Read all fields from a session file in a single pass.
session_detail(sid: str) -> boolDisplay detailed information for a specific session.
migrate_legacy_sessions() -> intMigrate v1 legacy .pid session files to v2.2+ .session format.
session_cleanup_dead() -> intRemove session files for dead processes.
All builders produce list[str] argument lists (never shell strings). They perform no validation -- inputs are assumed sanitized by the trust boundary. Called by mode handlers after validation.
format_socat_host(host: str) -> strFormat a host for inclusion in a socat address.
socat address fields are colon-delimited, so an IPv6 literal is enclosed in square brackets to keep its own colons from being read as field separators. 2001:db8::1 becomes [2001:db8::1]. Hostnames and IPv4 literals contain no colons and pass through unchanged, and an already bracketed literal is returned as-is.
Without the brackets an address such as TCP6:2001:db8::1:443 is ambiguous -- the boundary between address and port cannot be determined -- and socat rejects it.
format_socat_endpoint(host: str, port: int | str) -> strFormat a host and port as a socat endpoint in HOST:PORT form, bracketing IPv6 literals. The port remains the final colon-delimited field: [2001:db8::1]:443, 10.0.0.5:80.
cmd_list_to_string(cmd: list[str]) -> strJoin a command argument list into a single string.
build_socat_listen_cmd(proto: str, port: int, logfile: str, extra_opts: str, capture: bool) -> list[str]Build a socat command for a single-port listener.
build_socat_forward_cmd(listen_proto: str, lport: int, rhost: str, rport: int, remote_proto: str, capture: bool) -> list[str]Build a socat command for bidirectional port forwarding.
build_socat_tunnel_cmd(lport: int, rhost: str, rport: int, cert: str, key: str, capture: bool, remote_proto: str) -> list[str]Build a socat command for an encrypted (OpenSSL) tunnel. The listener is TLS over TCP; the remote leg's transport is TCP and its address family follows remote_proto, so an IPv6 remote target is reached over a bracketed TCP6 connector. The tunnel mode derives this from the remote host, selecting TCP6 for an IPv6 literal and TCP4 otherwise.
Build a socat command for an encrypted (OpenSSL) tunnel.
build_socat_redirect_cmd(proto: str, lport: int, rhost: str, rport: int, capture: bool) -> list[str]Build a socat command for transparent traffic redirection.
When capture=True, builders insert -v into the socat argument list. This tells socat to dump all traffic in hex format to stderr. The launcher redirects stderr to a capture log file.
The most security-critical module. Controls process spawning (setsid + argument lists), termination (9-step protocol-scoped sequence), and port verification.
process_is_running(pid: int) -> boolCheck whether a process is running, treating an exited but uncollected child as dead.
Processes launched by the framework are children of the management process. An exited child stays in the process table as a zombie until its exit status is collected, and a zombie still answers signal 0 -- so a check written against signal 0 alone would report a dead child as alive. For a child, the retained Popen handle is authoritative: polling it collects a terminated child and reports the truth. For any other process, signal 0 is used and qualified by a zombie check.
reap_child(pid: int) -> int | NoneCollect the exit status of a terminated child and drop its handle, which removes the process table entry. Returns the exit status, or None if the PID is not a child of this process or is still running. A child that is still running is left alone.
register_child(process: subprocess.Popen) -> NoneRecord a child process handle so its exit status can be collected when it terminates. Called by launch_socat_session() and by the watchdog on every replacement launch.
launch_socat_session(name: str, mode: str, proto: str, lport: int, cmd: list[str], rhost: str, rport: str, stderr_redirect: str) -> tuple[str, int]Launch a socat process with session tracking and process isolation.
check_port_available(port: int, proto: str) -> boolCheck if a port is available for binding on a specific protocol.
The query is scoped to both the transport and the address family of the supplied protocol: a tcp4 check lists TCP listeners in the IPv4 family only. A tcp6 listener therefore never masks an available tcp4 port, and a UDP listener never masks an available TCP port. The two dimensions together are what make dual-stack operation correct.
check_port_freed(port: int, proto: str, retries: int) -> boolVerify that a port has been released after stopping a session.
Retries with a delay to account for TIME_WAIT. Carries the same transport and address family scope as check_port_available(), so a listener of the other family on the same port number does not keep the port reporting as in use.
kill_by_port(port: int, proto: str) -> NoneLast-resort function to kill socat processes on a specific port.
Enumeration is scoped to one transport and one address family, and every candidate PID is verified as socat through /proc/{pid}/comm before a signal is sent. Both scopes are required. A tcp4 listener and a tcp6 listener are independent sockets that can hold the same port number at the same time, so a query that named only the transport would enumerate the other family's socat process and terminate it -- stopping one session would take down an unrelated one.
stop_session(sid: str) -> boolExecute the 9-step stop sequence for a session.
Monitor-first auto-restart. The watchdog receives the PID of an already-running process and monitors it. It never launches the initial process -- only re-launches after confirmed death.
watchdog_loop(session_id: str, session_name: str, cmd: list[str], initial_pid: int, max_restarts: int, backoff_initial: int, stderr_redirect: str) -> NoneBackground watchdog that monitors a process and auto-restarts on crash.
After each successful restart the watchdog calls session_update_process() with the replacement PID and PGID before it begins monitoring the new process. Under setsid the replacement is a session leader, so its PGID equals its PID. This keeps the session record aligned with the process that currently owns the port, which is what liveness reporting and the stop sequence depend on. A failed replacement launch leaves the record untouched and ends the watchdog.
start_watchdog(session_id: str, session_name: str, cmd: list[str], initial_pid: int, max_restarts: int, backoff_initial: int, stderr_redirect: str) -> threading.ThreadStart a watchdog monitor as a daemon thread.
generate_self_signed_cert(cn: str) -> tuple[str, str]Generate a self-signed certificate and private key for TLS tunnels.
To add a new operational mode (e.g., proxy):
- Create
src/socat_manager/modes/proxy.pywithmode_proxy(args: Namespace) -> None - Follow the pattern: validate inputs → check port → build command → launch → watchdog → dual-stack
- Add command builder to
commands.pyif the socat syntax differs from existing builders - Add subparser to
cli.pyinbuild_parser()with help text and examples - Add dispatch case to
__main__.pyindispatch_mode() - Add menu submenu to
menu.py - Add unit tests (test_cli parser, test_commands builder) and integration tests (test_mode_handlers)
- Update documentation: README modes section, USAGE_GUIDE mode reference, wiki Usage-Guide
- Type hints: All function signatures fully typed
- Docstrings: Google-style with Args, Returns, Raises sections
-
Imports:
from __future__ import annotationsat top of every module -
Constants: UPPER_SNAKE_CASE, defined in
config.py,Final[T]type hints -
Dataclasses:
@dataclass(frozen=True, slots=True)for all configuration classes - Line length: 120 characters (E501 ignored in ruff)
-
Linting:
ruff check --select E,W,F,I
menu.py (948 lines, 28 functions) provides the full interactive TUI.
-
interactive_menu()-- main loop with banner and 10-option menu -
_menu_listen()through_menu_redirect()-- per-mode submenus -
_collect_common_flags()-- shared protocol, dual-stack, capture, watchdog, name -
_confirm_and_execute()-- displays command, confirms, runs, catches SystemExit -
_offer_paired_forward()-- paired forward prompt after listener -
_prompt_port(),_prompt_host(), etc. -- validated input prompts
Every prompt function raises _MenuCancel on q, quit, cancel, back, exit, or Ctrl+C/Ctrl+D. Submenu functions catch _MenuCancel to return to the root menu. The main loop catches KeyboardInterrupt for graceful exit.
| Exception | Raised By | Meaning |
|---|---|---|
ValidationError |
All validators | Invalid user input |
RuntimeError |
launch_socat_session() |
Launch failed (port busy, socat error) |
OSError |
os.kill(), os.killpg()
|
Process/signal failure |
SystemExit |
Mode handlers (sys.exit(1)) |
Fatal validation or launch error |
KeyboardInterrupt |
User Ctrl+C | Interrupt signal |
_MenuCancel |
Menu prompt functions | User cancelled input |
Socat Network Operations Manager · Repository · MIT License
Socat Network Operations Manager v1.0.2 | Python 3.12+ | Source Repository | MIT License | Zero External Dependencies
Getting Started
Operations
Architecture
Development
Security
Reference