Metadata-Version: 2.1
Name: ferret-agent
Version: 0.1.6
Summary: Ferret — self-hosted outbound tunnel agent. No port forwarding, no third-party cloud.
License: AGPL-3.0-or-later
Project-URL: Homepage, https://github.com/Dejan-Port/ferret
Project-URL: Issues, https://github.com/Dejan-Port/ferret/issues
Keywords: tunnel,vpn,remote-access,websocket,self-hosted,firewall,nat-traversal
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: System :: Networking
Classifier: Topic :: Security
Classifier: Topic :: Internet
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: websockets<14,>=12.0
Provides-Extra: all
Requires-Dist: fastapi>=0.110; extra == "all"
Requires-Dist: uvicorn>=0.29; extra == "all"
Requires-Dist: cryptography>=42; extra == "all"
Requires-Dist: pystray>=0.19; extra == "all"
Requires-Dist: Pillow>=10.0; extra == "all"
Requires-Dist: keyring>=24; extra == "all"
Provides-Extra: crypto
Requires-Dist: cryptography>=42; extra == "crypto"
Provides-Extra: gui
Requires-Dist: pystray>=0.19; extra == "gui"
Requires-Dist: Pillow>=10.0; extra == "gui"
Requires-Dist: keyring>=24; extra == "gui"
Provides-Extra: server
Requires-Dist: fastapi>=0.110; extra == "server"
Requires-Dist: uvicorn>=0.29; extra == "server"
Requires-Dist: cryptography>=42; extra == "server"

# 🐾 Ferret

