Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

SwarmOtter User Guide

SwarmOtter logo

SwarmOtter is a performance-first Rust BitTorrent daemon with a practical Web UI, a complete API, and fail-closed VPN/NIC traffic containment.

This guide is for people running SwarmOtter. Architecture, requirements, ADRs, and contributor-facing design records remain in the repository design/ directory.

What SwarmOtter provides

  • A daemon process, swarmotterd.
  • A Web UI served by the daemon.
  • A REST API under /api/v1.
  • .torrent, magnet, tracker, DHT, PEX, TCP, UDP tracker, and uTP support.
  • IPv4 and IPv6 torrent networking when enabled by configuration.
  • Strict data-plane containment through an interface, source address, or network namespace.

Important operating model

SwarmOtter separates the control plane from the torrent data plane.

  • The control plane is the API and Web UI listener configured by api.bind_address.
  • The torrent data plane is peer, tracker, DHT, PEX, webseed, magnet metadata, and torrent-related DNS traffic.

Network containment applies to the torrent data plane. Binding the Web UI to a LAN address does not allow torrent traffic to use that LAN path unless the torrent network configuration explicitly allows and enforces it.

Start here

Use Getting Started for a local run, then read Configuration for the common br0, VPN, and container configurations. Use API Reference for scripting and integration work.

Getting Started

Build

git clone https://github.com/sphildreth/swarmotter.git
cd swarmotter
cargo build --release

The daemon binary is:

./target/release/swarmotterd

Upgrading from 1.x to v2.0.0

v2.0.0 changes an omitted [network] table from implicit disabled containment to strict containment without a configured path, which fails startup validation. Before upgrading an existing installation, configure the strict interface, source address, or namespace that torrent traffic must use. Set mode = "disabled" explicitly only for local development or when a separate boundary such as the supplied Gluetun shared namespace provides fail-closed containment. Validate the migrated file with swarmotterd --check-config --config PATH before restarting the service.

Create a config file

Create directories for downloads and incomplete data:

mkdir -p ~/.config/swarmotter
mkdir -p ~/Downloads/swarmotter/downloads ~/Downloads/swarmotter/incomplete

Minimal local-only configuration. SwarmOtter’s default containment posture is strict, which requires an explicit network path so torrent traffic can never silently fall back to the default route. For a local development run you must either bind to a specific interface/source or explicitly acknowledge disabled containment.

Strict configuration bound to a specific interface (recommended for any real torrent traffic):

[api]
bind_address = "127.0.0.1:9091"

[storage]
download_dir = "/home/YOU/Downloads/swarmotter/downloads"
incomplete_dir = "/home/YOU/Downloads/swarmotter/incomplete"

[network]
mode = "strict"
required_interface = "tun0"
required_source_ipv4 = "10.8.0.2"
allow_ipv6 = false
fail_closed = true

[torrent]
listen_port = 51413
allow_ipv6 = true
utp_enabled = true
utp_prefer_tcp = true
encryption_mode = "preferred"

Warning: [network] mode = "disabled" is available only for local development or a separately enforced boundary such as the supplied Gluetun shared-network-namespace deployment. It must never be inferred from a missing file/table, platform, bind failure, or unavailable interface. See ADR-0051.

For a quick loopback-only test with no torrent traffic containment, you may set mode = "disabled" explicitly. An omitted [network] table no longer selects disabled mode: it produces strict mode without a path and fails startup with invalid_config.

With this layout, active downloads write partial data under incomplete. Completed torrents move to downloads only after all pieces verify.

Save it as:

~/.config/swarmotter/config.toml

Then start:

./target/release/swarmotterd --config ~/.config/swarmotter/config.toml

Open:

http://127.0.0.1:9091/

Add content

Use the Web UI to add a magnet link, choose a .torrent file, or drag a .torrent file anywhere onto the app window. The same operation is available through the API:

curl -X POST http://127.0.0.1:9091/api/v1/torrents/file \
  --data-binary @example.torrent \
  -H 'Content-Type: application/x-bittorrent'

LAN access

To reach the Web UI from another machine on your LAN, bind the control plane to all IPv4 addresses. Authentication is strongly recommended:

[api]
bind_address = "0.0.0.0:9091"
require_auth = true
auth_token = "replace-with-a-long-random-token"

On a network that is deliberately the control-plane trust boundary, set require_auth = false and omit auth_token. The Web UI then works without a token prompt, but every client that can reach the listener can control SwarmOtter.

API clients can authenticate with either:

Authorization: Bearer <token>

or:

X-SwarmOtter-Auth: <token>

Optional Transmission-compatible endpoint

SwarmOtter can expose an optional compatibility endpoint at /transmission/rpc for existing Transmission-style clients and scripts when compatibility.transmission.enabled = true.

The endpoint accepts header-only GET session negotiation used by clients such as Prowlarr; RPC methods continue to use POST.

[compatibility.transmission]
enabled = true

Auth mapping uses the same API token flow as the native API:

  • Authorization and X-SwarmOtter-Auth are accepted by the daemon.
  • If a client uses HTTP Basic auth, the username is ignored and the password must equal api.auth_token.

Prowlarr 2.3.x

SwarmOtter’s Transmission adapter has been successfully interoperability-tested with Prowlarr 2.3.x. Configure Prowlarr’s Transmission download client with:

  • URL base: /transmission/
  • Host and port: the SwarmOtter control-plane listener (port 9091 by default)
  • SSL: enable only when the listener or its reverse proxy serves HTTPS
  • Username: any nonempty value when authentication is required
  • Password: the configured api.auth_token

The validated flow covers Basic authentication, Transmission session negotiation, the client-version check, and listing existing torrents.

The adapter supports torrent-add for:

  • magnet links via filename
  • base64-encoded .torrent metadata via metainfo

It also supports common Transmission session, torrent lifecycle, queue, and helper calls. Mutating calls map to native SwarmOtter operations; for example, torrent-remove with delete-local-data / delete_local_data can delete payload data.

Remote HTTP/HTTPS torrent URL fetching is not supported through this endpoint.

Optional qBittorrent-compatible endpoint

SwarmOtter can also expose an optional qBittorrent-compatible endpoint at /api/v2 when enabled:

[compatibility.qbittorrent]
enabled = true

Use the same API auth token to protect the endpoint as you do for native API:

[api]
require_auth = true
auth_token = "replace-with-a-long-random-token"

Authentication is supported through:

  • Bearer token via Authorization: Bearer <token> (and X-SwarmOtter-Auth).
  • qBittorrent-style SID cookie flow:
curl -i -X POST \
  http://127.0.0.1:9091/api/v2/auth/login \
  --data "username=swarmotter&password=replace-with-a-long-random-token"

Use the returned SID cookie for subsequent /api/v2 requests.

For automation, the shim currently documents and supports:

  • GET /api/v2/app/version
  • GET /api/v2/app/webapiVersion
  • GET /api/v2/torrents/info
  • POST /api/v2/torrents/add
  • POST /api/v2/torrents/delete
  • POST /api/v2/torrents/pause
  • POST /api/v2/torrents/resume
  • POST /api/v2/torrents/start
  • POST /api/v2/torrents/stop
  • POST /api/v2/torrents/setCategory

The shim is opt-in by design, keeps the native API as the source of truth, and does not expose indexer/search/discovery compatibility endpoints.

Configuration

SwarmOtter uses a TOML configuration file plus optional environment variable overrides. The daemon validates configuration at startup and refuses invalid settings.

Most sections can be omitted entirely. When a section is present with only a few fields, unspecified fields use their documented defaults.

v2.0.0 containment migration

In 1.x, omitting [network] could select disabled containment. In v2.0.0, omission selects strict mode without an enforceable path and validation fails. Existing installations must configure the intended interface, source address, or namespace, or explicitly set mode = "disabled" only when local development or a separately enforced boundary supplies containment. Check the migrated file with swarmotterd --check-config --config PATH before replacing a running daemon.

Environment overrides

Environment variables use the SWARMOTTER_ prefix. Nested fields are separated with double underscores:

SWARMOTTER_API__BIND_ADDRESS=0.0.0.0:9091
SWARMOTTER_API__REQUIRE_AUTH=true
SWARMOTTER_API__AUTH_TOKEN=replace-with-a-long-random-token
SWARMOTTER_AUTOPILOT__MODE=act
SWARMOTTER_NETWORK__MODE=strict
SWARMOTTER_NETWORK__REQUIRED_INTERFACE=br0
SWARMOTTER_NETWORK__SOCKS5__ENABLED=true
SWARMOTTER_NETWORK__SOCKS5__HOST=proxy.example
# SOCKS5 support is TCP-only, so its required UDP features must be disabled.
SWARMOTTER_TORRENT__UTP_ENABLED=false
SWARMOTTER_DHT__ENABLED=false
SWARMOTTER_TORRENT__LISTEN_PORT=51413
SWARMOTTER_TORRENT__ENCRYPTION_MODE=preferred
SWARMOTTER_COMPATIBILITY__QBITTORRENT__ENABLED=true
SWARMOTTER_COMPATIBILITY__TRANSMISSION__ENABLED=true
SWARMOTTER_STORAGE__RESUME_DIR=/srv/swarmotter/resume
SWARMOTTER_STORAGE__STATE_DIR=/srv/swarmotter/state

Runtime configuration editing

SwarmOtter exposes two update modes:

  • PATCH /api/v1/settings updates live-safe fields (bandwidth, queue, and seeding policy).
  • PUT /api/v1/settings replaces the full config after validation and persists it atomically. The existing api.auth_token is preserved when omitted from the request body. A redacted network.socks5.password is likewise preserved when the SOCKS5 username is unchanged; clearing or changing that username requires a complete new credential pair.

The PUT /api/v1/settings response reports which fields were applied live, which fields require restart, and whether the write was persisted. Supported package and Compose deployments provide a private writable config directory. If persistence is unavailable, the Web UI can fall back to PATCH for only bandwidth, queue, seeding, and autopilot settings.

Network containment, peer listen port, IP-family policy, uTP policy, peer encryption mode, and DHT changes are applied live by stopping the complete old data-plane task set and rebuilding eligible tasks with fresh binders. API listener/body-limit and logging destination changes are reported as requiring a process restart.

Changing a global storage root is rejected when an existing torrent still depends on the old root. Assign explicit locations with move-data before changing storage.download_dir; complete or remove incomplete payloads before changing storage.incomplete_dir. This prevents a settings update from making existing payload data appear missing.

Unknown top-level or nested TOML fields are rejected. This prevents a misspelled containment or security setting from silently falling back to a default.

Durable daemon state

Torrent records, queue order, labels, file choices, per-torrent controls, canonical metainfo, and library-operation indexes are stored separately from configuration in a versioned local SQLite state store. Select its path with --state-file PATH or SWARMOTTER_STATE_FILE=PATH. Those explicit choices take precedence over storage.state_dir; when that setting is present, the daemon uses storage.state_dir/state.json on its next start. The legacy file name is retained for path compatibility even after its contents migrate to SQLite.

Without an explicit path, the daemon uses the first available location:

  1. The systemd STATE_DIRECTORY, as state.json.
  2. /var/lib/swarmotter/state.json when that directory exists.
  3. $XDG_STATE_HOME/swarmotter/state.json.
  4. $HOME/.local/state/swarmotter/state.json.
  5. ./swarmotter-state.json.

Existing version-one JSON state is read for upgrade compatibility and migrates in place on its first successful save. The migration first writes and checkpoints a complete temporary SQLite database, then atomically replaces the legacy file. SQLite writes use a durable local transaction and checkpoint before a rollback snapshot; the database file is mode 0600 on Unix. Corrupt or unsupported state stops startup with an explicit error instead of presenting an empty library. SwarmOtter does not rebuild a damaged library database from payload or fast-resume files because those sources are not authoritative for queue and control-plane state; restore a valid state backup or re-add the torrents intentionally. Restored completed torrents are rechecked before seeding.

The SQLite store retains indexed library/queue state, canonical metainfo, byte-exact original locally supplied .torrent files, bounded audit history, and rolling metric history. It uses full 40-character v1/hybrid-primary or 64-character pure-v2 locators; a v2 peer-wire truncation is never persisted as a library key. To rebuild only verified indexes and projections after an interrupted projection update, stop the daemon and run:

swarmotterd --state-file /path/to/state.json --rebuild-state-projections

This offline command validates the existing supported SQLite database first. It refuses missing, legacy, corrupt, or unsupported files; it never creates a new state file, migrates JSON, reconstructs payload state, or repairs a corrupt database. The command deliberately does not load --config or consult storage.state_dir: it resolves only --state-file (including SWARMOTTER_STATE_FILE) or the platform compatibility default, so it remains usable when the normal configuration is invalid or its strict network path is unavailable.

Common configuration: bind torrents to br0

Use this when the interface name is stable but addresses are assigned by DHCP, SLAAC, or router advertisements.

[api]
bind_address = "0.0.0.0:9091"
require_auth = true
auth_token = "replace-with-a-long-random-token"

[storage]
download_dir = "/mnt/incoming/swarmotter/downloads"
incomplete_dir = "/mnt/incoming/swarmotter/incomplete"
# resume_dir = "/var/lib/swarmotter/resume"
# state_dir = "/var/lib/swarmotter/state"
# temp_dir = "/var/cache/swarmotter"

[network]
required_interface = "br0"
allow_ipv6 = true
fail_closed = true
validate_route = true
validate_dns = true

[torrent]
listen_port = 51413
allow_ipv6 = true
utp_enabled = true
utp_prefer_tcp = true
encryption_mode = "preferred"

If a [network] table contains required_interface but omits mode, SwarmOtter treats it as strict containment. Setting mode = "strict" explicitly is also valid.

On Linux, this binds torrent data-plane sockets to the named interface using SO_BINDTODEVICE. The kernel may choose the current IPv4 or IPv6 source address from that interface, so address changes do not break the configuration.

On Linux, SwarmOtter validates DNS for this interface mode before resolving torrent hostnames. The common systemd-resolved setup is accepted when resolvectl dns br0 reports link DNS servers. Static nameservers in /etc/resolv.conf are accepted when their routes go through br0. If DNS cannot be proven constrained to the configured path, hostname tracker and DHT bootstrap resolution fails closed instead of using an unconstrained resolver.

Static source address containment

Use source addresses when the address is stable and should be enforced.

[network]
mode = "strict"
required_interface = "tun0"
required_source_ipv4 = "10.8.0.2"
allow_ipv6 = false
fail_closed = true
validate_route = true
validate_dns = true

For dual-stack static containment:

[network]
mode = "strict"
required_interface = "tun0"
required_source_ipv4 = "10.8.0.2"
required_source_ipv6 = "fd00:8::2"
allow_ipv6 = true
fail_closed = true
validate_route = true
validate_dns = true

[torrent]
allow_ipv6 = true

If required_source_ipv6 is set, network.allow_ipv6 must be true.

Network namespace containment

Use a network namespace when the daemon should run inside a prebuilt contained network stack, such as a VPN namespace.

[network]
mode = "strict"
required_network_namespace = "vpn"
allow_ipv6 = true
fail_closed = true
validate_route = true
validate_dns = false

The process must actually be running in the required namespace. If it is not, network health reports network_namespace_unavailable.

Throughput-oriented defaults

The default torrent data-plane settings favor throughput while preserving fail-closed behavior when strict containment is configured:

  • torrent.utp_enabled = true
  • torrent.utp_prefer_tcp = true
  • torrent.allow_ipv6 = true
  • network.allow_ipv6 = true
  • torrent.encryption_mode = "preferred"
  • dht.enabled = true
  • pex.enabled = true
  • Bandwidth limits default to 0, meaning unlimited.
  • Peer limits default to 0, meaning unlimited where the specific limit uses that convention.

Use bandwidth and queue limits when the host needs resource caps. Leaving them unlimited or high is better for raw transfer throughput.

Adaptive autopilot controls

The adaptive swarm performance autopilot is configurable and can be staged safely:

  • Global behavior is controlled by [autopilot].mode, defaulting to act.
  • mode is one of disabled, observe, or act.
  • In observe mode, SwarmOtter reports slowdown causes without applying tuning actions.
  • In act mode, SwarmOtter can apply bounded daemon/engine actions such as discovery refresh, peer-worker adjustment, peer-backoff relaxation, and queue-slot release.
  • Queue-slot release is prioritized for active torrents with no recent block progress so queued torrents are not blocked behind stalled work.
  • Unfinished engine exits and retryable metadata-discovery exits return to the queue with bounded retry backoff, which prevents stale active-looking records from occupying download slots.
  • Queue reconciliation also recovers active records that no longer have a running engine task, returning them to the queue behind waiting work.
  • Per-torrent control is an override through API/UI.
  • Recommendations are constrained by existing hard caps and never ignore bandwidth, queue, or containment limits.

