SwarmOtter User Guide
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:
AuthorizationandX-SwarmOtter-Authare 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
9091by 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
.torrentmetadata viametainfo
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>(andX-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/versionGET /api/v2/app/webapiVersionGET /api/v2/torrents/infoPOST /api/v2/torrents/addPOST /api/v2/torrents/deletePOST /api/v2/torrents/pausePOST /api/v2/torrents/resumePOST /api/v2/torrents/startPOST /api/v2/torrents/stopPOST /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/settingsupdates live-safe fields (bandwidth, queue, and seeding policy).PUT /api/v1/settingsreplaces the full config after validation and persists it atomically. The existingapi.auth_tokenis preserved when omitted from the request body. A redactednetwork.socks5.passwordis 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:
- The systemd
STATE_DIRECTORY, asstate.json. /var/lib/swarmotter/state.jsonwhen that directory exists.$XDG_STATE_HOME/swarmotter/state.json.$HOME/.local/state/swarmotter/state.json../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 = truetorrent.utp_prefer_tcp = truetorrent.allow_ipv6 = truenetwork.allow_ipv6 = truetorrent.encryption_mode = "preferred"dht.enabled = truepex.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 toact. modeis one ofdisabled,observe, oract.- In
observemode, SwarmOtter reports slowdown causes without applying tuning actions. - In
actmode, 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]
| Option | Default | Meaning |
|---|---|---|
bind_address | "127.0.0.1:9091" | Address for the Web UI and API control plane. |
require_auth | false | Requires API/Web UI token auth. Strongly recommended for non-loopback listeners. |
auth_token | unset | Required when require_auth = true. |
max_request_body_bytes | 16777216 | Maximum 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]
| Option | Default | Meaning |
|---|---|---|
enabled | false | Enable 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>andX-SwarmOtter-Auth: <token>are accepted. - For qBittorrent-style cookie sessions, POST to
/api/v2/auth/loginwith credentials wherepasswordmatchesapi.auth_token; the response sets aSIDcookie that can be reused for subsequent/api/v2requests.
Represented compatibility endpoints used by automation include:
GET /api/v2/app/versionGET /api/v2/app/webapiVersionGET /api/v2/torrents/infoPOST /api/v2/torrents/addPOST /api/v2/torrents/deletePOST /api/v2/torrents/pausePOST /api/v2/torrents/resumePOST /api/v2/torrents/startPOST /api/v2/torrents/stopPOST /api/v2/torrents/setCategory
The adapter is intentionally limited: no indexer/search/discovery endpoints are exposed through the compatibility surface.
[compatibility.transmission]
| Option | Default | Meaning |
|---|---|---|
enabled | false | Enable 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]
| Option | Default | Meaning |
|---|---|---|
download_dir | unset | Final directory for verified completed downloads. |
incomplete_dir | unset | Active write directory for incomplete downloads. |
resume_dir | unset | Dedicated durable fast-resume directory. Resume filenames use the full canonical torrent locator; unset preserves adjacent active-data placement. |
state_dir | unset | Default 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_dir | unset | Root for the fallback swarmotter-downloads payload layout when download_dir is unset. It does not relocate atomic state/resume temporary files. |
preallocate | false | Pre-size files before downloading. |
sparse | true | When false, active payload files are sized up front even if preallocate = false. |
cow_strategy | conservative | conservative 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_bytes | 0 | If > 0, reject new adds when target-root usable space falls below this number of bytes. |
minimum_free_space_percent | 0 | If > 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
| Option | Default | Meaning |
|---|---|---|
path | required | Lexical root path. A control applies to this path and descendants. |
max_active_downloads | 0 | Maximum active torrent engines on the root; 0 is unlimited. |
max_active_bytes | 0 | Maximum sum of admitted declared payload bytes on the root; 0 is unlimited. |
max_write_bytes_per_second | 0 | Shared sustained rate for verified local payload writes; 0 is unlimited. |
max_concurrent_rechecks | 0 | Maximum 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]
| Option | Default | Meaning |
|---|---|---|
mode | strict | Torrent 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_interface | unset | Interface name, such as br0 or tun0. |
required_source_ipv4 | unset | Required IPv4 source address. |
required_source_ipv6 | unset | Required IPv6 source address. |
required_network_namespace | unset | Required Linux network namespace name. |
allow_ipv6 | true | Enables IPv6 torrent networking when the path is contained. |
fail_closed | true | Blocks torrent networking when strict containment is unhealthy. |
validate_route | false | Requires route validation when supported by the probe. |
validate_dns | false | Reports 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
| Option | Default | Meaning |
|---|---|---|
enabled | false | Enables the contained SOCKS5 TCP CONNECT wrapper. |
host | unset | Required when enabled. The proxy hostname is resolved through the configured containment path. |
port | 1080 | SOCKS5 listener port; must be nonzero. |
username | unset | RFC 1929 username. Must be supplied together with password, or both must be omitted for no-authentication. |
password | unset | RFC 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]
| Option | Default | Meaning |
|---|---|---|
mode | act | Autopilot mode: disabled (no analysis), observe (reasons only), or act (reasons plus bounded automatic actions). |
[torrent]
| Option | Default | Meaning |
|---|---|---|
listen_port | 51413 | Inbound peer TCP and DHT/uTP UDP port. |
allow_ipv6 | true | Enables IPv6 peers when network containment also allows IPv6; when false, IPv6 peers are filtered before connecting. |
utp_enabled | true | Enables uTP peer transport through contained UDP sockets. |
utp_prefer_tcp | true | Tries TCP first, with uTP fallback. |
encryption_mode | preferred | Contained 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. |
selfish | false | Removes 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]
| Option | Default | Meaning |
|---|---|---|
enabled | false | Opts 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_gateway | unset | Optional IPv4 NAT-PMP gateway. Without it, Linux discovery is limited to the configured interface. |
upnp_service_url | unset | Optional HTTP WANIP/WANPPP control URL; credentials and fragments are rejected. Without it, contained SSDP discovery is used. |
lease_seconds | 3600 | Requested mapping lifetime, bounded to seven days. |
refresh_before_expiry_seconds | 300 | Lead time for renewal; must be positive and shorter than the lease. |
[port_test]
| Option | Default | Meaning |
|---|---|---|
enabled | false | Opts into an operator-configured external TCP listener test. |
endpoint | unset | Required HTTP(S) URL when enabled. It is contacted only through the data-plane binder and is not shown in status responses. |
cache_ttl_seconds | 900 | Reuse window for the latest result, from 1 through 86,400 seconds. |
timeout_seconds | 10 | Per-request bound, from 1 through 30 seconds. |
[bandwidth]
| Option | Default | Meaning |
|---|---|---|
global_download | 0 | Global download bytes/sec, 0 means unlimited. |
global_upload | 0 | Global upload bytes/sec, 0 means unlimited. |
alt_download | 0 | Alternate download bytes/sec. |
alt_upload | 0 | Alternate upload bytes/sec. |
alt_enabled | false | Uses alternate limits when true. |
max_peers | 0 | Exact 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_torrent | 0 | Additional per-torrent session cap shared by inbound and outbound peers. 0 uses the daemon default of 64. |
[queue]
| Option | Default | Meaning |
|---|---|---|
max_active_downloads | 5 | Simultaneous active downloads, 0 means unlimited. |
max_active_metadata_fetches | 100 | Simultaneous active magnet metadata fetches, 0 means unlimited. Does not consume download/seed active slots. |
max_active_seeds | 5 | Simultaneous active seeds, 0 means unlimited. |
auto_start | true | Starts 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_downloadsto 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_fetchesto limit concurrent magnet metadata fetches (default 100). High values can cause tracker rate limiting. - Set
max_active_seedsto limit concurrent seeders (default 5). Seeding torrents consume upload bandwidth and peer connections.
Peer limits:
- Set
max_peersto 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_torrentto 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_downloadandglobal_uploadto 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_peerswhen 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]
| Option | Default | Meaning |
|---|---|---|
global_ratio_limit | 2.0 | Stops seeding after this ratio, unless overridden. |
global_idle_limit | 1800 | Stops 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]
| Option | Default | Meaning |
|---|---|---|
enabled | true | Enables DHT for non-private torrents. |
port | 51413 | Local UDP port used by the shared DHT runner. |
bootstrap_nodes | built-in public bootstrap hostnames | DHT bootstrap nodes. |
In strict mode, bootstrap hostnames are subject to DNS containment policy.
[pex]
| Option | Default | Meaning |
|---|---|---|
enabled | true | Enables peer exchange for non-private torrents. |
max_peers | 0 | PEX peer addition cap, 0 means unlimited. |
[peer_filter]
| Option | Default | Meaning |
|---|---|---|
enabled | false | Enables 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]]
| Option | Default | Meaning |
|---|---|---|
path | required | Folder to scan for .torrent files. |
recursive | false | Scans child folders when true. |
download_dir | unset | Per-watch download directory override. |
label | unset | Label applied to imports. |
profile | unset | Named profile applied before the torrent is registered. It must exist in [profiles.profiles]. |
start_behavior | "start" | "start" or "paused". |
archive_dir | unset | Where imported files are archived. |
failure_dir | unset | Where failed imports are moved. |
delete_after_import | true | Deletes 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]
| Option | Default | Meaning |
|---|---|---|
level | "info" | Log level. |
json | false | Emits JSON logs when true. |
file | true | Records daemon logs to a file as well as stderr/journal. |
file_path | unset | Log 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
athroughp, with no port, path, query, or fragment; Hostis one valid authority and Fetch Metadata is otherwise permitted;api.require_auth = true; and- exactly one
Authorization: Bearer <token>orX-SwarmOtter-Auth: <token>value matchesapi.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.
| Method | Path | Description |
|---|---|---|
| GET | /health | Daemon and network health. |
| GET | /version | Version and build info. |
| GET | /stats | Global 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;0means unlimited.peer_permits_in_use: observed live inbound plus outbound peer sessions.peer_permits_available: remaining bounded capacity, ornullwhen 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
| Method | Path | Description |
|---|---|---|
| GET | /torrents | List torrents. |
| GET | /torrents/query | Query torrents with server-side filters, sorting, pagination, counts, and optional grouping. |
| POST | /torrents | Add magnet JSON or raw .torrent body. |
| POST | /torrents/magnet | Add magnet JSON with storage, profile, preview, file-selection, structured exclusion, and active-file-suffix options. |
| POST | /torrents/file | Upload raw .torrent body; query supports start, profile, label, preview, selection, active-root, and partial-suffix options. |
| POST | /torrents/bulk | Add multiple magnets and/or base64 .torrent payloads. |
| GET | /torrents/:hash | Torrent details. |
| GET | /torrents/:hash/metainfo | Return the retained, byte-exact original .torrent document when one exists. |
| GET | /torrents/:hash/stats | Per-torrent counters and live engine diagnostics. |
| DELETE | /torrents/:hash?delete_data=bool | Remove torrent, optionally deleting data. |
| POST | /torrents/remove | Remove multiple torrents: { info_hashes, delete_data? }. |
| POST | /torrents/:hash/pause | Pause. |
| POST | /torrents/:hash/resume | Resume. |
| POST | /torrents/:hash/start | Start now, bypassing queue. |
| POST | /torrents/:hash/stop | Stop. |
| POST | /torrents/:hash/recheck | Force recheck. |
| POST | /torrents/:hash/reannounce | Reannounce. |
| POST | /torrents/:hash/move | Move data: { path }. |
| POST | /torrents/:hash/labels | Set labels: { labels }. |
| POST | /torrents/:hash/limits | Set per-torrent bandwidth limits: { download_limit, upload_limit }, bytes/sec, 0 = unlimited. |
| PUT | /torrents/:hash/seeding | Replace the persisted per-torrent ratio/idle/forever policy. |
| GET | /torrents/:hash/policy | Effective profile values plus the source of every value. |
| PUT | /torrents/:hash/policy | Set/clear explicit profile: { profile: "name" } or { profile: null }. |
| POST | /torrents/:hash/storage-preview | Read-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
| Method | Path | Description |
|---|---|---|
| GET | /profiles | Return the complete { profiles, labels } configuration section. |
| PUT | /profiles | Replace the complete { profiles, labels } section after validation. |
| PUT | /torrents/:hash/encryption-mode | Set 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:
| Parameter | Description |
|---|---|
q | Case-insensitive search across name, info hash, state, health, label, and storage root. |
state | Comma-separated torrent states such as downloading, paused, or error. |
health | Comma-separated health labels such as good, stalled, or network_blocked. |
label | Comma-separated labels; unlabeled torrents use unlabeled. |
storage_root | Comma-separated download roots; torrents without an explicit root use default. |
performance | Comma-separated buckets: active, error, complete, transferring, has_peers, no_peers, stalled, unhealthy. |
min_peers, max_peers | Filter by the greater of active peer workers and known peers. |
min_down_rate, min_up_rate | Filter by current byte/sec rates. |
sort | One of name, state, health, health_score, progress, size, down_rate, up_rate, ratio, peers, added, completed, or queue. |
dir | asc or desc. |
page | 1-based page number. |
per_page | Page size, capped by the daemon; 0 returns counts and groups without rows. |
group_by | Optional 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
| Method | Path | Description |
|---|---|---|
| GET | /torrents/:hash/files | List files. |
| PATCH | /torrents/:hash/files | Alias for set wanted. |
| POST | /torrents/:hash/files/wanted | Set wanted: { file_indices, wanted }. |
| POST | /torrents/:hash/files/priority | Set priority: { file_indices, priority }. |
| POST | /torrents/:hash/files/:index/rename | Rename path: { new_path }. |
Trackers
| Method | Path | Description |
|---|---|---|
| GET | /torrents/:hash/trackers | List trackers. |
| POST | /torrents/:hash/trackers | Add tracker: { url }. |
| DELETE | /torrents/:hash/trackers/:url | Remove tracker. |
| POST | /torrents/:hash/trackers/edit | Edit 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, orunsupported.last_scrape: Unix seconds for the latest attempt.scrape_seeders,scrape_leechers, andscrape_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
| Method | Path | Description |
|---|---|---|
| GET | /torrents/:hash/peers | List peers. |
| POST | /torrents/:hash/peers/ban | Add or update a global manual ban: { ip, reason? }. |
| POST | /torrents/:hash/peers/unban | Remove 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
| Method | Path | Description |
|---|---|---|
| GET | /peer-filter | Return active direct rules/import paths, local import results, manual bans, client-ID prefixes, and rejection counters. |
| PUT | /peer-filter | Replace { enabled, rules, blocklist_paths, manual_bans, blocked_client_ids }. |
| POST | /peer-filter/unban | Remove 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
| Method | Path | Description |
|---|---|---|
| POST | /torrents/:hash/queue/move-up | Move up. |
| POST | /torrents/:hash/queue/move-down | Move down. |
| POST | /torrents/:hash/queue/move-top | Move to top. |
| POST | /torrents/:hash/queue/move-bottom | Move to bottom. |
Settings
| Method | Path | Description |
|---|---|---|
| GET | /settings | Get configuration with API auth token and SOCKS5 password redacted. |
| PATCH | /settings | Update live-safe runtime settings. |
| PUT | /settings | Replace full configuration atomically after validation. |
PATCH /settings updates live-safe bandwidth, queue, and seeding fields.
PUT /settings accepts [torrent].encryption_mode with these values:
disabledpreferred(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
| Method | Path | Description |
|---|---|---|
| GET | /network/health | Network containment health plus non-sensitive port-mapping and listen-port-test status. |
| GET | /network/port-mapping | Read the current opt-in router mapping status without sending router traffic. |
| POST | /network/port-mapping/refresh | Immediately reconcile the configured router mapping through the contained path. |
| POST | /network/port-test | Run or return the fresh cached operator-configured listen-port test. |
| GET | /network/diagnostics | Detailed 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
| Method | Path | Description |
|---|---|---|
| GET | /storage/roots | Return 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
| Method | Path | Description |
|---|---|---|
| POST | /watch/scan | Trigger a scan. |
| GET | /watch/history | Import history. |
| GET | /watch/status | Watch-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, ortransient_failure.post_action_error:nullor 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
| Method | Path | Description |
|---|---|---|
| GET | /logs/recent | Recent daemon logs, with lines=1..500, default 100. |
| GET | /doctor | Consolidated operational health report. |
| POST | /reset | Stop 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.0means stalled, blocked, or paused;100means complete.bars(0..5): UI mapping for signal-bars rendering.label: one ofunknown,network_blocked,stalled,critical,poor,fair,good,excellent,paused,complete.availability_score,throughput_score,peer_score,stability_score, anddiscovery_score(0..100each): 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:
| Score | Bars | Label |
|---|---|---|
0 | 0 | stalled |
1..34 | 1 | critical |
35..54 | 2 | poor |
55..74 | 3 | fair |
75..89 | 4 | good |
90..100 | 5 | excellent |
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-Idand 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-closetorrent-get,torrent-start,torrent-start-now,torrent-stop,torrent-verify,torrent-reannouncetorrent-add,torrent-remove,torrent-set,torrent-set-location,torrent-rename-pathqueue-move-top,queue-move-up,queue-move-down,queue-move-bottomfree-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
SIDflow viaPOST /api/v2/auth/loginand a returnedSIDcookie.
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/versionGET /api/v2/app/webapiVersionGET /api/v2/torrents/infoGET /api/v2/torrents/categoriesPOST /api/v2/torrents/addPOST /api/v2/torrents/deletePOST /api/v2/torrents/pausePOST /api/v2/torrents/resumePOST /api/v2/torrents/startPOST /api/v2/torrents/stopPOST /api/v2/torrents/recheckPOST /api/v2/torrents/reannouncePOST /api/v2/torrents/setCategoryPOST /api/v2/torrents/setLocationPOST /api/v2/torrents/renameFileGET /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 = truewhen you want network health to reportdns_not_constrainedproactively instead of discovering it at tracker/DHT resolution time.
Health states
| State | Meaning |
|---|---|
healthy | Torrent networking can use the configured contained path. |
disabled | Network containment is disabled. |
interface_missing | The configured interface name is not visible to the daemon. |
interface_down | The configured interface exists but is down. |
no_interface_address | The interface has no usable IPv4 or allowed IPv6 address. |
source_address_missing | A configured source address is not assigned. |
route_invalid | Route validation failed. |
socket_bind_failed | The daemon could not bind a socket to the configured path. |
dns_not_constrained | DNS validation was requested but could not be proven safe. |
network_namespace_unavailable | The daemon is not in the required namespace. |
blocked_fail_closed | Strict 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
.torrentfiles. - Drag-and-drop upload for
.torrentfiles anywhere in the app window. - Metadata-preview checkboxes for magnet and
.torrentintake.
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
nulland use the corresponding value from Settings > Seeding. - Clear inheritance and enter
0to 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/statusfor the global autopilot mode.GET /api/v1/network/healthandGET /api/v1/network/diagnosticsfor any containment condition that may block or bias tuning decisions.GET /api/v1/torrents/:hash/statsfor peer-level health and scheduler signals.GET /api/v1/torrents/:hash/autopilotandPOST /api/v1/torrents/:hash/autopilotfor 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/statusfor enabled folders and recent watch-folder activity.GET /api/v1/logs/recentfor live-tail style log snapshots.GET /api/v1/doctorfor a consolidated operational check summary.GET /api/v1/versionfor 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_64andaarch64tarballs. .debpackages foramd64andarm64..rpmpackages forx86_64andaarch64.SHA256SUMSfor 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_peerswhen it is nonzero, withmax_peers_per_torrentas 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
vpnservice, 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 whenknown_peersis high butactive_peer_workersis 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; currentlynulluntil 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_droppedlag notifications. - Torrents stay in
queuedstate 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_downloadsvsgranted_downloads: if requested exceeds granted, the download slot cap is the bottleneck.requested_metadata_fetchesvsgranted_metadata_fetches: if requested exceeds granted, the metadata fetch slot cap is the bottleneck.peer_limit,peer_permits_in_use, andpeer_permits_available: the authoritative process-wide peer-session cap and current usage. Available isnullwhen 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:
- Lower
max_active_downloadsto reduce concurrent peer connections. - Lower
max_peers_per_torrentto reduce per-torrent resource usage. - Set a global
max_peerscap to bound total connection count. - Ensure file descriptor limits are sufficient (65,536+ for 1,000 torrents).
- 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
.torrentfiles. - 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
.torrentfiles. - 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.