> ⚠️ **v0.1 Alpha** — works in production for us, but APIs and config format may change before v1.0. See [Roadmap](#roadmap).

**Self-hosted outbound tunnel. No port forwarding. No third-party cloud. No accounts.**

Ferret lets you reach any device behind a firewall — corporate NAT, hospital network, hotel WiFi — without opening a single inbound port. The agent initiates an outbound connection on port 443. That's it.

---

## The problem

You have a Raspberry Pi, a camera, a printer, a PLC — behind a strict firewall you don't control.

- **WireGuard** requires both sides to have reachable ports. Doesn't work.
- **Tailscale / ZeroTier** work, but your traffic goes through their servers. They require an account. You pay monthly. You trust them.
- **ngrok** is a relay. Same story.

Ferret is different: **you run the server**. Your traffic never leaves your infrastructure.

And your server doesn't need a static IP. Run it at home with a [No-IP](https://www.noip.com/) or any dynamic DNS service — the agent connects to `yourname.ddns.net` and it just works.

---

## How it works

```
  Your device (behind firewall)          Your server (VPS, cloud, on-prem)
  ┌─────────────────────────┐            ┌──────────────────────────────┐
  │  ferret agent           │            │  ferret-server               │
  │                         │  outbound  │                              │
  │  TUN interface ◄────────┼────────────┼──── WebSocket on :443        │
  │  LAN: 192.168.1.0/24   │            │                              │
  │  Cameras, printers...   │            │  Admin UI  ·  REST API       │
  └─────────────────────────┘            │  Per-agent ACL               │
                                         │  Audit log                   │
                                         └──────────────────────────────┘
```

The agent creates a TUN interface and connects outbound. The server assigns a VPN IP and routes traffic to the agent's LAN. Cameras, printers, PLCs — all reachable, no changes needed on those devices.

---

## Features

- **Outbound only** — agent initiates, works through any firewall
- **Full LAN access** — reach every device on the agent's network, not just the agent
- **Native subnet replication** — via NETMAP, the remote LAN appears on a local subnet; devices are reachable as if you were physically on the same network (e.g. remote `192.168.1.x` → local `10.8.0.x`, same structure, no manual IP mapping)
- **Per-agent ACL** — restrict which ports/IPs are accessible, hot-reloadable without disconnect
- **Invite tokens** — 5-minute short-lived tokens; HWID auto-bound on first connect; admin approves
- **Hardware binding** — client HWID and server HWID both bound into token via HMAC; stolen token unusable on different hardware
- **Token rotation** — versioned secrets (`kid`), retire old keys after natural expiry
- **Rate limiting** — IP-based backoff on failed connection attempts
- **Audit log** — every connection, proxy open, rule change, token event — filterable UI
- **One-line installer** — server generates a bash/PowerShell script with all credentials embedded
- **TCP proxy** — tunnel RDP, SSH, VNC to any host on agent's LAN
- **Encrypted storage** — LUKS container bound to server hardware; stolen server cannot be decrypted without the original machine
- **Self-hosted** — your server, your data, zero external dependencies

---

## Quick start

### Server (static IP or dynamic DNS)

```bash
pip install "ferret-agent[server]"

ferret-server \
  --admin-token your-secret-token \
  --secret     your-signing-secret \
  --public-url https://yourname.ddns.net
```

Only port 443 needs to be open. The server listens on `::` by default (dual-stack IPv4+IPv6). To bind IPv4 only: `--host 0.0.0.0`. A static IP is not required — dynamic DNS (No-IP, DuckDNS) works fine, as long as the server has a real public IP. **CGNAT does not work** — if your ISP shares a public IP across multiple customers, run the server on a VPS instead.

Open `https://yourname.ddns.net/agents/ui` — admin UI with token management and agent status.

### Agent (manual)

```bash
pip install ferret-agent

# Create agent.conf (or use the installer generated by the server UI)
cat > agent.conf << EOF
url   = wss://your-server.example.com/ws/agent
token = <token from server UI>
EOF

ferret
```

### Agent (one-line installer)

In the server UI, click **🐧** next to any agent to download a self-contained bash script that installs Python, ferret-agent, writes config, and registers a systemd service.

```bash
curl https://your-server.example.com/agents/<token>/installer?os=linux | sudo bash
```

### VPN client (ferret tun)

Connect your laptop to any agent's LAN from anywhere — the remote network appears locally via NETMAP:

```bash
pip install ferret-agent

sudo ferret tun \
  --server      https://your-server.example.com \
  --admin-token your-secret-token \
  --agent       <agent-token>
```

After connecting, the remote LAN is directly reachable — `ping 10.8.0.100`, SSH, browser, everything works as if you were physically on site. Requires root / `CAP_NET_ADMIN`.

To run as a systemd service:

```bash
sudo tee /etc/systemd/system/ferret-tun.service << EOF
[Unit]
Description=Ferret TUN VPN
After=network-online.target

[Service]
ExecStart=/usr/local/bin/ferret tun --server https://your-server.example.com --admin-token your-secret-token --agent <agent-token>
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl enable --now ferret-tun
```

---

## Token management

### Standard token

Created via API or admin UI. Valid immediately, no expiry by default.

```bash
# Via CLI
ferret-server token create "Office router" --rules "tcp:22,tcp:3389"

# Via API
curl -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
     -X POST https://your-server/agents/tokens \
     -d '{"name": "Office router", "rules": ["tcp:22", "tcp:3389"]}'
```

### Invite token (5-minute, HWID-bound)

For deploying agents securely without pre-sharing a permanent token. The invite is valid for 5 minutes — just long enough for the agent to make its first connection. After first connect, the server records the agent's hardware fingerprint (HWID). On every subsequent connection, the HWID must match.

```
  Admin creates invite (5 min)
       │
       ▼
  Agent connects → server records HWID
       │
       ▼
  Admin approves in UI (sees HWID, confirms it's the right machine)
       │
       ▼
  Agent: permanent, bound to that hardware forever
```

If the invite expires before first use, it's dead — create a new one. If someone else gets the invite token but connects from a different machine, admin sees the wrong HWID and rejects it.

```bash
# API
curl -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
     -X POST https://your-server/agents/invite \
     -d '{"name": "Branch office", "rules": ["tcp:22"]}'
# Returns token valid for 5 minutes

# Agent uses it like any token
ferret --token INVITE_TOKEN --server wss://your-server/ws/agent
# After connecting, waits for admin approval
```

### Token rotation

Retire old signing secrets without disconnecting existing agents. Old tokens remain valid until they expire naturally.

```python
from ferret.server.token_gen import TokenGen

gen = TokenGen(
    secrets={"v1": "old-secret-32chars+", "v2": "new-secret-32chars+"},
    active="v2"
)
# New tokens signed with v2; existing v1 tokens still validate
gen.retire("v1")  # only after all v1 tokens have expired
```

### Server HWID binding

Tokens can be bound to a specific server's hardware. Even if an attacker steals both the database and the signing secret, tokens cannot be replayed on a different server.

```python
gen.create(
    hw_id="client-hardware-fingerprint",
    name="Branch office",
    server_hw_id="server-hardware-fingerprint",  # from ferret.hw_id.get() on server
)
```

---

## Access control (ACL)

Per-agent rules. Empty rules = agent cannot open any proxy tunnel.

```
"tcp:22"    — SSH only
"tcp:3389"  — RDP only
"udp:161"   — SNMP only
"tcp:*"     — all TCP
"udp:*"     — all UDP
"*:*"       — everything (TCP + UDP)
```

Rules are hot-reloadable — change takes effect on the next proxy request, no reconnect needed.

```bash
# Update rules via API
curl -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
     -X PUT https://your-server/agents/TOKEN/rules \
     -d '{"rules": ["tcp:22", "tcp:443"]}'
```

---

## TCP proxy (RDP, SSH, VNC)

Reach any host on the agent's LAN from your server:

```python
from ferret.server import AgentRouter
from ferret.example import PortalTcpProxy

router = AgentRouter(...)
proxy  = PortalTcpProxy(router)

proxy.expose("agent-token", "192.168.1.50", 3389, local_port=13389)  # RDP
proxy.expose("agent-token", "192.168.1.1",  22,   local_port=12222)  # SSH
```

Then `mstsc /v:your-server:13389` from anywhere.

---

## Encrypted server storage (ferret-db)

`ferret-db` protects server data from physical theft. The LUKS container is bound to hardware fingerprint (HWID) — a key derived from DMI UUIDs, CPU model, and disk serials. Without the original hardware, the container is unreadable.

### How it works

```
  Hardware (DMI + CPU + disk serials)
        │
        ▼
  HWID string  ──── SHA-256 ────►  material
  machine.key ─────────────────►  material
        │
        ▼
  HKDF(SHA-256, salt, info="ferret-db-key-v1")  →  LUKS passphrase (64 bytes)
        │
        ▼
  cryptsetup luksOpen /db/ferret-data.img
        │
        ▼
  /db/secure/   ← ext4, mounted at boot, unmounted at shutdown
```

Key is derived in memory via `memfd_create` (never touches disk). Memory is `mlock`-ed and zeroed after use.

### Setup (one-time)

```bash
# Install with crypto dependencies
pip install "ferret-agent[all]"

# Initialize 20GB container
sudo ferret-db init --size 20

# Output:
# Container : /db/ferret-data.img  (20GB)
# Header    : /etc/ferret/db.header  ← back this up separately!
# Mounted   : /db/secure
```

The LUKS header is stored separately at `/etc/ferret/db.header`. Back it up — without it the container cannot be opened even with the correct hardware.

> **Critical:** `/etc/ferret/db.salt` and `/etc/ferret/db.header` must be real files on the filesystem — **not** symlinks pointing inside `/db/secure`. The unlock process reads the salt to derive the key before the container is open. Placing the salt inside the container creates a circular dependency and makes the server unbootable.

### Daily operation

```bash
sudo ferret-db unlock   # open + mount (called automatically by systemd)
sudo ferret-db lock     # unmount + close
sudo ferret-db status   # check state
sudo ferret-db reset    # re-key after hardware or network change (audit logged)
```

> **Note:** The network fingerprint includes the first reachable WAN hop IP. If your ISP changes their routing and this IP changes, the container will fail to unlock after reboot. Run `sudo ferret-db reset` to bind to the new network topology.

### systemd integration

```ini
[Service]
PermissionsStartOnly=true
ExecStartPre=/usr/local/bin/ferret-db unlock
ExecStart=/usr/local/bin/ferret-server --admin-token ... --secret ...
ExecStopPost=/usr/local/bin/ferret-db lock
```

See `example/ferret-server.service.example` for the full unit file.

### Security properties

| Property | Detail |
|----------|--------|
| Key source | DMI UUID + board/chassis serial + CPU model + disk serials + network fingerprint |
| Hardware minimum | 2 hardware identifiers required; fails safe otherwise |
| Network fingerprint | Default gateway IP, local subnet, gateway MAC (ARP), first WAN hop (traceroute TTL=2) |
| Key derivation | HKDF-SHA256, 64-byte output, random 32-byte salt per init |
| In-memory key | `memfd_create` + `mlock` — key never written to disk or swap |
| Audit log | Append-only (`chattr +a`) JSON log at `/var/log/ferret-db-audit.log` |
| Header separation | LUKS header at `/etc/ferret/db.header`, body at `/db/ferret-data.img` |

---

## Security model

| Layer | Mechanism |
|-------|-----------|
| Transport | TLS 1.3 (WebSocket over HTTPS) |
| Token signing | HMAC-SHA256, `kid`-versioned, supports rotation + retire |
| Client HWID | Token stores `HMAC(secret, hw_id)` — plain fingerprint never in token |
| Server HWID | Token stores `HMAC(secret, server_hw_id)` — token invalid on different hardware |
| HWID binding | Invite tokens bind HWID on first connect; enforced on all subsequent connects |
| Application crypto | ChaCha20-Poly1305 AEAD, HKDF-SHA256 session keys, per-session nonce |
| Access control | Per-agent ACL rules, hot-reloadable, deny-by-default |
| Rate limiting | IP-based failed-attempt counter, exponential backoff |
| Audit | SQLite log of every event, filterable UI at `/agents/audit` |
| Server storage | LUKS container, HWID-bound key, `memfd_create` + `mlock` |

**Audit events:** `agent_connect`, `agent_disconnect`, `proxy_open`, `proxy_deny`, `tun_start`, `tun_stop`, `token_create`, `token_revoke`, `rules_change`, `invite_create`, `agent_approve`, `admin_access`

No traffic passes through third-party infrastructure. Ever.

---

## Comparison

|  | Tailscale | ZeroTier | ngrok | **Ferret** |
|--|-----------|----------|-------|------------|
| Self-hosted | Partial | Partial | No | **Yes** |
| Account required | Yes | Yes | Yes | **No** |
| Traffic through their servers | Sometimes | Sometimes | Yes | **Never** |
| Firewall bypass | Partial | Partial | Yes | **Yes** |
| Full LAN access | No | No | No | **Yes** |
| Per-agent ACL | Enterprise | No | No | **Yes** |
| Audit log | Enterprise | No | Paid | **Included** |
| Hardware binding | No | No | No | **Yes** |
| Static IP required | Yes | Yes | — | **No** |
| Dynamic DNS support | No | No | — | **Yes** |
| Open source (server) | No | No | No | **Yes** |
| Price | $18/user/mo | $4/user/mo | $8+/mo | **$0** |

---

## Handlers

| Handler | Description |
|---------|-------------|
| `TunHandler` | Layer 3 VPN, full LAN access via MASQUERADE |
| `ProxyHandler` | TCP proxy to any host:port on agent's LAN |
| `AmiHandler` | Asterisk AMI — originate calls, query extensions |
| `SmsHandler` | GSM dongle SMS via Asterisk DongleSendSMS |
| `AiHandler` | Ollama Vision/OCR — base64 image + prompt → response |

Custom handlers in 5 lines:

```python
@agent.on("my_command", capability="my_module")
async def handle(data: dict, send):
    await send({"type": "my_response", "result": do_work(data)})
```

---

## Installation

```bash
# Agent only (lightweight, no server deps)
pip install ferret-agent

# Server + agent
pip install "ferret-agent[server]"

# With crypto support
pip install "ferret-agent[all]"
```

Requires Python 3.11+. Linux only for TUN features; proxy and custom handlers work on any OS.

---

## Roadmap

- **v0.1** — Python, WebSocket, all features above ✅ — CLI VPN client (`ferret tun`); GUI tray app planned
- **v0.2** — TAP mode (Layer 2 bridge) — remote machines appear in Windows File Explorer Network neighborhood; Samba shares browsable without manual IP
- **v0.3** — QUIC transport (replaces WebSocket/TCP → eliminates TCP-over-TCP, ~800 Mbps)
- **v0.4** — Rust rewrite of data plane (agent + server tunnel core)
- **v0.5** — Linux kernel module (`ferret.ko`) — WireGuard-class throughput
- **v1.0** — Upstream Linux kernel submission

---

## Origin

Ferret was born out of a real production need while building
[ServisPort](https://github.com/Dejan-Port/port-servisni-portal) —
a field service management platform for repair shops, started in May 2026.

We needed to connect field technicians' devices behind strict firewalls
without opening ports or relying on third-party services.

Nothing on the market solved this simply. So we built it.

First commit: June 2026

**Author:** Dejan Kocić — [dkocic@servisport.org](mailto:dkocic@servisport.org)

---

## License

Server component: SSPL-1.0 — free for self-hosted use; commercial use requires a license.  
Agent component: AGPLv3 — free for everyone.  
See [LICENSE](LICENSE).

---

## Contributing

Issues and PRs welcome. For security vulnerabilities, email directly (see profile).