Example:

[autopilot]
mode = "act"  # optional; defaults to act

Option reference

[api]

OptionDefaultMeaning
bind_address"127.0.0.1:9091"Address for the Web UI and API control plane.
require_authfalseRequires API/Web UI token auth. Strongly recommended for non-loopback listeners.
auth_tokenunsetRequired when require_auth = true.
max_request_body_bytes16777216Maximum API request body size, including .torrent uploads.

Chrome Manifest V3 extension service workers are deliberate cross-origin API clients. Extension access requires require_auth = true and a valid configured token sent as Authorization: Bearer <token> or X-SwarmOtter-Auth: <token>. An extension Origin is rejected when auth is disabled, even if auth_token remains populated. No extension ID allowlist is inferred from configuration; the token is mandatory, and ordinary foreign HTTP(S) browser Origins remain forbidden even when they send it. The extension manifest must separately grant host permission for the exact SwarmOtter HTTP and/or HTTPS API origin.

[compatibility.qbittorrent]

OptionDefaultMeaning
enabledfalseEnable the optional qBittorrent-compatible compatibility endpoint at /api/v2.

When enabled, /api/v2 is an optional compatibility adapter over native SwarmOtter operations and does not add any separate torrent data-plane pathways.

Authentication follows api.require_auth:

  • If auth is required, Authorization: Bearer <token> and X-SwarmOtter-Auth: <token> are accepted.
  • For qBittorrent-style cookie sessions, POST to /api/v2/auth/login with credentials where password matches api.auth_token; the response sets a SID cookie that can be reused for subsequent /api/v2 requests.

Represented compatibility endpoints used by automation include:

  • GET /api/v2/app/version
  • GET /api/v2/app/webapiVersion
  • GET /api/v2/torrents/info
  • POST /api/v2/torrents/add
  • POST /api/v2/torrents/delete
  • POST /api/v2/torrents/pause
  • POST /api/v2/torrents/resume
  • POST /api/v2/torrents/start
  • POST /api/v2/torrents/stop
  • POST /api/v2/torrents/setCategory

The adapter is intentionally limited: no indexer/search/discovery endpoints are exposed through the compatibility surface.

[compatibility.transmission]

OptionDefaultMeaning
enabledfalseEnable the optional Transmission RPC compatibility endpoint at /transmission/rpc.

When enabled, SwarmOtter maps compatible requests to existing daemon operations. Auth mapping follows api.require_auth: when auth is required, Transmission Basic auth password must match api.auth_token; username is not security- significant.

The adapter supports common Transmission session, torrent lifecycle, queue, and helper calls, including mutating calls such as torrent-remove, torrent-set, torrent-set-location, and torrent-rename-path. torrent-remove maps delete-local-data / delete_local_data to SwarmOtter’s native delete-data behavior, so clients using that flag can delete payload data.

torrent-add accepts only:

  • magnet links (filename)
  • base64-encoded metainfo (metainfo)

Remote HTTP/HTTPS URLs for torrent metadata are rejected by this adapter.

[storage]

OptionDefaultMeaning
download_dirunsetFinal directory for verified completed downloads.
incomplete_dirunsetActive write directory for incomplete downloads.
resume_dirunsetDedicated durable fast-resume directory. Resume filenames use the full canonical torrent locator; unset preserves adjacent active-data placement.
state_dirunsetDefault directory for the durable state store (the compatible default filename is state.json) when no --state-file or SWARMOTTER_STATE_FILE is supplied; changing it through Settings requires restart.
temp_dirunsetRoot for the fallback swarmotter-downloads payload layout when download_dir is unset. It does not relocate atomic state/resume temporary files.
preallocatefalsePre-size files before downloading.
sparsetrueWhen false, active payload files are sized up front even if preallocate = false.
cow_strategyconservativeconservative preserves filesystem defaults. disable_for_new_files requests NOCOW for newly created Linux Btrfs payload files, never modifies existing files, and fails a write explicitly when the root or existing file cannot satisfy that policy.
minimum_free_space_bytes0If > 0, reject new adds when target-root usable space falls below this number of bytes.
minimum_free_space_percent0If > 0, reject new adds when free space on the target root falls below this percent of total root size.

The minimum reserve fields apply to add/start-time preflight and are checked before payload data is written. Both fields are optional; when both are set, the preflight uses the stricter reserve.

When incomplete_dir is set, SwarmOtter writes partial pieces there while the torrent is downloading. resume_dir, when configured, stores fast-resume metadata separately under a full canonical torrent-locator filename; configuring it never moves payload data. After every piece is verified, the daemon moves torrent data into download_dir and removes its fast-resume metadata so the completed directory contains only user payload files. If incomplete_dir is unset, the active and final directory are both download_dir. With preallocate = false and sparse = true, active single-file torrents still create a zero-length placeholder in incomplete_dir when the engine starts; the file is not sized to the full payload until data is written. With sparse = false, active payload files are sized up front.

temp_dir only controls the fallback payload root used when download_dir is unset. It is not a general replacement for atomic temporary files: daemon state and resume writes use a temporary sibling beside their final file, flush it, rename it atomically, and sync the target directory. This deliberately avoids a cross-filesystem rename weakening crash recovery.

CoW, preallocation, and sparse files

The default cow_strategy = "conservative" never changes filesystem flags. Use it unless you have evaluated the filesystem and workload trade-offs. cow_strategy = "disable_for_new_files" is an explicit Linux Btrfs-only choice: before a newly created payload file is sized or written, SwarmOtter requests the NOCOW inode flag. It does not touch existing files; an existing file that lacks NOCOW is rejected for further writes rather than being changed or silently accepted under a different strategy. The daemon also fails the start rather than silently applying a different strategy if Btrfs, the required capability, or the flag operation is unavailable.

On Btrfs, NOCOW changes filesystem-level behavior: data checksumming, compression, snapshots, and fragmentation characteristics can differ. It does not replace SwarmOtter piece-hash verification or forced rechecks. preallocate and sparse remain independent choices: preallocation reserves/sizes files up front, while sparse layout delays allocation until payload writes. Choose them based on capacity and fragmentation needs, not as an integrity setting.

Per-root storage controls

Use repeatable [[storage.root_controls]] entries to give different local storage roots independent admission, write-pressure, and recheck budgets:

[[storage.root_controls]]
path = "/srv/torrents/hdd"
max_active_downloads = 2
max_active_bytes = 107374182400
max_write_bytes_per_second = 52428800
max_concurrent_rechecks = 1
OptionDefaultMeaning
pathrequiredLexical root path. A control applies to this path and descendants.
max_active_downloads0Maximum active torrent engines on the root; 0 is unlimited.
max_active_bytes0Maximum sum of admitted declared payload bytes on the root; 0 is unlimited.
max_write_bytes_per_second0Shared sustained rate for verified local payload writes; 0 is unlimited.
max_concurrent_rechecks0Maximum full rechecks running on the root; 0 is unlimited.

Nested controls are allowed; the most-specific lexical path wins. Duplicate normalized paths are rejected. The active write directory determines the matching control, so an incomplete_dir below a root shares its budget with other descendants. Active-engine count and declared payload bytes are reserved before an engine starts; a saturated root leaves eligible work queued until capacity becomes available. A magnet reservation is updated after its metadata is verified. max_active_bytes is a scheduling budget, not a replacement for the free-space reserve preflight.

The write ceiling delays verified local payload writes without dropping or modifying data. Full rechecks wait for a root slot and release it even if their request is cancelled. Existing work may finish safely during a configuration replacement; subsequent admissions use the replacement controls.

[profiles]

Named policy profiles keep category-style defaults consistent without copying queue, seeding, or bandwidth values into every torrent. A profile can supply storage paths, queue priority, initial start behavior, ratio/idle defaults, seed-forever, per-torrent transfer caps, an optional peer-wire encryption_mode, and intake-time file-selection/content-organization rules. Labels map case-insensitively to profiles; when more than one mapped label is present, the normalized label name makes the selection deterministic.

[profiles.labels]
linux = "linux-release"

[profiles.profiles.linux-release]
encryption_mode = "required" # disabled, preferred, or required

[profiles.profiles.linux-release.storage]
download_dir = "/srv/releases/linux"
incomplete_dir = "/srv/releases/.incoming"

[profiles.profiles.linux-release.queue]
priority = "high"       # low, normal, or high
start_behavior = "start" # start or paused

[profiles.profiles.linux-release.seeding]
ratio_limit = 2.0
idle_limit = 86400
seed_forever = false

[profiles.profiles.linux-release.bandwidth]
download_limit = 0       # bytes/sec; 0 is unlimited
upload_limit = 5242880

[profiles.profiles.linux-release.tracker]
# First matching case-insensitive host glob wins. Omit enabled to leave a
# matching tracker eligible; high/normal/low determines attempt ordering.
host_rules = [
  { host_pattern = "*.example.invalid", enabled = false },
  { host_pattern = "tracker.example.org", priority = "high" },
]

[profiles.profiles.linux-release.intake]
# Case-insensitive * and ? patterns against slash-separated torrent paths.
excluded_file_patterns = ["*.nfo", "samples/*"]
# Safe relative path below both selected storage roots.
organization_subdirectory = "lawful/linux"
# Optional independent staging directory below the incomplete root.
incomplete_subdirectory = "review"
# Put single-file torrents below a directory named after the torrent.
force_top_level_folder = true
# Active files are named `original-name.part` until completion.
partial_file_suffix = ".part"

[[profiles.profiles.linux-release.intake.excluded_file_rules]]
# Every populated field in one rule must match; any matching rule excludes.
path_segment = "samples"
max_size_bytes = 104857600

[[profiles.profiles.linux-release.intake.excluded_file_rules]]
suffix = ".txt"

An explicit profile supplied at add time, by a watch folder, or through the torrent policy API wins over a label mapping. Per-torrent limits and seeding settings then win for their individual fields. A durable per-torrent encryption override is set with PUT /api/v1/torrents/:hash/encryption-mode; its body must contain encryption_mode, and JSON null explicitly clears the override to restore profile/label/global inheritance. Profile queue priority, seeding, rate limits, and encryption remain live for inheriting torrents. start_behavior controls initial admission; changing it never stops already-running work.

Encryption precedence is explicit torrent override, selected profile, then global torrent.encryption_mode. A profile or label-map replacement restarts only active download/metadata engines whose effective mode changes; future inbound TCP seeding sessions use the refreshed mode, while an already negotiated peer session retains its wire stream. Pure-v2 MSE/PE uses its 20-byte peer-wire identity only at the protocol boundary; the full SHA-256 identity remains the durable/API key. Profiles do not create a separate network path or proxy.

Storage is deliberately different: the resolved completed and incomplete paths are captured when a torrent is registered, including the global/no- profile result. Editing a profile, changing labels on an existing torrent, or assigning a profile later never relocates data. The resolved start-or-paused decision is captured at registration too, so later profile or global auto-start edits cannot revoke a queued torrent’s admission. Use the move-data operation for an intentional relocation. State restored from before these fields is migrated transactionally from its preceding effective values when a profile configuration replacement is applied; until then it retains legacy global queue behavior.

Intake rules are also create-time snapshots. Exclusion patterns mark matching files unwanted before payload transfer; an add request may supply additional unwanted_file_indices or structured suffix/path-glob/path-segment/size rules. For a magnet, those choices are retained until its contained metadata fetch exposes the real file tree. organization_subdirectory and incomplete_subdirectory must be non-empty relative paths with normal components only; absolute, current-directory, and parent-directory paths are rejected. partial_file_suffix is applied only to active payload names and is removed on complete verification. Later profile edits never silently rewrite an existing torrent’s reviewed selection or location. Tracker host rules remain live profile policy, so they can control discovery without rewriting the intake snapshot.

[network]

OptionDefaultMeaning
modestrictTorrent data-plane containment mode. An omitted [network] table produces strict mode without a path, which fails startup with invalid_config. Explicit mode = "disabled" is for development or a separately enforced boundary only. See ADR-0051.
required_interfaceunsetInterface name, such as br0 or tun0.
required_source_ipv4unsetRequired IPv4 source address.
required_source_ipv6unsetRequired IPv6 source address.
required_network_namespaceunsetRequired Linux network namespace name.
allow_ipv6trueEnables IPv6 torrent networking when the path is contained.
fail_closedtrueBlocks torrent networking when strict containment is unhealthy.
validate_routefalseRequires route validation when supported by the probe.
validate_dnsfalseReports dns_not_constrained in network health when DNS cannot be proven constrained. Hostname resolution is still fail-closed unless DNS is constrained or a network namespace is used.

[network.socks5]

SOCKS5 is an explicit, opt-in TCP CONNECT transport layered on top of the configured network path. The daemon resolves and connects to the proxy through the contained binder first. For peer TCP addresses it sends SOCKS IP-address forms; HTTP(S) tracker, scrape, and webseed hostnames use SOCKS domain forms so their target DNS resolution happens at the proxy. A failed proxy connection or handshake returns an error; SwarmOtter never retries the target directly.

[network.socks5]
enabled = true
host = "proxy.example"
port = 1080
# Omit both for SOCKS5 no-authentication, or set both for RFC 1929 auth.
# username = "operator"
# password = "supply-with-your-secret-manager"

[torrent]
utp_enabled = false

[dht]
enabled = false
OptionDefaultMeaning
enabledfalseEnables the contained SOCKS5 TCP CONNECT wrapper.
hostunsetRequired when enabled. The proxy hostname is resolved through the configured containment path.
port1080SOCKS5 listener port; must be nonzero.
usernameunsetRFC 1929 username. Must be supplied together with password, or both must be omitted for no-authentication.
passwordunsetRFC 1929 password. API settings reads and replacement responses redact it.

SOCKS5 CONNECT is intentionally TCP-only. Configuration validation requires torrent.utp_enabled = false and dht.enabled = false when it is enabled. A UDP tracker URL is rejected through the proxy binder rather than routed directly; SOCKS5 UDP ASSOCIATE is not implemented. The Network diagnostics view reports only that SOCKS5 is enabled and that its UDP path is blocked; it never exposes the proxy host or credentials. NAT-PMP/UPnP router mapping remains a local gateway operation on the same contained path, not a proxy bypass for torrent traffic. See ADR-0062.

Strict mode is the default and requires at least one enforceable path: interface, source address, or network namespace. Breaking change (ADR-0051): an omitted [network] table no longer selects disabled mode. It produces strict mode without a path and the daemon fails at startup with invalid_config. Existing users who relied on the disabled default must configure a strict path or set mode = "disabled" explicitly. Never infer disabled mode from a missing file/table, platform, bind failure, or unavailable interface; never auto-change strict to preferred or disabled.

The daemon observes one process-wide containment gate shared by every torrent data-plane component (binder, DHT, listener, engine, seeder, tracker, webseed, metadata). On live path loss the gate blocks immediately, the inbound listener and DHT runner stop, data-plane tasks are aborted, and active torrents enter network_blocked while the control plane remains available. On recovery the gate reopens and only work carrying durable formerly-live recovery intent resumes; paused, queued, stale blocked, and automatically seed-stopped torrents remain stopped. Every block advances the gate generation, so tasks from before a blocked interval cancel even if recovery is immediate.

Concrete bind failures block synchronously and latch socket_bind_failed (or blocked_fail_closed for a generic policy denial). A healthy probe alone does not clear the latch. Only an explicit PUT /api/v1/settings replacement whose peer-listener bind and, outside SOCKS5 TCP-only mode, contained UDP bind validation succeeds may recover it; a failed replacement preserves the prior configuration and blocked state. On Linux, route and DNS path validation invoke ip route get; direct and tarball installs must provide the ip utility from the distribution’s iproute2 or iproute package.

Tracker announce, supported HTTP/HTTPS BEP 48 scrape, and webseed ranges use a framed HTTP/1 client only over binder-provided streams. Redirects repeat contained resolution/connect, follow at most five hops, allow HTTP-to-HTTPS, and reject HTTPS-to-HTTP. Tracker decoded bodies are capped at 2 MiB; webseed responses must be exact 206 ranges and are capped at the requested byte count. No cookies, authorization, or connection pool are retained. Scrape is derived only from an HTTP(S) tracker whose final path component begins announce; other paths and UDP scrape report unsupported without disabling announce.

Router port mapping and listener reachability

Router port mapping is optional and disabled by default. When enabled, [port_mapping] maps the configured TCP [torrent].listen_port through a local NAT-PMP or UPnP IGD gateway, refreshes its lease before expiry, and reports the result through the network API and Web UI.

Mapping is intentionally stricter than ordinary torrent operation: it requires network.mode = "strict", network.fail_closed = true, and a concrete network.required_interface. NAT-PMP discovery/UDP, UPnP SSDP discovery, and UPnP SOAP control traffic all use that contained interface. If the path is blocked or the router rejects a mapping, SwarmOtter does not fall back to the default route; it reports the mapping as unavailable or blocked while leaving an otherwise healthy torrent data plane unchanged.

[port_mapping]
enabled = true
protocols = ["nat_pmp", "upnp"]
# Optional overrides for constrained environments:
# nat_pmp_gateway = "192.0.2.1"
# upnp_service_url = "http://192.0.2.1:49000/control/WANIPConn1"
lease_seconds = 3600
refresh_before_expiry_seconds = 300

[port_test] is a separate, opt-in diagnostic. SwarmOtter never contacts a hardcoded public service. Configure an HTTP(S) endpoint you operate; a request through the same data-plane binder appends listen_port, protocol=tcp, and format=swarmotter-port-test-v1. The endpoint may return plain open or closed, or JSON with reachable/open boolean or status: "open" | "closed".

[port_test]
enabled = true
endpoint = "https://reachability.example.invalid/check"
cache_ttl_seconds = 900
timeout_seconds = 10

Results are cached, serialized, and informational (unknown, open, closed, error, or timeout). A failed request or negative result never changes containment health or starts an uncontained retry. The API status does not repeat the configured endpoint URL. A successful mapping lease can refresh this diagnostic through the same contained runtime path.

[autopilot]

OptionDefaultMeaning
modeactAutopilot mode: disabled (no analysis), observe (reasons only), or act (reasons plus bounded automatic actions).

[torrent]

OptionDefaultMeaning
listen_port51413Inbound peer TCP and DHT/uTP UDP port.
allow_ipv6trueEnables IPv6 peers when network containment also allows IPv6; when false, IPv6 peers are filtered before connecting.
utp_enabledtrueEnables uTP peer transport through contained UDP sockets.
utp_prefer_tcptrueTries TCP first, with uTP fallback.
encryption_modepreferredContained TCP/uTP MSE/PE peer-wire mode. disabled uses plaintext only. preferred attempts MSE/PE first, then retries plaintext only on the same selected contained transport. required refuses plaintext and never falls back. Pure-v2 uses its required 20-byte peer-wire identity only for MSE/PE while retaining its full SHA-256 library key. Changing this global setting rebuilds active data-plane tasks before it is reported as applied.
selfishfalseRemoves a torrent after verified completion and does not seed it; already-completed managed records are also removed on runtime reconciliation while preserving downloaded data.

[port_mapping]

OptionDefaultMeaning
enabledfalseOpts into router mapping of the TCP peer listener. Requires strict fail-closed containment and network.required_interface.
protocols["nat_pmp", "upnp"]Deterministic NAT-PMP/UPnP attempt order.
nat_pmp_gatewayunsetOptional IPv4 NAT-PMP gateway. Without it, Linux discovery is limited to the configured interface.
upnp_service_urlunsetOptional HTTP WANIP/WANPPP control URL; credentials and fragments are rejected. Without it, contained SSDP discovery is used.
lease_seconds3600Requested mapping lifetime, bounded to seven days.
refresh_before_expiry_seconds300Lead time for renewal; must be positive and shorter than the lease.

[port_test]

OptionDefaultMeaning
enabledfalseOpts into an operator-configured external TCP listener test.
endpointunsetRequired HTTP(S) URL when enabled. It is contacted only through the data-plane binder and is not shown in status responses.
cache_ttl_seconds900Reuse window for the latest result, from 1 through 86,400 seconds.
timeout_seconds10Per-request bound, from 1 through 30 seconds.

[bandwidth]

OptionDefaultMeaning
global_download0Global download bytes/sec, 0 means unlimited.
global_upload0Global upload bytes/sec, 0 means unlimited.
alt_download0Alternate download bytes/sec.
alt_upload0Alternate upload bytes/sec.
alt_enabledfalseUses alternate limits when true.
max_peers0Exact process-wide peer-session cap shared by inbound and outbound peer TCP/uTP across all torrents. 0 is unlimited. Trackers, webseeds, DHT, and DNS are excluded.
max_peers_per_torrent0Additional per-torrent session cap shared by inbound and outbound peers. 0 uses the daemon default of 64.

[queue]

OptionDefaultMeaning
max_active_downloads5Simultaneous active downloads, 0 means unlimited.
max_active_metadata_fetches100Simultaneous active magnet metadata fetches, 0 means unlimited. Does not consume download/seed active slots.
max_active_seeds5Simultaneous active seeds, 0 means unlimited.
auto_starttrueStarts newly added torrents automatically.

Queue limits are enforced by the daemon scheduler. auto_start = false leaves new torrents queued until resume/start-now is requested. Queue move operations change the real scheduling order, and max_active_downloads controls how many queued downloads may run at once.

Performance tuning for large libraries

When managing 1,000+ torrents, consider these configuration adjustments to maintain responsive performance:

Queue limits:

  • Set max_active_downloads to a reasonable value (e.g., 50-100) to prevent resource exhaustion. With 1,000 torrents all downloading simultaneously, peer connections and file descriptors can overwhelm the system.
  • Set max_active_metadata_fetches to limit concurrent magnet metadata fetches (default 100). High values can cause tracker rate limiting.
  • Set max_active_seeds to limit concurrent seeders (default 5). Seeding torrents consume upload bandwidth and peer connections.

Peer limits:

  • Set max_peers to a nonzero value to hard-bound total peer sessions across all torrents. Size it together with the service file-descriptor limit and leave headroom for files, trackers, DHT, and control-plane descriptors.
  • Set max_peers_per_torrent to limit per-torrent peer connections (default 64). Lower values (e.g., 30-50) reduce resource usage with minimal impact on download speed for well-seeded torrents.

Both limits apply for the full peer-session lifetime, including metadata, normal serial/parallel, endgame, seeding, TCP, and uTP paths. An inbound socket that cannot obtain capacity is closed before its peer session starts. Live changes replace the permit pools and synchronously reconstruct eligible work; if reconstruction or full-config persistence fails, the old limits and live ownership remain in effect.

Bandwidth limits:

  • Set global_download and global_upload to prevent network saturation. The atomic rate limiter efficiently distributes bandwidth across all active torrents without mutex contention.
  • Use alternate speed limits (alt_download, alt_upload, alt_enabled) for scheduled bandwidth reduction during peak hours.

File descriptors:

  • Ensure the daemon has sufficient file descriptor limits (see Deployment). Peer descriptors are bounded by max_peers when configured, with additional workload-specific file, tracker, DHT, and control-plane overhead.

Autopilot:

  • Enable autopilot.mode = "act" (default) to allow automatic queue slot release for stalled torrents, peer worker adjustments, and discovery refresh. This helps maintain throughput across large libraries without manual intervention.

Example configuration for a 1,000-torrent library:

[queue]
max_active_downloads = 50
max_active_metadata_fetches = 100
max_active_seeds = 20
auto_start = true

[bandwidth]
global_download = 0
global_upload = 0
max_peers = 10000
max_peers_per_torrent = 50

[autopilot]
mode = "act"

[seeding]

OptionDefaultMeaning
global_ratio_limit2.0Stops seeding after this ratio, unless overridden.
global_idle_limit1800Stops idle seeding after this many seconds, unless overridden.

Omit a field to use its default.

global_ratio_limit must be finite and non-negative. Invalid negative or non-finite values fail configuration validation with invalid_config.

Per-torrent policy is stored in durable daemon state rather than TOML. Set it with PUT /api/v1/torrents/:hash/seeding: a null ratio/idle value inherits these globals, explicit zero is a real immediate target, and seed_forever temporarily suppresses both effective targets without deleting the stored values. Policy and automatic/manual status survive restart without a daemon state version bump because legacy records default to inherited targets.

[dht]

OptionDefaultMeaning
enabledtrueEnables DHT for non-private torrents.
port51413Local UDP port used by the shared DHT runner.
bootstrap_nodesbuilt-in public bootstrap hostnamesDHT bootstrap nodes.

In strict mode, bootstrap hostnames are subject to DNS containment policy.

[pex]

OptionDefaultMeaning
enabledtrueEnables peer exchange for non-private torrents.
max_peers0PEX peer addition cap, 0 means unlimited.

[peer_filter]

OptionDefaultMeaning
enabledfalseEnables global peer-admission filtering.
rules[]IP addresses, CIDRs, or inclusive IP ranges to reject.
blocklist_paths[]Explicit local eMule/PeerGuardian-style blocklist files to load.
manual_bans[]Global IP bans created by an operator or the Peer UI action.
blocked_client_ids[]Printable peer-ID prefixes to reject after a BitTorrent handshake.

Each manual-ban entry has an ip and optional nonblank 1–240-character reason. Client-ID prefixes must be 1–20 printable ASCII characters.

Blocklist imports are local-only UTF-8 regular files. Each is limited to 32 MiB and 4 KiB per line; configured/imported rule sets are capped at 250,000 rules. Blank and comment lines are ignored. A malformed otherwise non-empty import line is skipped and counted in that source’s skipped_lines status, while an overlong line, unreadable/non-UTF-8/non-regular file, or limit excess rejects the configuration update. SwarmOtter never fetches blocklists itself.

A policy update rebuilds peer work transactionally, so a persistence or reconstruction failure restores the prior compiled policy and session instance. The status counters belong to the active compiled policy instance and reset after a successful replacement. IP rules reject candidate peers before a socket is opened and inbound peers before service; peer-ID-prefix rules run after the BitTorrent handshake. Neither rule type replaces the required network-containment path.

[[watch]]

OptionDefaultMeaning
pathrequiredFolder to scan for .torrent files.
recursivefalseScans child folders when true.
download_dirunsetPer-watch download directory override.
labelunsetLabel applied to imports.
profileunsetNamed profile applied before the torrent is registered. It must exist in [profiles.profiles].
start_behavior"start""start" or "paused".
archive_dirunsetWhere imported files are archived.
failure_dirunsetWhere failed imports are moved.
delete_after_importtrueDeletes imported watch files when no archive is configured.

Watch files are read through a bounded reader that enforces the shared 16 MiB metadata limit (MAX_TORRENT_METADATA_BYTES) before parsing and before any piece-sized allocation, regardless of max_request_body_bytes. Oversized or malformed watch files are rejected as malformed_torrent / bencode_error and never panic the daemon. See ADR-0050.

Watch profile, label, and download_dir defaults are applied before policy resolution. A watch profile therefore captures its storage paths for a newly imported torrent; later edits affect only the profile’s live inheriting fields.

Watch ingestion is stability-gated (ADR-0054). The scanner walks in a blocking filesystem task, sorts root-relative paths, rejects a configured symlink root, and skips every child symlink without descending through symlinked directories. A file is eligible only after two consecutive scans report the same length and modified timestamp. The bounded read rechecks both the path and opened-file metadata; a change discards the bytes and restarts stability without recording an import result. Manual and automatic scans are serialized.

path, archive_dir, and failure_dir must not be whitespace-only. An archive or failure directory must not lexically normalize to the watch root itself. If one is a strict descendant of its watch root, that destination and its subtree are excluded from this configured folder’s scan; this prevents recursive scans from re-importing moved inputs. Exclusion uses path-component boundaries without resolving symlinks; similarly named siblings are not excluded. A separately configured overlapping watch root evaluates its own destinations and can still scan that path.

Observations are memory-only. Restart requires a fresh first observation. An unchanged registered torrent then becomes a successful duplicate on the second scan: its existing path, labels, queue position/bypass, and settings are unchanged, while the configured success action runs once. With no archive and delete_after_import = false, leave marks that fingerprint processed, so it does not repeat until length or modified time changes. Watch status does not advance stability and excludes unchanged processed files from its pending count.

Only bencode, malformed-torrent, invalid-info-hash, and parse errors are permanent input failures; they execute failure_dir handling and do not retry unchanged input. Storage, I/O, persistence, containment, and internal failures are transient: the source stays and a later stable scan retries it. Archive and failure directories are created when absent, and create-new copy/remove actions never overwrite a destination. A delete/copy/remove/collision error preserves the primary result, appears as post_action_error, and leaves the fingerprint processed for manual resolution. A crash during an archive/failure copy can leave source plus a partial destination; recovery will not overwrite it.

GET /api/v1/watch/history and Watch status retain the newest 10,000 results in insertion order for the current daemon run. Each result keeps compatibility fields (success, duplicate, error) and reports outcome as imported, duplicate, permanent_failure, or transient_failure, plus an optional post_action_error. This operational history is not persisted.

[logging]

OptionDefaultMeaning
level"info"Log level.
jsonfalseEmits JSON logs when true.
filetrueRecords daemon logs to a file as well as stderr/journal.
file_pathunsetLog file path. When unset, uses $XDG_STATE_HOME/swarmotter/swarmotterd.log or ~/.local/state/swarmotter/swarmotterd.log.

Default logging is intentionally simple: terminal starts still show logs in the terminal, systemd starts still show logs in the journal, and the daemon also records the same logs to a per-user file.

API Reference

SwarmOtter exposes a native REST API under /api/v1. The Web UI uses the same API as external automation.

Response format

All responses use a common envelope:

{ "success": true, "data": {}, "error": null }

Errors use the same envelope:

{
  "success": false,
  "data": null,
  "error": {
    "code": "network_blocked",
    "message": "Required torrent network interface tun0 is not available."
  }
}

HTTP status codes reflect the error class:

  • 400: bad input.
  • 401: missing or invalid API authentication.
  • 403: browser origin, Fetch Metadata, or Host validation failed.
  • 404: not found.
  • 409: duplicate.
  • 503: network or containment blocked.
  • 500: internal error.

Authentication and limits

When api.require_auth = true, every /api/v1 route requires the configured token through one of these headers:

Authorization: Bearer <token>
X-SwarmOtter-Auth: <token>

Startup validation rejects authenticated mode unless api.auth_token is set. GET /api/v1/settings never returns the token value.

When api.require_auth = false, API and Web UI requests do not require a token, including on a configured LAN listener. Every client that can reach such a listener can control SwarmOtter, so authenticated mode is strongly recommended unless the reachable network is the intended trust boundary.

Browser requests to every control route (/api/v1, /transmission/rpc, and /api/v2) must be same-origin, except for the authenticated Chrome extension client described below. An Origin-bearing request must provide exactly one valid UTF-8 Origin and Host; an ordinary browser Origin must be only scheme://authority, its normalized host and explicit port must match Host, and it may not contain user information, a path, query, or fragment. Origin: null, opaque, foreign, malformed, duplicate, multi-value, and invalid-byte headers are rejected. Scheme is intentionally not compared, so a TLS-terminating reverse proxy is supported when it preserves the public Host authority.

Sec-Fetch-Site permits only same-origin, none, or an absent header. same-site, cross-site, unknown, duplicated, and invalid-byte values are rejected. This includes WebSocket and SSE requests. The shared browser_origin_guard is the outermost control-route layer, before native authentication, Transmission session negotiation, qBittorrent SID handling, compatibility-enabled checks, request extraction, and daemon operations. The same-origin and headerless-client policy is identical whether api.require_auth is true or false. CLI and automation clients with neither Origin nor Sec-Fetch-Site are unaffected.

A Chrome Manifest V3 extension service worker with host permission sends an Origin such as chrome-extension://abcdefghijklmnopabcdefghijklmnop; Chromium uses Sec-Fetch-Site: none for this privileged request. SwarmOtter accepts this cross-origin shape only when all of these conditions hold:

  • the Origin contains exactly one valid Chrome extension ID: 32 lowercase characters from a through p, with no port, path, query, or fragment;
  • Host is one valid authority and Fetch Metadata is otherwise permitted;
  • api.require_auth = true; and
  • exactly one Authorization: Bearer <token> or X-SwarmOtter-Auth: <token> value matches api.auth_token.

Auth-disabled mode always rejects extension Origins, even if an auth_token value is present. A valid token never permits a foreign HTTP(S), null, opaque, or malformed Origin. This is token-authenticated extension access, not a broad extension-origin allowlist.

An origin rejection always uses HTTP 403 but preserves the selected surface’s error format: the native API returns its JSON error envelope with cross_origin_forbidden or the extension-specific extension_origin_forbidden, Transmission returns a JSON error object, and qBittorrent returns plain-text Forbidden. The native extension error explains that authenticated mode and a valid configured token are required. Rejections are never redirected to the Web UI.

API request bodies are capped by api.max_request_body_bytes; this applies to JSON requests and raw .torrent uploads. The root /health alias remains a control-plane health endpoint outside /api/v1.

Bencoded torrent metadata (.torrent uploads, bulk base64 metainfo, magnet info dicts fetched via BEP 9, and watch-folder files) is additionally bounded by a shared 16 MiB limit (MAX_TORRENT_METADATA_BYTES) enforced by the core parser before any piece-sized allocation. A .torrent body or assembled magnet info dict that exceeds the metadata limit is rejected with malformed_torrent (or bencode_error for raw decoder overruns). Raw torrent uploads are streamed and stop at the lower of the configured request limit and 16 MiB: when api.max_request_body_bytes is lower, crossing that configured limit returns HTTP 413 with payload_too_large before the metadata-specific error. Bulk and Transmission base64 metainfo decoding stops before decoded output can exceed 16 MiB.

Restored daemon state uses a versioned local SQLite store, not bencode. A validated legacy JSON state document migrates in place on its first successful save. Restored metainfo, including retained canonical raw info bytes when available, must pass TorrentMeta::validate() before runtime use. See ADR-0050 and ADR-0067.

Health, version, and stats

All paths in this section are under /api/v1, except the root /health alias.

MethodPathDescription
GET/healthDaemon and network health.
GET/versionVersion and build info.
GET/statsGlobal stats.

The root /health path is also available without the /api/v1 prefix.

/stats returns aggregate transfer counters plus a scheduler object for large-library diagnostics. Scheduler fields include managed and queued torrent counts, running engine counts, requested and granted download/metadata slots, retry-backoff counts, active queue limits, peer-worker budget fields, and boolean saturation flags for download slots, metadata fetch slots, and peer worker budget.

The authoritative process-wide peer-session fields are:

  • peer_limit: configured process-wide limit; 0 means unlimited.
  • peer_permits_in_use: observed live inbound plus outbound peer sessions.
  • peer_permits_available: remaining bounded capacity, or null when unlimited.
  • peer_sessions_denied: inbound sockets rejected by the global or routed per-torrent cap before a session starts.

The older peer_worker_global_limit, peer_worker_per_torrent_limit, effective_peer_worker_limit, peer_worker_budget, and saturation values are retained compatibility diagnostics for engine worker scheduling. They are not the process-wide connection-limit authority.

Torrent management

MethodPathDescription
GET/torrentsList torrents.
GET/torrents/queryQuery torrents with server-side filters, sorting, pagination, counts, and optional grouping.
POST/torrentsAdd magnet JSON or raw .torrent body.
POST/torrents/magnetAdd magnet JSON with storage, profile, preview, file-selection, structured exclusion, and active-file-suffix options.
POST/torrents/fileUpload raw .torrent body; query supports start, profile, label, preview, selection, active-root, and partial-suffix options.
POST/torrents/bulkAdd multiple magnets and/or base64 .torrent payloads.
GET/torrents/:hashTorrent details.
GET/torrents/:hash/metainfoReturn the retained, byte-exact original .torrent document when one exists.
GET/torrents/:hash/statsPer-torrent counters and live engine diagnostics.
DELETE/torrents/:hash?delete_data=boolRemove torrent, optionally deleting data.
POST/torrents/removeRemove multiple torrents: { info_hashes, delete_data? }.
POST/torrents/:hash/pausePause.
POST/torrents/:hash/resumeResume.
POST/torrents/:hash/startStart now, bypassing queue.
POST/torrents/:hash/stopStop.
POST/torrents/:hash/recheckForce recheck.
POST/torrents/:hash/reannounceReannounce.
POST/torrents/:hash/moveMove data: { path }.
POST/torrents/:hash/labelsSet labels: { labels }.
POST/torrents/:hash/limitsSet per-torrent bandwidth limits: { download_limit, upload_limit }, bytes/sec, 0 = unlimited.
PUT/torrents/:hash/seedingReplace the persisted per-torrent ratio/idle/forever policy.
GET/torrents/:hash/policyEffective profile values plus the source of every value.
PUT/torrents/:hash/policySet/clear explicit profile: { profile: "name" } or { profile: null }.
POST/torrents/:hash/storage-previewRead-only path proposal: { download_dir?, incomplete_dir?, profile? }.

Torrent list/detail rows include nullable error, uploaded, ratio, seeding, seeding_status, effective_ratio_limit, and effective_idle_limit. error retains the last terminal/runtime failure for operator diagnosis and is null after a successful retry or lifecycle action that clears it. The persisted seeding object has nullable ratio_limit and idle_limit fields plus seed_forever. Nullable targets inherit [seeding] globals; explicit zero is an immediate target. seed_forever: true makes both effective fields null without erasing stored overrides.

Rows also include an explicit identity object with kind (v1, v2, or hybrid) and the applicable full hash values. unknown is retained only for a legacy record that predates the additive identity field. The longstanding info_hash field is now the canonical torrent locator: 40 lowercase hexadecimal characters for v1 and hybrid-primary records, or 64 for pure-v2 records. A hybrid’s full v2 locator is also accepted as an alias for its canonical v1 record. The 20-byte peer/tracker/DHT wire value is never an API locator.

When every attempted configured tracker fails and no usable DHT, PEX, direct-peer, or webseed source exists, the daemon stops the bounded engine attempt in tracker_error and exposes the last tracker failure in error. POST /torrents/:hash/reannounce or Resume/Start Now clears the terminal error and starts a new attempt. A successful tracker response or usable alternative source prevents tracker_error.

The replacement request requires exactly these keys:

{
  "ratio_limit": 1.5,
  "idle_limit": 1800,
  "seed_forever": false
}

ratio_limit must be a finite non-negative number or null; idle_limit must be a non-negative integer number of seconds or null; and seed_forever must be boolean. Missing, unknown, negative, non-finite, fractional-idle, or numeric-overflow input returns invalid_argument. The daemon persists before success, then immediately re-evaluates active or automatically stopped complete content. It never auto-resumes a manual pause.

seeding_status is one of not_eligible, queued, active, stopped_ratio, stopped_idle, or stopped_manual. Fully verified queued content is completed + queued; a live registered seeder is seeding + active; automatic stops return to completed; and a complete operator pause is paused + stopped_manual. During containment failure the coarse state is network_blocked and the fine status is preserved for recovery.

Add requests can start paused while still inserting the torrent into queue order. For JSON magnet adds, set either paused: true or start_behavior: "paused". For raw .torrent uploads, use ?paused=true or ?start_behavior=paused on /torrents or /torrents/file. paused and start_behavior must agree when both are provided. If add-time free-space preflight is configured, add requests can fail before write with a storage-capacity error when the target root does not meet the reserve configured under [storage].

Set preview: true to register a metadata-first preview. A .torrent preview is paused with its parsed file tree available immediately. A magnet preview may use the normal contained BEP 9 metadata path, then becomes paused before any payload storage, payload announce, or piece request. Resume or Start Now clears the durable preview gate and follows the normal queue and containment paths. preview cannot be combined with an explicit payload-start request. unwanted_file_indices is a deduplicated selection captured at add time; for magnets it is applied only after the real file tree has been validated. The effective policy endpoint exposes the stored intake decision. Add JSON and bulk requests may additionally supply file_exclusion_rules, incomplete_dir, and partial_file_suffix. A structured rule can combine a path glob, filename suffix, path segment, and inclusive minimum/maximum byte length; matching files remain unwanted. A partial suffix applies only to active files and is removed when all files complete. The storage-preview endpoint is read-only: it shows the resolved complete and incomplete paths without creating, moving, or opening payload files.

SwarmOtter accepts v1, pure-v2, and hybrid BEP 52 magnets and .torrent files. Pure-v2 records retain their full SHA-256 locator through registration, queue, API, durable state, fast resume, and contained peer/tracker/DHT discovery. The pure-v2 engine validates the file tree and SHA-256 piece layers before payload transfer. Hybrid records preserve both validated identities and use their v1 compatibility swarm as the canonical registry record. Invalid or incomplete v2/hybrid metadata fails with a typed error; it never falls back to a v1 key, piece hash, or network path.

GET /torrents/:hash/metainfo returns application/x-bittorrent bytes only when SwarmOtter retained an original full .torrent document at local-file or watch-folder intake. It never reconstructs an original document from canonical info bytes, never starts metadata retrieval, and returns an unavailable/not- found error for magnet-only or pre-retention records.

Strict fail-closed network blocking can still put the new torrent in network_blocked instead of paused.

Policy profiles

MethodPathDescription
GET/profilesReturn the complete { profiles, labels } configuration section.
PUT/profilesReplace the complete { profiles, labels } section after validation.
PUT/torrents/:hash/encryption-modeSet a durable per-torrent peer-wire encryption override: { "encryption_mode": "disabled" | "preferred" | "required" }; { "encryption_mode": null } clears it.

Add requests may include profile and labels; labels are applied before resolution so a label mapping can select a profile. Bulk add accepts the same top-level profile and labels values for every item. An unknown or empty profile is rejected. Compatibility adapters likewise attach their category or labels before registration.

GET /torrents/:hash/policy returns the selected profile plus each effective storage, queue, seeding, bandwidth, peer-encryption, and create-time intake value with a machine-readable source: global, profile, label, torrent, legacy_torrent, profile_storage_snapshot, registration_storage_snapshot, existing_storage_snapshot, initial_admission_snapshot, or intake_snapshot. This lets clients explain why a value applies without duplicating daemon precedence rules. registration_storage_snapshot means the resolved storage choice was fixed when the torrent was registered; initial_admission_snapshot means the one-time start-or-paused decision was captured for that torrent, so later profile, label, or global edits do not retroactively change its admission. The encryption override endpoint requires the encryption_mode key: omitting it is rejected, while explicit JSON null restores normal inheritance.

Resolved storage is captured at registration, including a global/no-profile result. Assigning or clearing a profile, or changing labels later, preserves the torrent’s existing completed and incomplete locations; use POST /torrents/:hash/move to relocate data. The initial start-or-paused decision is captured too, so profile/global auto-start edits cannot revoke a new or migrated queued torrent’s admission. Queue priority, seeding, and rate caps update live while a torrent inherits them. Legacy state is migrated transactionally during a profile configuration replacement.

Profile tracker settings use ordered, case-insensitive host glob rules. The first matching rule controls tracker enablement and priority; this remains a live profile policy. Intake settings are under [profiles.profiles.<name>.intake]. excluded_file_patterns and structured excluded_file_rules select unwanted files, while organization_subdirectory, incomplete_subdirectory, force_top_level_folder, and partial_file_suffix determine safe active and completed paths. These intake values are snapshotted when a torrent is registered, so later profile edits do not rewrite a reviewed selection or location. Magnet so= selection is a bounded local allowlist that can only reduce the selected files; literal x.pe endpoints use the ordinary peer filter and contained binder without hostname resolution.

Successful add responses mean the torrent record was registered and inserted into queue order. The daemon does not wait for queue reconciliation, metadata fetching, tracker announces, peer connections, or engine startup before returning. Rapid add bursts are coalesced by the daemon scheduler.

Bulk add requests use:

{
  "magnets": ["magnet:?xt=urn:btih:..."],
  "torrent_files": [{ "metainfo": "base64 .torrent bytes" }],
  "download_dir": "/data/downloads",
  "incomplete_dir": "/data/.incoming",
  "paused": true,
  "preview": true,
  "unwanted_file_indices": [2, 5],
  "file_exclusion_rules": [{ "suffix": ".nfo" }],
  "partial_file_suffix": ".part",
  "profile": "linux-release",
  "labels": ["linux"]
}

download_dir, incomplete_dir, paused, start_behavior, preview, unwanted_file_indices, file_exclusion_rules, partial_file_suffix, profile, and labels apply to every item in the batch. The response includes added items with { kind, index, info_hash } and failed items with { kind, index, code, message }, so one invalid or duplicate item does not prevent other valid items from being registered.

Bulk remove requests use:

{ "info_hashes": ["40-or-64-hex locator..."], "delete_data": false }

The response includes removed and not_found info-hash arrays. The daemon removes all found records and reconciles queue state once for the batch.

Large-library clients should use GET /torrents/query instead of repeatedly fetching the full list. Supported query parameters are:

ParameterDescription
qCase-insensitive search across name, info hash, state, health, label, and storage root.
stateComma-separated torrent states such as downloading, paused, or error.
healthComma-separated health labels such as good, stalled, or network_blocked.
labelComma-separated labels; unlabeled torrents use unlabeled.
storage_rootComma-separated download roots; torrents without an explicit root use default.
performanceComma-separated buckets: active, error, complete, transferring, has_peers, no_peers, stalled, unhealthy.
min_peers, max_peersFilter by the greater of active peer workers and known peers.
min_down_rate, min_up_rateFilter by current byte/sec rates.
sortOne of name, state, health, health_score, progress, size, down_rate, up_rate, ratio, peers, added, completed, or queue.
dirasc or desc.
page1-based page number.
per_pagePage size, capped by the daemon; 0 returns counts and groups without rows.
group_byOptional grouping: state, health, label, storage_root, or performance.

The response data object is:

{
  "rows": [],
  "total": 1000,
  "filtered": 42,
  "page": 1,
  "per_page": 100,
  "page_count": 1,
  "sort": "name",
  "dir": "asc",
  "counts": {
    "states": { "downloading": 10 },
    "health": { "good": 8 },
    "labels": { "linux": 6 },
    "storage_roots": { "/data/linux": 6 },
    "performance": { "active": 10 }
  },
  "groups": [{ "key": "downloading", "label": "Downloading", "count": 10 }]
}

/torrents/:hash/stats includes counters, rates, limits, active peer workers, known peers, live peer scheduler diagnostics, tracker diagnostics, and DHT/PEX freshness. Nullable diagnostic fields mean the daemon has not published that live signal yet.

Files

MethodPathDescription
GET/torrents/:hash/filesList files.
PATCH/torrents/:hash/filesAlias for set wanted.
POST/torrents/:hash/files/wantedSet wanted: { file_indices, wanted }.
POST/torrents/:hash/files/prioritySet priority: { file_indices, priority }.
POST/torrents/:hash/files/:index/renameRename path: { new_path }.

Trackers

MethodPathDescription
GET/torrents/:hash/trackersList trackers.
POST/torrents/:hash/trackersAdd tracker: { url }.
DELETE/torrents/:hash/trackers/:urlRemove tracker.
POST/torrents/:hash/trackers/editEdit tracker: { old_url, new_url }.

Tracker rows expose per-URL announce status. last_error is populated only for failed announces, while last_message carries the latest successful announce message. They also expose:

  • scrape_status: not_contacted, updating, ok, error, or unsupported.
  • last_scrape: Unix seconds for the latest attempt.
  • scrape_seeders, scrape_leechers, and scrape_downloads: nullable counts retained from the latest successful exact-key BEP 48 response.
  • last_scrape_error: the latest failed-attempt or task-failure detail without erasing retained counts.

Initial download discovery, magnet discovery, explicit/periodic reannounce, completion, and active seeder announces schedule supported HTTP/HTTPS scrape. Only tracker paths whose final component begins with announce are derivable; UDP scrape is unsupported. seeders and leechers prefer a successful live announce, then fall back to retained scrape counts. downloads uses retained scrape data when available. Existing compatibility adapters keep their prior field shapes.

Peers

MethodPathDescription
GET/torrents/:hash/peersList peers.
POST/torrents/:hash/peers/banAdd or update a global manual ban: { ip, reason? }.
POST/torrents/:hash/peers/unbanRemove a global manual ban: { ip }.

Peer rows include the discovered peer address, direction, current rates, flags, and ban state. Negotiated per-peer encryption state is not exposed in this phase.

Peer admission policy

MethodPathDescription
GET/peer-filterReturn active direct rules/import paths, local import results, manual bans, client-ID prefixes, and rejection counters.
PUT/peer-filterReplace { enabled, rules, blocklist_paths, manual_bans, blocked_client_ids }.
POST/peer-filter/unbanRemove a global manual ban: { ip }.

Manual bans are global by IP even when created from a torrent peer view. A replacement validates and compiles all local sources before it affects live peer work. The status response contains trimmed direct rules, blocklist_paths, per-source import outcomes, manual bans, client-ID prefixes, and counters for the active compiled policy instance; those counters reset on a successful replacement. It also reports any fail-closed loading detail.

Queue

MethodPathDescription
POST/torrents/:hash/queue/move-upMove up.
POST/torrents/:hash/queue/move-downMove down.
POST/torrents/:hash/queue/move-topMove to top.
POST/torrents/:hash/queue/move-bottomMove to bottom.

Settings

MethodPathDescription
GET/settingsGet configuration with API auth token and SOCKS5 password redacted.
PATCH/settingsUpdate live-safe runtime settings.
PUT/settingsReplace full configuration atomically after validation.

PATCH /settings updates live-safe bandwidth, queue, and seeding fields.

PUT /settings accepts [torrent].encryption_mode with these values:

  • disabled
  • preferred (default)
  • required

The global mode applies MSE/PE to contained TCP and uTP peer streams. In preferred mode, failed negotiation may retry plaintext only on the same selected contained transport; required never retries plaintext. Pure-v2 sessions use the required 20-byte peer-wire/MSE identity while retaining their full SHA-256 API and durable locator, so the same negotiation rules apply without a lossy registry fallback. Changing the global field live-rebuilds existing data-plane tasks and does not require a process restart. A named profile may set encryption_mode, and the per-torrent endpoint overrides it durably. Profile or label-map changes restart only active download/metadata engines whose resolved mode changes after persistence. Future inbound TCP seeding sessions use the refreshed mode; existing negotiated sessions retain their established wire stream.

Named profiles are part of the full settings configuration under profiles; the dedicated /profiles endpoints are preferred when only that section needs to change.

PUT /settings validates the full config before persistence, preserves the existing api.auth_token when omitted, applies live-safe fields immediately, and reports fields that require restart. It also preserves a redacted network.socks5.password only when the submitted SOCKS5 username is unchanged; clearing or changing the username requires a complete new credential pair.

Network

MethodPathDescription
GET/network/healthNetwork containment health plus non-sensitive port-mapping and listen-port-test status.
GET/network/port-mappingRead the current opt-in router mapping status without sending router traffic.
POST/network/port-mapping/refreshImmediately reconcile the configured router mapping through the contained path.
POST/network/port-testRun or return the fresh cached operator-configured listen-port test.
GET/network/diagnosticsDetailed network/path diagnostics.

/network/diagnostics includes transport settings such as utp_enabled, utp_prefer_tcp, peer_encryption_mode, socks5_enabled, and socks5_udp_blocked. SOCKS diagnostics reveal neither proxy host nor credentials. See Network Containment for health state meanings.

The port_test object returned by /network/health is informational and contains enabled, endpoint_configured, the TCP listener port, state, timestamps, and bounded detail—but never the configured endpoint URL. States are unknown, open, closed, error, or timeout. A POST only sends a request when testing is enabled and an endpoint is configured; a fresh cached result is reused. See Configuration.

The port_mapping object returned by /network/health and /network/port-mapping contains its enabled flag, configured protocol order, listener/external port, active protocol, local gateway diagnostic, attempt and lease timestamps, state, and bounded detail. States are disabled, pending, active, unavailable, blocked, or error. POST /network/port-mapping/refresh does not bypass strict containment: it returns an informational blocked or unavailable status if the contained path or router cannot complete the request.

Storage

MethodPathDescription
GET/storage/rootsReturn diagnostics for configured and state-placement roots, including free space, mount data, actual local I/O, and root controls.

The storage diagnostics response currently includes per-root identity and space data needed by operators and automation. Typical fields include:

{
  "roots": [
    {
      "path": "/mnt/media/downloads",
      "roles": ["download"],
      "exists": true,
      "is_directory": true,
      "writable": true,
      "filesystem_type": "ext",
      "mount_point": "/mnt/media",
      "mount_options": ["rw", "relatime"],
      "mount_source": "/dev/sdb1",
      "total_space_bytes": 1024,
      "free_space_bytes": 128,
      "available_space_bytes": 120,
      "required_free_space_bytes": 64,
      "reserve_satisfied": true,
      "torrent_count": 4,
      "active_torrents": 2,
      "active_bytes": 67108864,
      "active_write_rate": 1048576,
      "active_recheck_rate": 0,
      "sustained_write_bytes_per_second": 1048576,
      "sustained_verification_bytes_per_second": 524288,
      "cow_strategy": "conservative",
      "cow_strategy_supported": true,
      "active_rechecks": 0,
      "root_control_path": "/mnt/media",
      "max_active_downloads": 2,
      "max_active_bytes": 107374182400,
      "max_write_bytes_per_second": 52428800,
      "max_concurrent_rechecks": 1,
      "warnings": []
    }
  ],
  "minimum_free_space_bytes": 0,
  "minimum_free_space_percent": 0,
  "generated_at": 1783227600
}

active_bytes is the aggregate declared payload budget reserved by active engines, not free space consumed on the filesystem. A limit value of 0 means unlimited. root_control_path is null when no [[storage.root_controls]] entry applies; nested controls resolve to the most-specific lexical root. sustained_write_bytes_per_second and sustained_verification_bytes_per_second are observed local storage I/O, not peer transfer rates; verification excludes ordinary seeding reads. Mount fields are best-effort and may be null in restricted containers or on platforms that do not expose compatible mount metadata. Roles also identify configured resume/state/temporary/log placement roots. cow_strategy_supported is null when the host cannot determine support safely; an explicit unsupported NOCOW request fails before payload bytes are written.

Watch folders

MethodPathDescription
POST/watch/scanTrigger a scan.
GET/watch/historyImport history.
GET/watch/statusWatch-folder status, folder readiness, and recent imports.

Watch recent_imports, history rows, and each folder’s last_result retain the compatibility fields path, success, info_hash_hex, error, and duplicate, and add:

  • outcome: imported, duplicate, permanent_failure, or transient_failure.
  • post_action_error: null or the archive/delete/failure-move error. A post-action error does not replace the primary outcome.

History is insertion ordered, in-memory only, and capped at the newest 10,000 rows. Unstable first/changed observations produce no row. pending_torrent_files counts unseen, changed, stabilizing, and transient-retry files, but excludes an unchanged fingerprint already processed in this daemon run. Calling status does not advance stability.

Watch duplicate is a successful operational outcome: the existing torrent and queue entry remain byte-for-byte/position-for-position unchanged, and the configured success file action runs. This does not change the native torrent- add compatibility contract; an API duplicate still returns HTTP 409 with duplicate_torrent. New API/watch adds share a durable registry/queue transaction, so persistence failure returns the existing typed error envelope without a visible torrent, queue entry, add event, or scheduled start.

Logs and doctor

MethodPathDescription
GET/logs/recentRecent daemon logs, with lines=1..500, default 100.
GET/doctorConsolidated operational health report.
POST/resetStop torrent work, remove torrent records, delete configured download/incomplete contents, and clear daemon log files.

POST /reset is destructive and clients should present an explicit confirmation step. The daemon preserves configured download_dir and incomplete_dir root directories themselves, removes registered torrent payloads from per-torrent override locations, clears in-memory torrent/queue state, and truncates the active daemon log file so the running logger can continue writing to the same path.

Events

SSE and WebSocket events use the same JSON event shape:

{ "kind": "torrent_changed", "info_hash": "40hex...", "payload": {} }
  • SSE: GET /api/v1/events
  • WebSocket: GET /api/v1/ws
  • Both support per-torrent filtering with ?info_hash=<40-hex>.

Current event kinds include torrent_added, torrent_changed, torrent_removed, torrent_error, torrent_metadata_received, torrent_completed, torrent_files_changed, torrent_trackers_changed, torrent_peers_changed, stats_updated, network_status_changed, port_mapping_changed, port_test_changed, watch_folder_imported, watch_folder_failed, settings_changed, and daemon_health_changed.

watch_folder_imported covers both imported and successful duplicate. watch_folder_failed covers permanent and transient attempts. Their payloads contain path, outcome, success, duplicate, info_hash, error, and post_action_error; the top-level event info_hash is present when parsing produced one. A changing/unstable observation emits neither event.

Per-torrent health

Every torrent list row and detail response includes a health object that answers whether the torrent can complete and whether it is downloading well right now. Health is computed from engine state: piece availability, peer usefulness, throughput, recent stability, and discovery. It is not a proxy for seed count or completion percentage.

Torrent summaries also include active_peer_workers and known_peers so UI and API clients can show current peer activity without making a separate diagnostics request for every row.

{
  "health": {
    "score": 82,
    "bars": 4,
    "label": "good",
    "availability_score": 91,
    "throughput_score": 76,
    "peer_score": 80,
    "stability_score": 88,
    "discovery_score": 70,
    "reasons": [
      "all missing pieces are available",
      "6 useful peers are active"
    ]
  }
}

Fields:

  • score (0..100): weighted health score. 0 means stalled, blocked, or paused; 100 means complete.
  • bars (0..5): UI mapping for signal-bars rendering.
  • label: one of unknown, network_blocked, stalled, critical, poor, fair, good, excellent, paused, complete.
  • availability_score, throughput_score, peer_score, stability_score, and discovery_score (0..100 each): component sub-scores.
  • reasons: short human-readable strings explaining the score.

Score formula:

health_score =
    availability_score * 0.40
  + throughput_score   * 0.25
  + peer_score         * 0.15
  + stability_score    * 0.10
  + discovery_score    * 0.10

Bar and label mapping:

ScoreBarsLabel
00stalled
1..341critical
35..542poor
55..743fair
75..894good
90..1005excellent

Hard caps override the weighted score: network containment blocking (network_blocked), paused (paused), or complete (complete) always short-circuit to their own label and score. Incomplete torrents with missing pieces that have zero known sources cap at 35; incomplete torrents with no useful peer cap at 30; incomplete torrents with no recently received valid block cap at 25; torrents with no discovery and no connected peers cap at 20.

Transmission RPC compatibility

When enabled, POST /transmission/rpc is a compatibility adapter over native daemon operations. Compatibility clients that negotiate a session with GET /transmission/rpc, including Prowlarr, receive the same authentication and X-Transmission-Session-Id challenge without dispatching an RPC method. The adapter is not part of the native /api/v1 surface.

The Transmission session-get.version field begins with the adapter’s Transmission compatibility level and includes the SwarmOtter product version in parentheses. Native /api/v1/version remains authoritative for the SwarmOtter release version.

The compatibility baseline has been successfully tested with Prowlarr 2.3.x, including authenticated session negotiation, version validation, and torrent listing through Prowlarr’s Transmission download-client integration.

Enable it with:

[compatibility.transmission]
enabled = true

Authentication follows api.require_auth:

  • When auth is required, HTTP Basic auth is accepted and the Basic password must equal api.auth_token; the username is not security-significant.
  • When auth is disabled, auth headers are not required for this endpoint.
  • The endpoint enforces X-Transmission-Session-Id and returns a new session ID header on session mismatch.

The browser-origin policy in Authentication and limits runs before the enabled check, authentication, and session negotiation. An origin rejection returns HTTP 403 with the Transmission JSON error object and does not issue a session ID or dispatch an RPC method.

A Chrome extension calling this compatibility route must satisfy the shared extension rule before Transmission authentication: enable API auth and send the configured token as Bearer or X-SwarmOtter-Auth. Transmission Basic auth by itself does not identify an allowed extension Origin at the outer guard.

The adapter currently supports common session, torrent lifecycle, queue, and helper calls:

  • session-get, session-set, session-stats, session-close
  • torrent-get, torrent-start, torrent-start-now, torrent-stop, torrent-verify, torrent-reannounce
  • torrent-add, torrent-remove, torrent-set, torrent-set-location, torrent-rename-path
  • queue-move-top, queue-move-up, queue-move-down, queue-move-bottom
  • free-space, port-test, blocklist-update

Existing torrent-get fields uploadRatio/upload_ratio and uploadedEver/uploaded_ever use the same truthful native accounting. seedRatioLimit, seedRatioMode, seedIdleLimit, and seedIdleMode expose effective native seeding targets as Transmission-compatible numeric values; unlimited targets use mode 2 and a zero limit. SwarmOtter does not retain accumulated active download and seed durations, so secondsDownloading and secondsSeeding return the numeric neutral value 0 rather than null.

torrent-remove maps delete-local-data and delete_local_data to the native delete-data option.

torrent-add accepts magnet links via filename and base64 torrent metadata via metainfo. Remote HTTP/HTTPS torrent metadata URLs are rejected.

torrent-add and torrent-set additionally accept an optional native compatibility extension profile. A string selects a configured profile during the same durable add/assignment path used by the native API; explicit null in torrent-set clears an existing assignment. Labels are present before profile resolution. Add and list responses include truthful state, completion, directory, labels, and terminal error data where their established field names allow it. Transmission port-test maps to the latest configured listener-test result and returns port_is_open: true only for an open result.

qBittorrent-compatible API compatibility

When enabled, /api/v2 is a compatibility adapter over native daemon operations. It is not a separate data-plane implementation and does not expose indexing, search, or discovery endpoints.

Enable it with:

[compatibility.qbittorrent]
enabled = true

Authentication follows api.require_auth:

  • Bearer token flow via Authorization / X-SwarmOtter-Auth.
  • qBittorrent-style SID flow via POST /api/v2/auth/login and a returned SID cookie.

The browser-origin policy in Authentication and limits runs before the enabled check, login/SID handling, form extraction, and daemon operations. An origin rejection returns HTTP 403 with plain-text Forbidden; it does not create a SID or dispatch the requested operation.

A Chrome extension must send the configured Bearer or X-SwarmOtter-Auth token on every /api/v2 request. A qBittorrent SID cookie alone does not authorize the cross-origin exception because the shared guard runs before SID handling.

Representative automation endpoints:

  • GET /api/v2/app/version
  • GET /api/v2/app/webapiVersion
  • GET /api/v2/torrents/info
  • GET /api/v2/torrents/categories
  • POST /api/v2/torrents/add
  • POST /api/v2/torrents/delete
  • POST /api/v2/torrents/pause
  • POST /api/v2/torrents/resume
  • POST /api/v2/torrents/start
  • POST /api/v2/torrents/stop
  • POST /api/v2/torrents/recheck
  • POST /api/v2/torrents/reannounce
  • POST /api/v2/torrents/setCategory
  • POST /api/v2/torrents/setLocation
  • POST /api/v2/torrents/renameFile
  • GET /api/v2/torrents/properties?hash=...
  • GET /api/v2/torrents/trackers?hash=...
  • GET /api/v2/torrents/files?hash=...

qBittorrent categories are derived from native labels, profile names, and label-to-profile mappings; there is no second category store. Supplying a category at add time always records the label. If it exactly matches a named profile, it also selects that profile before registration so its add-time storage and start policy apply. Category mutation continues to use native label and profile-assignment transactions. The new lifecycle, location, rename, tracker, and file endpoints delegate to their native operations.

The qBittorrent torrent-info response continues to expose its documented ratio and uploaded counters from the native summary. It does not claim ratio_limit or seeding_time_limit policy options.

Network Containment

Network containment is SwarmOtter’s fail-closed data-plane routing model.

It applies to torrent-related traffic:

  • Peer TCP.
  • Peer UDP and uTP.
  • DHT UDP.
  • PEX-discovered peers.
  • UDP tracker announces.
  • HTTP and HTTPS tracker announces and supported scrape.
  • Webseeds.
  • Magnet metadata fetching.
  • DNS used by torrent operations.

The API and Web UI are separate control-plane traffic and use api.bind_address.

Traffic planes

SwarmOtter separates API/Web UI control traffic from torrent data-plane traffic. Network containment applies to the torrent data plane.

flowchart LR
    subgraph control["Control plane"]
        client["Browser or API client"] -->|"api.bind_address"| api["SwarmOtter API / Web UI"]
    end

    subgraph data["Torrent data plane"]
        engine["SwarmOtter torrent engine"] -->|"containment boundary"| boundary["Required interface, source address, or contained namespace"]
        boundary --> torrentNet["Peers, trackers, DHT, PEX peers, webseeds, and torrent DNS"]
    end

For the Docker Compose deployment, the containment boundary is Gluetun:

flowchart TB
    lan["LAN browser or API client"]

    subgraph host["Docker host"]
        published["Published port 9091"]
        subgraph ns["Shared Gluetun network namespace"]
            swarmotter["SwarmOtter service<br/>network_mode: service:vpn"]
            firewall["Gluetun firewall"]
        end
    end

    vpn["VPN tunnel"]

    lan -->|"API / Web UI"| published --> swarmotter
    swarmotter -->|"torrent data plane"| firewall --> vpn

Fail-closed behavior

When strict containment is enabled and the configured path is unavailable, SwarmOtter blocks torrent networking instead of falling back to the default route.

Strict mode is the default. Omitting [network] does not disable containment: it leaves strict mode without an enforceable path and validation fails before the control listener or background tasks start. Use explicit disabled mode only for local development or a separately enforced boundary:

[network]
mode = "disabled"
flowchart TB
    operation["Torrent operation"] --> check{"Required path healthy?"}
    check -->|"yes"| path["Contained network path"]
    check -->|"no"| blocked["Blocked fail closed"]

    api["API / Web UI"] --> status["Status and remediation remain available"]

Common fail-closed conditions:

  • The required interface does not exist.
  • The required interface is down.
  • The required interface has no usable IP address.
  • A configured source address is no longer assigned.
  • IPv6 is required but disabled in network or torrent configuration.
  • The route cannot be validated when route validation is enabled.
  • DNS cannot be validated when DNS validation is enabled.
  • The required network namespace is unavailable.
  • Socket binding fails.

Linux route and DNS path checks use ip route get. The official container and native packages provide the required utility; direct and tarball installs must install iproute2 on Debian/Ubuntu or iproute on Fedora/RHEL-family systems.

The API reports the current state at:

GET /api/v1/network/health

The Web UI displays the same health state.

Live gate, recovery intent, and bind failures

One process-wide gate covers binders, engines, trackers, peer sessions, webseeds, metadata, DHT, uTP, inbound listeners, and seeders. When a required path disappears, the gate blocks before socket-owning tasks are aborted. Every block advances the cancellation generation. A task from an older generation is therefore cancelled even if recovery follows before it next polls, and a connected stream cannot bridge a fail-closed interval. The API/Web UI listener is outside this data-plane gate and remains available for diagnostics and repair.

HTTP(S) tracker announce/scrape and webseed range reads use one contained HTTP/1 codec. Each redirect hop asks the binder to connect to the target host; the ordinary contained binder resolves it on the contained path, while an enabled SOCKS5 binder keeps the hostname for remote target DNS. TLS wraps only that stream. No connector, independent resolver, or pool can open a data-plane socket. HTTPS-to-HTTP redirects are rejected, decoded bodies are bounded, and exact webseed Range/Content-Range semantics are enforced. UDP scrape is unsupported and makes no network call; UDP announce remains contained and supported unless SOCKS5 TCP-only mode is enabled.

Only work demonstrably live at the block edge receives durable recovery intent. After recovery, SwarmOtter consumes that intent and resumes those downloads, metadata fetches, or active seeders. Paused, merely queued, ratio/idle-stopped, completed-without-a-live-seeder, and stale blocked records do not start because the path recovered.

A source, interface, UDP, or peer-listener bind failure blocks immediately and reports socket_bind_failed; a generic strict-policy denial reports blocked_fail_closed. These failures remain latched even if the interface probe later reports healthy. To recover, submit an explicit full configuration with PUT /api/v1/settings. SwarmOtter validates the peer-listener bind and, unless SOCKS5 TCP-only mode is enabled, a contained ephemeral UDP bind before clearing the latch. If validation or persistence fails, the old configuration remains active and traffic stays blocked. A partial settings patch, health tick, or torrent resume does not clear the latch.

SOCKS5 TCP proxy

[network.socks5] is an opt-in TCP CONNECT layer, not a replacement for the configured containment path. The daemon uses the contained binder to resolve and connect to the proxy itself. TCP peer IP addresses use SOCKS IP-address requests; HTTP(S) tracker, scrape, and webseed hostnames use SOCKS domain requests so target DNS happens at the proxy. If a proxy connection or handshake fails, the target is not retried directly.

SOCKS5 no-authentication and RFC 1929 username/password authentication are supported. The password is redacted from Settings reads and update results. A blank password in a full Settings save retains the stored value only when the username is unchanged.

The supported proxy mode is deliberately TCP-only. Enabling it requires:

[network.socks5]
enabled = true
host = "proxy.example"

[torrent]
utp_enabled = false

[dht]
enabled = false

The proxy binder blocks UDP sockets and direct target resolution, so UDP tracker, DHT, and uTP traffic cannot silently escape outside the proxy. SOCKS5 UDP ASSOCIATE and proxy-provided inbound forwarding are not implemented. Peer listeners remain bound to the configured contained path. NAT-PMP/UPnP mapping uses the same containment boundary directly for local router traffic; it is not a proxy or torrent-egress fallback. Network diagnostics expose only the socks5_enabled and socks5_udp_blocked state, never proxy host or credentials.

Dynamic interface binding

For DHCP or SLAAC addresses, bind to an interface instead of an address:

[network]
mode = "strict"
required_interface = "br0"
allow_ipv6 = true
fail_closed = true
validate_route = true
validate_dns = true

[torrent]
allow_ipv6 = true

On Linux, SwarmOtter enforces sockets with device-bound sockets. IPv4 and IPv6 connections are both allowed when the interface has usable addresses and both network.allow_ipv6 and torrent.allow_ipv6 are true. Hostname resolution is allowed only when DNS is also proven constrained to the configured path, such as systemd-resolved link DNS reported by resolvectl dns br0.

DNS policy

DNS is part of torrent traffic. In strict containment, hostname resolution must not escape through an unconstrained resolver.

Use one of these patterns:

  • Bind to an interface whose DNS is visible to the Linux probe, such as systemd-resolved link DNS from resolvectl dns br0.
  • Use a contained network namespace or container network where DNS is part of the contained path.
  • Use IP-literal peers, trackers, and bootstrap nodes when DNS containment is not available.
  • Set validate_dns = true when you want network health to report dns_not_constrained proactively instead of discovering it at tracker/DHT resolution time.

Health states

StateMeaning
healthyTorrent networking can use the configured contained path.
disabledNetwork containment is disabled.
interface_missingThe configured interface name is not visible to the daemon.
interface_downThe configured interface exists but is down.
no_interface_addressThe interface has no usable IPv4 or allowed IPv6 address.
source_address_missingA configured source address is not assigned.
route_invalidRoute validation failed.
socket_bind_failedThe daemon could not bind a socket to the configured path.
dns_not_constrainedDNS validation was requested but could not be proven safe.
network_namespace_unavailableThe daemon is not in the required namespace.
blocked_fail_closedStrict containment blocked traffic.

Privileged local acceptance test

Build and invoke the harness as your normal user. It requests sudo internally only for ip namespace/link operations:

cargo build --locked -p swarmotterd
scripts/test-network-containment-transition.sh \
  "$PWD/target/debug/swarmotterd"

The harness uses no external network or default route. It creates two PID-qualified namespaces, generates a lawful payload and torrent, and runs a compact HTTP tracker plus throttled TCP BitTorrent seed in the peer namespace. The raw torrent is registered through the real API and must show partial tracker-discovered peer-wire progress. The harness then deletes the daemon veth and requires interface_missing, network_blocked, empty data-plane scheduler diagnostics, stable verified bytes, and a responsive /health route. The tracker, seed, generator, and API clients have no capabilities; SwarmOtter gets only CAP_NET_RAW for SO_BINDTODEVICE. Cleanup removes both namespaces and fixture processes.

Web UI

The Web UI is served by swarmotterd from the same address as the API.

http://127.0.0.1:9091/

Change the listener with:

[api]
bind_address = "0.0.0.0:9091"
require_auth = true
auth_token = "replace-with-a-long-random-token"

Authenticated access is strongly recommended when binding outside localhost. With require_auth = true, the Web UI asks for the token once and keeps it in browser-local storage. A trusted-LAN deployment may set require_auth = false; the UI then uses the same-origin API without a token prompt. Every client that can reach an unauthenticated listener can control SwarmOtter. Browser requests must remain same-origin, and reverse proxies must preserve the public Host.

Add torrents

The Web UI supports:

  • Magnet link entry.
  • File picker upload for .torrent files.
  • Drag-and-drop upload for .torrent files anywhere in the app window.
  • Metadata-preview checkboxes for magnet and .torrent intake.

Dropped .torrent files are sent to:

POST /api/v1/torrents/file

The app refreshes the torrent list after successful upload.

Selecting Metadata preview adds a .torrent in a paused state, or lets a magnet fetch and verify only its metadata through the contained daemon path. Once a magnet preview has its file list, Torrent Details shows the captured intake policy and the payload gate. Choose file priorities as needed, then use Start or Resume to allow normal payload transfer. A preview never turns into a payload download merely because a profile or queue setting changes.

Torrent list

The Peers column shows active peer workers / known peers from the torrent summary response. The main UI area uses the available browser width so wide tables can show operational details without being capped to a narrow centered column. Per-row torrent actions are icon buttons with accessible labels. The Details action opens keyboard-accessible lifecycle, queue, move, label, bandwidth-limit, file-rename, and tracker-edit controls. Removing one torrent offers separate Cancel, keep-data, and delete-data choices.

Torrent Details displays an explicit identity row. Hybrid torrents show both their v1 SHA-1 and v2 SHA-256 identifiers; this avoids presenting the v2 value as if it were a v1 registry hash. Older daemon responses retain the legacy v1-hash fallback during an upgrade.

The torrent list is an interactive table. Click a column header to sort by that column, and click it again to reverse the direction. Header filters can filter individual columns: status and health use list filters, while numeric columns such as size, progress, rates, ratio, and peers accept comparisons such as > 0, >= 50, < 10, or = 1. The toolbar search remains a global filter across common torrent summary fields, and Clear Filters resets both the toolbar search and column filters.

Torrent rows can be selected with checkboxes. The torrent toolbar can select all currently visible rows, clear the current selection, and remove all selected torrents. Bulk removal removes torrent records through POST /api/v1/torrents/remove and keeps downloaded data.

Tracker details

Torrent Details → Trackers keeps announce status separate from scrape status. The table shows the last scrape time, retained seeders/leechers/downloads in S / L / D order, and the compatibility counts used elsewhere. A scrape error is displayed beside error while the last successful counts remain visible; unsupported means the tracker is UDP or its final path is not derivable from announce*. UDP announce remains supported—only UDP scrape is unsupported.

All tracker URL, status, time, count, and error values are escaped before being inserted into the table. Scrape is operational telemetry scheduled by download, magnet, reannounce, completion, and active seeder tracker activity; it is not a separate user mutation.

The Details summary also displays Last error from the native torrent summary. If every attempted configured tracker fails and no usable alternative source exists, the state becomes tracker error and this row retains the last tracker failure. Reannounce, Resume, or Start Now clears the terminal error and starts a new attempt.

Per-torrent seeding policy

Torrent Details includes a Seeding Policy card. Its read-only summary reports the uploaded-byte count, ratio, exact seeding status, stored ratio/idle targets, effective ratio/idle targets after global inheritance, and whether seed-forever is enabled. Status values are displayed as not eligible, queued, active, stopped ratio, stopped idle, or stopped manual.

Use the Ratio target and Idle target controls as follows:

  • Select Inherit global ratio or Inherit global idle to store null and use the corresponding value from Settings > Seeding.
  • Clear inheritance and enter 0 to request an immediate automatic stop. Zero is a real target; it is not the same as inheritance.
  • Select Seed forever to suppress both effective automatic targets while preserving the stored overrides for later use.

Save Seeding Policy replaces all three per-torrent fields together. The UI waits for the server response and reloads Torrent Details before displaying the new summary; it does not predict a status transition locally. Invalid input or a persistence failure is shown in the card’s alert and leaves the last rendered stored/effective values unchanged. A policy edit never resumes a torrent that an operator manually paused; use Resume or Start Now when that is intentional.

Large-library operations console

For large libraries, the Operations Console is optimized for speed and low layout churn. The list is designed for high-count visibility with:

  • server-side search plus state, health, and performance-condition filters,
  • table sorting that round-trips through the server query endpoint,
  • a browser-local saved view for search/filter/page-size/sort state,
  • count-oriented list requests and pagination for incremental refresh,
  • clear confirmation paths for bulk destructive operations, and
  • detail views that avoid forcing a full table reload.

The underlying /api/v1/torrents/query endpoint also supports label, storage root, peer/rate threshold, counts-only, and optional grouping parameters for external automation and future UI views.

Protocol encryption controls

SwarmOtter can negotiate MSE/PE peer encryption. The Settings screen exposes torrent.encryption_mode with these choices:

  • disabled (plaintext handshakes only),
  • preferred (contained TCP/uTP attempts use MSE/PE first, with a plaintext fallback only on the same selected transport),
  • required (refuse plaintext with no fallback).

The default is preferred. The UI keeps this control in the same Settings edit flow as other daemon config because it changes peer-wire compatibility behavior.

Policy profiles

Settings includes a Policy profiles editor for the persisted profiles configuration section. The Add screen can choose a profile and labels before registration. Torrent Details shows every effective profile value with its source and can set or clear an explicit profile assignment. Profiles may set an optional encryption_mode; Torrent Details shows its effective source and can set an explicit per-torrent encryption mode or choose Inherit profile or global mode to send an explicit null clear. Storage paths and the initial start-or-paused decision are shown as create-time snapshots: profile reassignment does not move existing data or revoke a queued torrent’s admission. Queue priority, seeding, bandwidth, and peer encryption remain explainable live inheritance.

Profiles can also define ordered tracker-host enablement/priority plus create-time intake exclusions, complete/incomplete content organization, single-file top-level folders, and active-only partial suffixes. Torrent Details displays the effective live tracker policy, stored structured exclusion rules, organization values, resolved complete/incomplete path preview, explicitly unwanted file indices, and whether a metadata-preview gate remains active. Those intake choices are fixed at registration; later profile edits do not silently alter an existing torrent, while tracker-host policy remains live.

Peer admission

Settings includes a Peer admission panel for the global local-rule, blocklist-path, manual-ban, and peer-ID-prefix policy. It reads the live policy from GET /api/v1/peer-filter, showing the effective direct rules, local-source load/skipped-line results, manual bans, rejection counters, and any fail-closed detail. The editable fields remain part of the full Settings configuration snapshot, so reloading or saving Settings preserves the complete peer-admission configuration rather than overwriting it with status data.

The torrent Details Peers tab can ban an IP. These are global manual bans, not torrent-local exceptions, and the Settings panel lists them with a global Unban action through POST /api/v1/peer-filter/unban. A peer row is marked banned only when its IP is in that explicit manual-ban list; merely viewing the table does not perform a new admission decision or change rejection counters. Peer admission rejects unwanted candidates but does not replace the required contained network path.

Storage root diagnostics

The Doctor view surfaces storage diagnostics from GET /api/v1/storage/roots so operators can:

  • review per-root free/available bytes before large add bursts,
  • identify which roots are close to configured reserve thresholds, and
  • diagnose storage pressure alongside active write/recheck activity and configured root controls, mount options, CoW strategy support, and observed sustained payload-write/verification throughput.

Storage reserve fields in configuration are [storage].minimum_free_space_bytes and [storage].minimum_free_space_percent. When configured, add operations are rejected before writing data when the target root cannot satisfy the configured reserve.

The Storage settings panel also manages repeatable [[storage.root_controls]] entries. Each row exposes a lexical path plus active-download, active-byte, write-rate, and concurrent-recheck limits. The Doctor table shows the matching control root, declared active bytes, active rechecks, and saturation warnings so an operator can distinguish a local root budget from global queue limits. It also exposes durable placement for fast-resume metadata, daemon-state defaults, and fallback temporary payload storage, plus the explicit CoW strategy. A state-directory change is shown as restart-required; a resume directory change is rejected while unfinished data remains. The UI does not offer an implicit filesystem optimization: NOCOW is explicit, applies only to new supported Btrfs files, leaves existing files unchanged, and errors rather than falling back silently when an existing writable file lacks the flag.

Performance diagnostics and autopilot visibility

The torrent detail view uses /api/v1/torrents/:hash/stats as its primary diagnostic source. Existing health sub-scores and reasons are the basis for the autopilot-oriented “why is this slow?” explanation and are updated from the same contained network observations as engine and network health reporting. In act mode, the daemon may apply bounded actions from those observations; the details page shows the current decision and rationale.

In autopilot visibility mode, the UI reads:

  • GET /api/v1/autopilot/status for the global autopilot mode.
  • GET /api/v1/network/health and GET /api/v1/network/diagnostics for any containment condition that may block or bias tuning decisions.
  • GET /api/v1/torrents/:hash/stats for peer-level health and scheduler signals.
  • GET /api/v1/torrents/:hash/autopilot and POST /api/v1/torrents/:hash/autopilot for per-torrent decision views and mode override controls.

The Settings tab includes an Autopilot card for the global disabled / observe / act mode. The default is act, and Torrent Details keeps the per-torrent override control.

The Settings screen uses a two-panel layout: section navigation on the left and the selected settings group on the right. Save, reload, and reset controls sit in the Settings header. Saving submits the full configuration snapshot. If an operator intentionally makes the config path read-only, a failed persistence attempt falls back only to the live-safe bandwidth, queue, seeding, and autopilot PATCH; the UI reports that other changes were not applied.

The details page renders a compact “why is this slow?” report with these fields:

  • active/global/autopilot mode state.
  • machine-readable reason identifiers and recommendations or applied-action candidates.
  • no-progress queue-slot release recommendations when a stalled active torrent is eligible to let queued work proceed.
  • snapshot signals and network-conditions impact for operational context.

The UI should present autopilot recommendations as human-readable entries with underlying machine-readable identifiers (for operators and automation clients) and continue to honor the fail-closed containment model.

Notifications

Transient operation feedback is shown as toast notifications instead of inline status text. This includes torrent add/upload results, user-initiated torrent removal, external removals observed while the complete unfiltered library is visible, bandwidth setting saves, and watch-folder scan results. Filtered or paginated result changes are never treated as proof that a torrent was removed.

Toasts display for 5 seconds by default. The display time is a browser-local UI preference that can be changed in Settings > Notifications.

Network health

The UI shows network containment health from:

GET /api/v1/network/health

Detailed network checks and path diagnostics use:

GET /api/v1/network/diagnostics

SOCKS5 TCP proxy

Settings > Network exposes an opt-in SOCKS5 TCP CONNECT card with proxy host, port, and optional username/password fields. Enabling it clears the uTP and DHT controls because this release deliberately blocks UDP tracker, DHT, and uTP paths rather than sending them directly outside the proxy. Server-side validation remains authoritative if a client submits an incompatible full configuration.

The Settings API never returns the SOCKS5 password to the browser, so the password field is blank after reload. Saving it blank preserves the existing credential only while the username is unchanged; clear the username to remove authentication, or supply both a new username and password to replace it. The Network summary reports only that SOCKS5 is enabled and TCP-only/UDP-blocked; it does not display the proxy host or credentials.

Router port mapping and listener reachability

The Network view has separate cards for Router port mapping and Listen-port reachability. Both are opt-in diagnostics and controls for the configured TCP peer listener; neither a failed mapping nor a failed reachability result pauses, resumes, or otherwise changes torrent lifecycle.

Router mapping is disabled by default. In Settings > Network, enable it only when the daemon uses strict network containment with fail-closed behavior and a required interface. The same settings card chooses NAT-PMP and/or UPnP IGD, optionally supplies a NAT-PMP gateway or UPnP control URL, and sets the requested lease and renewal lead time. Saving Settings preserves these values in the full configuration snapshot.

The mapping card shows the current state, configured protocols, local and external ports, active protocol, local gateway diagnostic, last attempt, lease expiry, and bounded detail. Refresh mapping requests an immediate reconciliation through the contained network path. The daemon renews successful leases and attempts a best-effort deletion during graceful shutdown; it never falls back to a default-route socket when a contained path or router is unavailable.

The reachability test is configured separately with an HTTP(S) endpoint the operator controls, a cache lifetime, and a request timeout. The Network card shows only whether an endpoint is configured—not its URL—alongside the open/closed/unknown/error result and cache timing. Run port test uses the same contained path and reuses a fresh cached result. A successful router mapping asks an enabled reachability test to run, but the two results remain independent: router acceptance does not prove external reachability, and a test failure does not invalidate a mapping.

If the UI shows interface_missing, the daemon cannot see the configured interface name in its current network namespace. See Troubleshooting.

Logs, Watch status, and doctor report

Operational diagnostics in the UI come from:

  • GET /api/v1/watch/status for enabled folders and recent watch-folder activity.
  • GET /api/v1/logs/recent for live-tail style log snapshots.
  • GET /api/v1/doctor for a consolidated operational check summary.
  • GET /api/v1/version for the application version shown in the Doctor view.

The Watch history table has a separate stable Outcome column: imported, duplicate, permanent failure, or transient failure. Duplicate means the existing torrent was retained unchanged and the configured success action ran. Transient failures remain eligible for a later stable scan; permanent failures do not retry an unchanged fingerprint. The Status column is warning-colored when post_action_error is present even if the primary outcome is imported or duplicate, and Detail shows both the primary error and archive/delete/failure- move error so the operator can resolve a retained source or destination collision. Pending counts include unseen, changed, stabilizing, and transient- retry files but exclude unchanged processed files. Watch history contains only the current daemon run and retains its newest 10,000 results.

The Settings view also exposes a destructive Reset action. After confirmation, it calls POST /api/v1/reset to stop torrent activity, remove torrent records, empty the configured download and incomplete directories while preserving those root directories, and clear daemon log files.

Browser assets

The daemon serves the Web UI favicon set and app manifest from the embedded graphics assets. The header uses the SwarmOtter icon next to the app name and includes a light/dark theme icon. The Web UI defaults to dark mode and stores the selected theme in browser localStorage under swarmotter.theme. Web assets use a self-only content security policy and cannot be framed by another site.

Deployment

Upgrading from 1.x to v2.0.0

v2.0.0 makes strict containment the configuration default. An installation that previously omitted [network] no longer starts with torrent containment disabled; it fails validation until an enforceable interface, source address, or namespace is configured. Explicit mode = "disabled" remains limited to development or deployments where a separate boundary, such as the supplied Gluetun shared namespace, provides fail-closed containment. Run swarmotterd --check-config --config PATH against the migrated configuration before restarting a package, systemd, or container deployment.

Basic Linux service

Build the daemon:

cargo build --release

Install a private config for a foreground run:

install -d -m 0700 "$HOME/.config/swarmotter"
install -m 0600 config/swarmotter.toml.example "$HOME/.config/swarmotter/swarmotter.toml"

Edit $HOME/.config/swarmotter/swarmotter.toml, then run:

umask 077
./target/release/swarmotterd --config "$HOME/.config/swarmotter/swarmotter.toml"

Do not omit [network]: omission selects strict mode without a path and fails startup validation. Configure the intended interface/source/namespace, or use explicit mode = "disabled" only when another boundary provides fail-closed containment.

Logs are written to stderr and to the configured daemon log file. With default logging, the per-user file is ~/.local/state/swarmotter/swarmotterd.log unless XDG_STATE_HOME is set.

Strict route and DNS validation use the Linux ip route get command. Direct and tarball installations must provide the ip utility through iproute2 on Debian/Ubuntu or iproute on Fedora/RHEL-family systems. The official container image and native packages include or declare this dependency.

Release Artifacts

Version tags publish Linux-native artifacts on GitHub Releases:

  • Linux x86_64 and aarch64 tarballs.
  • .deb packages for amd64 and arm64.
  • .rpm packages for x86_64 and aarch64.
  • SHA256SUMS for the release assets.

The tarballs include bin/swarmotterd, configuration examples, deployment examples, and the user-guide pages needed for local install review. The packages install:

  • /usr/bin/swarmotterd
  • /etc/swarmotter/swarmotter.toml
  • /usr/lib/systemd/system/swarmotterd.service
  • /var/lib/swarmotter, /data/downloads, and /data/incomplete

Package installation creates the swarmotter service account and reloads systemd metadata. It also installs the distribution package that supplies the Linux ip utility used by strict route validation. The package keeps /etc/swarmotter mode 0700 and the config mode 0600, both owned by the service account. This lets validated Web UI settings updates use atomic replacement without exposing the API token to other local users.

Package installation does not start the daemon automatically. Review /etc/swarmotter/swarmotter.toml, make sure the configured containment path exists, then enable the service:

sudo systemctl enable --now swarmotterd

Systemd

An example unit is provided in:

deploy/swarmotterd.service

Install it after installing the daemon binary, private service-owned config, service account, and storage directories (the native packages perform those steps):

sudo install -m 0644 deploy/swarmotterd.service /etc/systemd/system/swarmotterd.service
sudo systemctl daemon-reload
sudo systemctl enable --now swarmotterd

Make sure the service user owns the private config directory and can write the storage directories.

File descriptor requirements

SwarmOtter opens file descriptors for peer sessions, payload files, tracker requests, and contained UDP work:

  • Peer sessions: bounded process-wide by bandwidth.max_peers when it is nonzero, with max_peers_per_torrent as an additional cap (zero selects 64). TCP uses a stream socket; uTP uses a contained UDP socket for that session.
  • Tracker connections: transient TCP sockets during HTTP/HTTPS announces; UDP trackers use contained UDP sockets.
  • File handles: payload layout and active storage work can retain handles, especially for multi-file torrents.
  • Inbound listener: one shared contained TCP listener routes all registered seeding torrents, rather than one listener per torrent.
  • Other contained work: DHT, DNS, webseeds, and health validation add bounded transient overhead but are intentionally outside the peer-session permit count.

Set a nonzero process-wide max_peers when a hard peer descriptor bound is required, then reserve additional headroom for files, trackers, the shared listener, and control-plane descriptors. Measure /proc/$PID/fd under the intended workload; the default ulimit -n of 1,024 on many systems is commonly insufficient for a busy daemon.

Configuring file descriptor limits

The packaged systemd unit already includes:

[Service]
LimitNOFILE=65536

For shell sessions, add to /etc/security/limits.conf:

swarmotter soft nofile 65536
swarmotter hard nofile 65536

For standalone Docker containers, use the --ulimit flag:

docker run --ulimit nofile=65536:65536 ...

The provided compose.yml includes the equivalent setting:

services:
  swarmotter:
    ulimits:
      nofile:
        soft: 65536
        hard: 65536

Verify the limit is applied:

cat /proc/$(pgrep swarmotterd)/limits | grep "Max open files"

Homelab Docker Compose with Gluetun

The production container image is published to:

ghcr.io/sphildreth/swarmotter

Pull requests validate the Compose manifest but do not build or publish a container image. Successful pushes to main build and publish a multi-architecture image tagged as main and sha-<shortsha>. Version-tag releases publish linux/amd64 and linux/arm64 images tagged as vX.Y.Z, X.Y.Z, X.Y, X, and latest. After the first GHCR publish, set the package visibility to public in GitHub Packages if anonymous homelab pulls are desired.

What is Gluetun?

Gluetun is a containerized VPN client, firewall, and network namespace boundary. The official image is qmcgaw/gluetun. It supports common VPN providers and custom VPN configuration, including OpenVPN and WireGuard.

SwarmOtter uses Gluetun in the provided Compose stack because it gives the homelab deployment a clear torrent data-plane boundary:

  • VPN credentials live in deploy/gluetun.env, separate from the SwarmOtter API token.
  • The Gluetun container owns the tunnel device and firewall rules.
  • The SwarmOtter container joins Gluetun’s network namespace with network_mode: "service:vpn".
  • The API/Web UI port is published by the vpn service, while torrent peer, tracker, DHT, webseed, and torrent DNS traffic share Gluetun’s contained network path.

In this layout, Gluetun is the fail-closed boundary. If the VPN namespace or firewall is unhealthy, SwarmOtter’s torrent data plane cannot use the normal Docker bridge as a fallback. This follows Gluetun’s documented pattern for connecting another container to Gluetun’s network stack.

The provided Compose stack runs SwarmOtter in the Gluetun network namespace:

deploy/compose.yml

The SwarmOtter container config used by this stack disables in-app network containment because all SwarmOtter traffic shares Gluetun’s VPN namespace and firewall.

That explicit mode = "disabled" is specific to this shared-namespace design; it is not a general container default. Gluetun owns the VPN route, firewall, and kill switch, and network_mode: service:vpn prevents SwarmOtter from acquiring a separate Docker bridge path. A standalone container must instead configure a strict in-app path or use an equivalently enforced namespace.

The traffic layout looks like this:

flowchart TB
    lan["LAN browser or API client"]
    host["Docker host<br/>Port 9091 is published by the vpn service"]

    subgraph ns["Shared network namespace"]
        gluetun["Gluetun service: vpn<br/>owns /dev/net/tun<br/>manages the VPN tunnel<br/>enforces firewall and kill switch behavior"]
        swarmotter["SwarmOtter service<br/>network_mode: service:vpn<br/>API/Web UI listens on :9091<br/>torrent data plane shares the namespace"]
    end

    outside["Peers, trackers, DHT, and webseeds"]

    lan -->|"http://docker-host:9091"| host
    host --> swarmotter
    swarmotter -->|"torrent data-plane traffic"| gluetun
    gluetun -->|"VPN tunnel only"| outside

See Network Containment for the general fail-closed model and the difference between control-plane and data-plane traffic.

Prepare host directories:

sudo install -d -m 0700 -o 10001 -g 10001 /srv/swarmotter/config
sudo install -d -o 10001 -g 10001 /srv/swarmotter/state
sudo install -d -o 10001 -g 10001 /srv/swarmotter/downloads
sudo install -d -o 10001 -g 10001 /srv/swarmotter/incomplete
sudo install -d /srv/swarmotter/gluetun
sudo install -m 0600 -o 10001 -g 10001 config/swarmotter.container.toml.example /srv/swarmotter/config/swarmotter.toml

SWARMOTTER_CONFIG_DIR in .env names this directory. Compose mounts the directory read/write so atomic settings replacement works; keep it mode 0700 and owned by container UID/GID 10001.

Create and edit the Compose environment file:

cd deploy
cp .env.example .env
cp gluetun.env.example gluetun.env
openssl rand -hex 32

Set SWARMOTTER_API_TOKEN in .env to the generated token. Fill in gluetun.env with the settings required by your VPN provider. For custom WireGuard providers, this usually includes WIREGUARD_PRIVATE_KEY, WIREGUARD_ADDRESSES, WIREGUARD_PUBLIC_KEY, WIREGUARD_ENDPOINT_IP, and WIREGUARD_ENDPOINT_PORT. The split keeps the SwarmOtter API token out of the Gluetun container environment.

Keep FIREWALL_INPUT_PORTS=9091 in gluetun.env unless the internal SwarmOtter API port changes. This lets the API/Web UI control plane through Gluetun’s default-interface firewall while torrent data-plane traffic remains inside the Gluetun VPN namespace.

Validate and start the stack:

docker compose --env-file .env -f compose.yml config
docker compose --env-file .env -f compose.yml pull
docker compose --env-file .env -f compose.yml up -d

Verify the API and image:

curl -fsS http://localhost:9091/health
docker buildx imagetools inspect ghcr.io/sphildreth/swarmotter:latest
docker compose --env-file .env -f compose.yml exec swarmotter curl -fsS https://ifconfig.me

Update explicitly when a new stable release image is published:

cd deploy
docker compose --env-file .env -f compose.yml pull swarmotter
docker compose --env-file .env -f compose.yml up -d swarmotter

The repository also includes an update helper for Compose-based Docker servers:

cd deploy
./update-swarmotter.sh

The helper is intended to run as a normal user with Docker access and sudo rights. Root-owned 0600 .env and gluetun.env files are supported; the helper uses sudo only where needed to read or update deployment secrets and state. With no image argument, it resolves the latest GitHub Release and uses the matching ghcr.io/sphildreth/swarmotter:vX.Y.Z image. If the running container already has that version label, the helper exits without backing up, pulling, or restarting. Otherwise, it backs up Compose environment files, SwarmOtter configuration, SwarmOtter state, and Gluetun state into ~/swarmotter-backups, updates SWARMOTTER_IMAGE, and asks supported target images to validate the mounted configuration before stopping the healthy stack. It then recreates the Compose stack so Docker attaches networks before Gluetun installs VPN routes, validates the health endpoint, image labels, and contained egress from the SwarmOtter container, and keeps a local rollback image tag. Failed validation also prints service status and recent container logs before rollback.

Pass an explicit image or tag only when pinning a specific release or performing a rollback:

./update-swarmotter.sh ghcr.io/sphildreth/swarmotter:v1.0.0

Use --force to back up, pull, recreate, and validate even when the installed version already matches the latest release:

./update-swarmotter.sh --force

For a pinned rollback, set SWARMOTTER_IMAGE in deploy/.env to a vX.Y.Z or sha-<shortsha> tag and run the update commands again.

LAN Web UI with contained torrents

This exposes the control plane to the LAN while binding torrent data-plane sockets to br0:

[api]
bind_address = "0.0.0.0:9091"
require_auth = true
auth_token = "replace-with-a-long-random-token"

[storage]
download_dir = "/mnt/incoming/swarmotter/downloads"
incomplete_dir = "/mnt/incoming/swarmotter/incomplete"

[network]
mode = "strict"
required_interface = "br0"
allow_ipv6 = true
fail_closed = true
validate_route = true
validate_dns = true

[torrent]
listen_port = 51413
allow_ipv6 = true
utp_enabled = true
utp_prefer_tcp = true
encryption_mode = "preferred"

For a LAN that is deliberately the control-plane trust boundary, set SWARMOTTER_API_REQUIRE_AUTH=false in .env and leave SWARMOTTER_API_TOKEN empty. The Web UI then uses the same-origin API without a token prompt. Every client that can reach port 9091 can control SwarmOtter, so keep authentication enabled on any network that is not fully trusted.

The service user needs write access to both storage directories. Incomplete torrents write to incomplete_dir; verified completed data is moved into download_dir.

Container or VPN namespace

For stronger isolation, run SwarmOtter inside a network namespace or container whose only torrent data-plane path is the intended VPN or NIC path.

Container sketch:

docker build -f deploy/Dockerfile -t swarmotter .

sudo install -d -m 0700 -o 10001 -g 10001 /srv/swarmotter/config
sudo install -d -o 10001 -g 10001 /srv/swarmotter/state
sudo install -d -o 10001 -g 10001 /srv/swarmotter/downloads
sudo install -d -o 10001 -g 10001 /srv/swarmotter/incomplete
sudo install -m 0600 -o 10001 -g 10001 \
  config/swarmotter.container.toml.example \
  /srv/swarmotter/config/swarmotter.toml

docker run -d --name swarmotter \
  --ulimit nofile=65536:65536 \
  -p 9091:9091 \
  -e SWARMOTTER_API__AUTH_TOKEN="$(openssl rand -hex 32)" \
  -v /srv/swarmotter/downloads:/data/downloads \
  -v /srv/swarmotter/incomplete:/data/incomplete \
  -v /srv/swarmotter/state:/var/lib/swarmotter \
  -v /srv/swarmotter/config:/etc/swarmotter \
  swarmotter

The container runs as UID/GID 10001. Keep the bind-mounted config directory owned by that account and mode 0700 so full settings replacement can create and atomically rename a mode-0600 config file.

Attach the container to the intended contained network instead of the default bridge when strict data-plane containment is required.

Recovering a latched bind failure

If network health reports socket_bind_failed or blocked_fail_closed, fixing the interface alone does not reopen torrent traffic. Correct the full configuration and submit it through PUT /api/v1/settings (or restart with an already-correct file). A live replacement clears the latch only after both an ephemeral contained UDP bind (unless SOCKS5 TCP-only mode is enabled) and the configured peer-listener bind validate. Failed validation leaves the old configuration and blocked gate in place. Use GET /api/v1/network/health and /api/v1/network/diagnostics to verify the result; do not switch strict mode to disabled as a recovery shortcut.

Reverse proxy

A reverse proxy may sit in front of the API/Web UI. Keep authentication enabled unless another trusted auth layer protects access. Terminate TLS at the proxy; the API token is a bearer credential and must not cross an untrusted network in plaintext. Preserve the public Host so same-origin browser validation works.

server {
    listen 80;
    server_name swarmotter.example;

    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name swarmotter.example;

    ssl_certificate /etc/letsencrypt/live/swarmotter.example/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/swarmotter.example/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:9091;
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location /api/v1/ws {
        proxy_pass http://127.0.0.1:9091;
        proxy_http_version 1.1;
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    location /api/v1/events {
        proxy_pass http://127.0.0.1:9091;
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_buffering off;
        proxy_read_timeout 1h;
    }
}

Troubleshooting

Where logs are recorded

SwarmOtter writes logs to stderr and to a file by default.

For a terminal run, logs appear in the terminal and are also recorded at:

$XDG_STATE_HOME/swarmotter/swarmotterd.log

If XDG_STATE_HOME is not set, the default is:

~/.local/state/swarmotter/swarmotterd.log

Override the file path when needed:

[logging]
file = true
file_path = "/var/log/swarmotter/swarmotterd.log"

For systemd deployments, logs are also available through the journal:

journalctl -u swarmotterd -f

missing field mode

Older builds required network.mode whenever [network] was present. Current SwarmOtter accepts this DHCP/SLAAC-safe configuration:

[network]
required_interface = "br0"

That partial table defaults to strict containment with IPv6 enabled. Rebuild and rerun the current binary if the daemon still reports:

missing field `mode`

Web UI shows interface_missing

interface_missing means the daemon cannot see the configured interface in its current network namespace.

Check the interface name on the same host or namespace where the daemon runs:

ip a show br0

Then confirm the config matches exactly:

[network]
required_interface = "br0"

Common causes:

  • The daemon is running inside a container that does not have br0.
  • The systemd unit runs in a different network namespace.
  • The interface name is different from the host interface name.
  • The daemon process lacks permission to create device-bound sockets when torrent networking starts.
  • You are running an older binary after editing source code.

Web UI shows no_interface_address

The interface exists and is up, but SwarmOtter did not find a usable address.

Check:

ip a show br0

For IPv6, both settings must allow it:

[network]
allow_ipv6 = true

[torrent]
allow_ipv6 = true

Web UI shows dns_not_constrained

This means strict containment was configured to validate DNS but DNS containment could not be proven.

For interface-bound configurations, first check whether Linux can see DNS on that interface:

resolvectl dns br0

If this reports DNS servers for br0, current SwarmOtter builds allow torrent hostname resolution through that constrained path.

If DNS cannot be proven constrained and you still set:

[network]
validate_dns = true

network health reports dns_not_constrained. Use a contained network namespace, container network, or IP-literal trackers/bootstrap nodes when the host cannot prove DNS is on the contained path.

IPv6 peers do not connect

Check all of the following:

[network]
allow_ipv6 = true

[torrent]
allow_ipv6 = true

Also confirm the interface has a usable IPv6 address:

ip -6 addr show dev br0
ip -6 route

If strict mode uses static source binding, required_source_ipv6 must match an address assigned to the configured path.

.torrent drag-and-drop does nothing

Only .torrent files are accepted by drag-and-drop. Check the browser console and daemon logs for upload errors, especially authentication failures and api.max_request_body_bytes rejections.

Increase the upload limit if needed:

[api]
max_request_body_bytes = 33554432

API requests fail with unauthorized

When api.require_auth = true, include one of these headers:

Authorization: Bearer <token>

or:

X-SwarmOtter-Auth: <token>

The Web UI uses the same API routes as external clients.

If a trusted-LAN deployment should not require a token, set api.require_auth = false in the mounted TOML configuration, or set SWARMOTTER_API_REQUIRE_AUTH=false for the Compose deployment. Non-loopback listeners log a warning because every reachable client can then control SwarmOtter.

Chrome extension POST returns extension_origin_forbidden

Chrome Manifest V3 service workers are cross-origin clients. A privileged extension request normally carries both:

Origin: chrome-extension://<32-character-extension-id>
Sec-Fetch-Site: none

SwarmOtter accepts that Origin only with authenticated API mode and a valid API token. Configure:

[api]
require_auth = true
auth_token = "replace-with-a-long-random-token"

Then send the same token on the extension service worker’s request:

Authorization: Bearer <token>

or:

X-SwarmOtter-Auth: <token>

Also grant the exact SwarmOtter API origin in the extension manifest’s host_permissions; HTTP and HTTPS permissions are separate. Do not try to set Origin or Sec-Fetch-Site in extension code—the browser owns those headers.

Check the native JSON error code and message:

  • extension_origin_forbidden: authenticated mode is off, the token is absent or invalid, an authentication header is duplicated, or both supported token header forms were sent together.
  • cross_origin_forbidden: Fetch Metadata, Origin, or Host failed the ordinary browser-origin policy. same-site/cross-site, foreign HTTP(S), null, opaque, malformed (including an invalid extension ID), and multi-value Origins remain intentionally rejected.

Setting only auth_token while require_auth = false does not enable extension access. SwarmOtter does not broadly trust all installed extensions on an unauthenticated listener.

Update helper health check reports connection resets

If deploy/update-swarmotter.sh reports repeated curl: (56) Recv failure: Connection reset by peer while checking http://127.0.0.1:9091/health, inspect the service status and recent logs printed by the updater. Current release images are also configuration-checked before the healthy stack is replaced. To distinguish a daemon failure from host port filtering, verify whether the daemon is healthy inside the shared Gluetun network namespace:

docker compose --env-file .env -f compose.yml exec swarmotter \
  curl -fsS http://127.0.0.1:9091/health

If that succeeds but host curl http://127.0.0.1:9091/health fails, Gluetun is blocking the published control-plane port. Set this in gluetun.env:

FIREWALL_INPUT_PORTS=9091

This opens the SwarmOtter API/Web UI port on Gluetun’s default interface. It does not expose torrent peer, tracker, DHT, webseed, or torrent DNS traffic outside the Gluetun VPN namespace.

Torrents are added but stay at 0 B/s

If torrents appear in the Web UI but stay at 0 B/s, check tracker status:

curl -sS http://127.0.0.1:9091/api/v1/torrents/<info_hash>/trackers

Check live per-torrent counters and engine diagnostics:

curl -sS http://127.0.0.1:9091/api/v1/torrents/<info_hash>/stats

Useful fields:

  • rate_down, rate_up: smoothed transfer rates in bytes/sec.
  • active_peer_workers: current bounded peer download workers.
  • known_peers: peers currently discovered by trackers, DHT, PEX, or direct input.
  • peer_scheduler: live scheduler counts showing discovered, eligible, filtered, failed-backoff, no-progress-backoff, parallel candidate, worker limit, and serial-fallback state. Use this when known_peers is high but active_peer_workers is low or zero.
  • useful_peers: connected peers observed with pieces the torrent still needs and an unchoked or recently useful state.
  • unchoked_peers: connected peers the engine has observed as unchoked.
  • choked_peers: reserved for explicit choke-state telemetry; currently null until the engine records positive per-peer choke state.
  • recent_peer_failures, recent_tracker_failures: recent failed peer sessions and tracker announce/scrape failures reported by the live engine.
  • tracker_ok, tracker_message, last_announce: last tracker announce status from the live engine.
  • tracker_last_ok_seconds_ago, dht_last_seen_seconds_ago, pex_last_seen_seconds_ago: freshness of the last successful tracker, DHT, and PEX discovery signals when live engine data is available.
  • dht_discovery_ok, pex_discovery_ok: whether DHT or PEX discovery has succeeded recently in the live engine.

Tracker rows from /api/v1/torrents/<info_hash>/trackers report per-tracker announce and scrape results. last_error/last_message remain announce-only. scrape_status, last_scrape, nullable scrape_seeders/scrape_leechers/ scrape_downloads, and last_scrape_error describe scrape. A failed scrape retains the previous successful counts. unsupported is expected for UDP and HTTP(S) URLs whose final path does not begin with announce; it does not mean UDP announce failed. If announce is not successful, compatibility seed/leech counts fall back to retained scrape data.

Common causes:

  • The torrent has no live seeders.
  • The tracker hostnames cannot resolve under strict DNS containment.
  • UDP tracker traffic is blocked by the network path.
  • A supported HTTP(S) scrape is redirected to an HTTPS-to-HTTP downgrade, returns malformed/missing exact-key BEP 48 data, or exceeds the decoded cap.
  • Only WebTorrent wss:// trackers are present; those are not BitTorrent TCP or UDP trackers.

In strict interface mode, hostname trackers and DHT bootstrap hostnames need constrained DNS. On Linux, SwarmOtter accepts systemd-resolved link DNS for the required interface, for example DNS servers shown by resolvectl dns br0.

Performance with large libraries (1,000+ torrents)

When managing large torrent libraries, monitor these indicators:

Symptoms of resource exhaustion

  • API responses slow down significantly (multiple seconds).
  • SSE/WebSocket subscribers receive events_dropped lag notifications.
  • Torrents stay in queued state despite available slots.
  • Daemon logs show repeated peer connection failures or tracker timeouts.
  • High CPU usage from lock contention or excessive reconciliation.

Check file descriptor usage

Peer-session descriptors are bounded by a nonzero max_peers; payload files, trackers, DHT, the shared listener, and the control plane add workload-specific overhead. Check the daemon’s current limit and usage:

PID=$(pgrep swarmotterd)
cat /proc/$PID/limits | grep "Max open files"
ls /proc/$PID/fd | wc -l

If usage approaches the limit, increase it (see Deployment).

Check scheduler saturation

The stats endpoint reports scheduler pressure:

curl -sS http://127.0.0.1:9091/api/v1/stats | jq .scheduler

Key fields:

  • requested_downloads vs granted_downloads: if requested exceeds granted, the download slot cap is the bottleneck.
  • requested_metadata_fetches vs granted_metadata_fetches: if requested exceeds granted, the metadata fetch slot cap is the bottleneck.
  • peer_limit, peer_permits_in_use, and peer_permits_available: the authoritative process-wide peer-session cap and current usage. Available is null when unlimited.
  • peer_sessions_denied: inbound sockets rejected before session start by an applicable global or per-torrent cap.
  • peer_worker_budget_saturated (and legacy peer-worker budget fields): engine worker-pressure compatibility telemetry. It does not mean the process-wide peer connection cap is full; use the permit fields above for that decision.
  • retry_backoff_torrents: high values indicate many torrents waiting for retry after transient failures.

Check event subscriber lag

If SSE or WebSocket clients report events_dropped, the broadcast buffer (default 4,096) is overflowing. This happens during reconciliation bursts when many torrents change state simultaneously. Clients should reconnect and request a full state refresh after receiving a lag notification.

Reduce resource pressure

If performance degrades with large libraries:

  1. Lower max_active_downloads to reduce concurrent peer connections.
  2. Lower max_peers_per_torrent to reduce per-torrent resource usage.
  3. Set a global max_peers cap to bound total connection count.
  4. Ensure file descriptor limits are sufficient (65,536+ for 1,000 torrents).
  5. Enable autopilot.mode = "act" for automatic stalled-torrent mitigation.

Lawful Use

SwarmOtter is a neutral, general-purpose BitTorrent client. BitTorrent has substantial lawful uses, and SwarmOtter is intended for downloading, sharing, and seeding content that users have the right to access and distribute.

Appropriate examples include:

  • Linux distributions officially distributed by torrent.
  • Open-source project releases.
  • Public-domain media.
  • Open datasets.
  • User-owned files.
  • Organization-approved internal distribution.
  • Generated local test torrents.

Users are responsible for ensuring their use complies with applicable laws and the rights of content owners. SwarmOtter does not provide legal advice.

What SwarmOtter does not provide

SwarmOtter does not provide:

  • Torrent indexes.
  • Infringing-content search.
  • Bundled copyrighted .torrent files.
  • Infringing magnet links.
  • Default tracker lists aimed at infringing content.
  • Guidance for finding unauthorized content.

Network containment framing

VPN/NIC containment is documented as routing correctness, privacy-preserving network design, operational safety, container networking, and fail-closed behavior. It is not a feature for copyright infringement or evasion.

Legal and Content Policy

This page summarizes SwarmOtter’s plain-language legal and content posture. It is project documentation and policy, not legal advice.

Project posture

  • SwarmOtter is a neutral, general-purpose BitTorrent client.
  • The project does not provide content.
  • The project does not index content.
  • The project does not encourage infringement.
  • Users are solely responsible for their own compliance with applicable law.

Project scope

The SwarmOtter project defines what it will and will not include in its repositories, documentation, examples, release artifacts, and project-hosted assets. This is a statement of project scope, not a mechanism for monitoring or policing user behavior. The project has no observability into how users deploy the software.

Prohibited project content

Official project repositories, documentation, examples, release artifacts, and project-hosted assets must not include:

  • Pirate indexers.
  • Search integrations aimed at infringing content.
  • Bundled copyrighted .torrent files.
  • Bundled infringing magnet links.
  • Default tracker lists associated with infringing content.
  • Documentation encouraging copyright infringement.
  • Documentation explaining how to find unauthorized content.
  • Screenshots based on copyrighted movies, shows, commercial games, music albums, ROM collections, or cracked software.
  • Wording that frames VPN/NIC binding as a way to hide infringement.

License

SwarmOtter source code is licensed under the Apache License, Version 2.0. See the repository LICENSE file.

Project policy belongs in documentation. It is not an extra restriction added to the Apache-2.0 license.

Branding

The SwarmOtter name, logo, and other brand assets may be subject to separate brand or trademark rules distinct from the source code license.