Metadata-Version: 2.4
Name: posthumous
Version: 0.7.1
Summary: A lightweight, federated deadman switch that triggers notifications and scripts when you stop checking in
Project-URL: Homepage, https://github.com/queelius/posthumous
Project-URL: Repository, https://github.com/queelius/posthumous
Project-URL: Issues, https://github.com/queelius/posthumous/issues
Project-URL: Changelog, https://github.com/queelius/posthumous/releases
Author-email: Alex Towell <lex@metafunctor.com>
License-Expression: MIT
License-File: LICENSE
Keywords: deadman-switch,federated,notifications,scheduler,totp,watchdog
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: End Users/Desktop
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: System :: Monitoring
Classifier: Topic :: Utilities
Requires-Python: >=3.10
Requires-Dist: aiohttp>=3.9
Requires-Dist: apprise>=1.6.0
Requires-Dist: click>=8.0
Requires-Dist: cryptography>=41.0
Requires-Dist: pyotp>=2.9
Requires-Dist: python-dateutil>=2.8
Requires-Dist: pyyaml>=6.0
Requires-Dist: qrcode>=7.4
Provides-Extra: dev
Requires-Dist: aioresponses>=0.7; extra == 'dev'
Requires-Dist: freezegun>=1.2; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Description-Content-Type: text/markdown

# Posthumous

A lightweight, federated deadman switch. Users check in periodically via TOTP; if they stop, the system progresses through escalating alerts and eventually triggers actions — sending notifications, running scripts, and executing a recurring post-trigger schedule.

## Features

- **TOTP authentication**: Works with any authenticator app (Google Authenticator, Authy, etc.)
- **Federated**: Multiple nodes sync check-ins; any single node can trigger (failure mode: duplicates, not silence)
- **Quorum-based triggering** (optional, v0.7+): Require M-of-N peer confirmation before triggering, so a single compromised peer cannot fire the deadman switch alone
- **Self-healing**: Nodes auto-recover state from peers on startup if their local state is corrupt or missing
- **Multi-stage escalation**: Configurable warning → grace → trigger pipeline with callbacks at each stage
- **Post-trigger scheduling**: Recurring actions after trigger, such as annual emails, birthday messages, and periodic scripts
- **80+ notification services**: Via [Apprise](https://github.com/caronc/apprise). Supports ntfy, email, Slack, Discord, Telegram, Pushbullet, Gotify, and more
- **Script execution**: Run Python or shell scripts with full event context via environment variables and JSON
- **Web interface**: Dark-themed check-in form with CSRF protection
- **JSON API**: Programmatic check-in and status endpoints (HMAC-signed peer sync with replay protection)
- **Systemd integration**: `phm service install` sets up a hardened user service with sd_notify watchdog heartbeat
- **Encryption at rest**: PBKDF2-HMAC-SHA256 with per-file random salts for encrypted state files
- **Portable**: Runs on laptop, Raspberry Pi, or VPS. Anywhere Python 3.10+ is available.

## Installation

### From PyPI

```bash
pip install posthumous
```

### From source

```bash
git clone https://github.com/queelius/posthumous.git
cd posthumous
pip install -e ".[dev]"
```

### Dev setup

```bash
pip install -e ".[dev]"
pytest                          # Run all tests (448 tests, 98% coverage)
```

## Quick Start

```bash
# 1. Initialize a new node — generates TOTP secret and shows QR code
posthumous init --node-name laptop

# 2. Scan the QR code with your authenticator app

# 3. Start the daemon
posthumous run

# 4. Check in periodically
posthumous checkin
# or use the short alias
phm checkin
```

After initialization, your config lives at `~/.posthumous/config.yaml`. Edit it to add notification channels, peers, and post-trigger actions.

## Configuration Reference

Configuration is stored in `~/.posthumous/config.yaml`. All fields with their defaults:

### Identity & Network

| Option | Default | Description |
|--------|---------|-------------|
| `node_name` | *(required)* | Name for this node (used in logs and notifications) |
| `secret_key` | *(required)* | Base32 TOTP secret (generated by `init`) |
| `listen` | `0.0.0.0:8420` | HTTP server bind address (`host:port`) |
| `api_token` | `null` | Optional API token for automated check-ins |

### Timing

All timing values accept human-readable durations: `7 days`, `12 hours`, `30 minutes`, `1 week`.

| Option | Default | Description |
|--------|---------|-------------|
| `checkin_interval` | `7 days` | Expected check-in frequency (used for "overdue" warnings) |
| `warning_start` | `8 days` | Time since last check-in to enter WARNING state |
| `grace_start` | `12 days` | Time since last check-in to enter GRACE state |
| `trigger_at` | `14 days` | Time since last check-in to TRIGGER (irreversible) |

### Security

| Option | Default | Description |
|--------|---------|-------------|
| `max_failed_attempts` | `5` | Failed auth attempts before lockout |
| `lockout_duration` | `15 minutes` | Lockout duration after max failed attempts |

### Federation

| Option | Default | Description |
|--------|---------|-------------|
| `peers` | `[]` | List of peer node URLs (e.g., `https://server:8420`) |
| `peer_check_interval` | `30 minutes` | How often to health-check peers |
| `peer_down_threshold` | `6 hours` | How long before alerting about a down peer |

### Notification Channels

Channels are named groups of [Apprise URLs](https://github.com/caronc/apprise/wiki):

```yaml
notifications:
  default:
    - "ntfy://my-topic"
  urgent:
    - "ntfy://my-topic"
    - "mailto://user:pass@smtp.gmail.com?to=contact@example.com"
  family:
    - "tgram://bot_token/chat_id"
```

### Actions

Actions fire at each state transition. Each action is either a notification or a script:

```yaml
actions:
  on_warning:
    - notify: default
      message: "Check-in needed. {days_left} days remaining."
  on_grace:
    - notify: urgent
      message: "URGENT: Posthumous triggers in {hours_left} hours."
  on_trigger:
    - notify: urgent
      message: "Posthumous has activated."
    - script: "scripts/on_trigger.py"
```

**Template variables** available in messages:

| Variable | Description |
|----------|-------------|
| `{node_name}` | This node's name |
| `{status}` | Current status |
| `{days_left}` | Days until trigger |
| `{hours_left}` | Hours until trigger |
| `{last_checkin}` | Last check-in timestamp |
| `{trigger_time}` | When the trigger fired |

### Post-Trigger Schedule

After trigger, these items run on a recurring schedule:

```yaml
post_trigger:
  - name: "annual_letter"
    when: "every year on trigger"
    script: "scripts/annual_letter.py"

  - name: "birthday_message"
    when: "every March 15"
    notify: default
    message: "Happy birthday. Thinking of you always."

  - name: "weekly_check"
    when: "every week after trigger"
    script: "scripts/weekly_maintenance.py"

  - name: "one_time_upload"
    when: "trigger + 1 day"
    script: "scripts/upload_files.py"
```

### Full Example

```yaml
node_name: "laptop"
secret_key: "JBSWY3DPEHPK3PXP"
listen: "0.0.0.0:8420"
api_token: "my-automation-token-here"

checkin_interval: 7 days
warning_start: 8 days
grace_start: 12 days
trigger_at: 14 days

peers:
  - https://backup-server.home:8420
  - https://vps.example.com:8420

notifications:
  default:
    - "ntfy://posthumous-alerts"
  urgent:
    - "ntfy://posthumous-alerts"
    - "mailto://user:pass@smtp.gmail.com?to=family@example.com"

actions:
  on_warning:
    - notify: default
      message: "Check-in needed. {days_left} days remaining before trigger."
  on_grace:
    - notify: urgent
      message: "URGENT: Posthumous triggers in {hours_left} hours."
  on_trigger:
    - notify: urgent
      message: "Posthumous has activated for node {node_name}."
    - script: "scripts/on_trigger.py"

post_trigger:
  - name: "annual_letter"
    when: "every year on trigger"
    script: "scripts/annual_letter.py"
  - name: "birthday_message"
    when: "every March 15"
    notify: default
    message: "Happy birthday. Thinking of you always."
  - name: "immediate_upload"
    when: "trigger"
    script: "scripts/upload_encrypted.py"
```

## State Machine

```
ARMED ──timeout──► WARNING ──timeout──► GRACE ──timeout──► TRIGGERED
  ▲                   │                   │                    │
  └───── check-in ────┴───── check-in ────┘                    │
                                                               ▼
                                                          (scheduler runs forever)
```

| State | Description |
|-------|-------------|
| **ARMED** | Normal operation. Timer counting since last check-in. |
| **WARNING** | First escalation. Fires `on_warning` actions. Check-in still resets to ARMED. |
| **GRACE** | Final warning. Fires `on_grace` actions. Check-in still resets to ARMED. |
| **TRIGGERED** | Terminal state. Fires `on_trigger` actions. No check-in can undo it. Post-trigger scheduler starts. |

If a node is offline and misses intermediate states (e.g., goes from ARMED straight to TRIGGERED), the watchdog fires all intermediate callbacks in order before reaching the current state.

## Scheduling DSL

The `when` field in post-trigger items supports:

### Trigger-Relative (one-time)

```yaml
"trigger"                    # At trigger time
"trigger + 3 days"           # 3 days after trigger
"trigger + 1 hour"           # 1 hour after trigger
```

### Trigger-Recurring

```yaml
"every day after trigger"    # Daily from trigger
"every week after trigger"   # Weekly from trigger
"every month after trigger"  # Monthly from trigger
"every 30 days after trigger" # Custom interval
```

### Anniversary

```yaml
"every year on trigger"      # Annual trigger anniversary
```

### Absolute (one-time)

```yaml
"2030-01-01"                 # Specific date
```

### Absolute Recurring

```yaml
"every December 25"          # Yearly on Christmas
"every March 15"             # Yearly on date
"every March 15 - 7 days"   # 7 days before March 15 each year
```

Each scheduled execution is deduplicated with a period key (e.g., `"2026"` for annual, `"2026-W05"` for weekly, `"once"` for one-time events) to prevent repeats across restarts or federated nodes.

## Notification Channels

Posthumous uses [Apprise](https://github.com/caronc/apprise) for notifications. Here are popular services with their URL formats:

### ntfy (recommended for self-hosting)

```yaml
notifications:
  alerts:
    - "ntfy://my-private-topic"
    - "ntfy://user:pass@ntfy.example.com/topic"
```

### Email (SMTP)

```yaml
notifications:
  email:
    - "mailto://user:pass@smtp.gmail.com?to=recipient@example.com"
    - "mailto://user:pass@smtp.gmail.com?to=person1@example.com,person2@example.com"
```

### Slack

```yaml
notifications:
  slack:
    - "slack://TokenA/TokenB/TokenC/#channel"
```

### Discord

```yaml
notifications:
  discord:
    - "discord://webhook_id/webhook_token/"
```

### Telegram

```yaml
notifications:
  telegram:
    - "tgram://bot_token/chat_id"
```

### Gotify

```yaml
notifications:
  gotify:
    - "gotify://hostname/token"
```

### Pushbullet

```yaml
notifications:
  pushbullet:
    - "pbul://access_token"
```

See the [Apprise wiki](https://github.com/caronc/apprise/wiki) for the full list of 80+ supported services.

## Script Execution

Scripts are executed asynchronously with a 300-second (5 minute) default timeout.

### Environment Variables

Scripts receive these environment variables:

| Variable | Description |
|----------|-------------|
| `POSTHUMOUS_EVENT` | Event type: `warning`, `grace`, `trigger`, or `scheduled` |
| `POSTHUMOUS_NODE` | Node name |
| `POSTHUMOUS_STATUS` | Current status |
| `POSTHUMOUS_TRIGGER_TIME` | ISO 8601 trigger timestamp (if triggered) |
| `POSTHUMOUS_LAST_CHECKIN` | ISO 8601 last check-in timestamp |
| `POSTHUMOUS_SCHEDULE_ITEM` | Name of scheduled item (if scheduled event) |
| `POSTHUMOUS_CONTEXT_FILE` | Path to JSON file with full context |

### Context JSON File

A temporary JSON file with complete context is created for each script execution and auto-cleaned after:

```json
{
  "event": "trigger",
  "trigger_time": "2026-02-10T14:30:00+00:00",
  "node_name": "laptop",
  "status": "triggered",
  "last_checkin": "2026-01-27T09:15:00+00:00",
  "schedule_item": null,
  "extra": {}
}
```

### Example Script

```python
#!/usr/bin/env python3
"""Upload encrypted files when triggered."""

import json
import os
import subprocess

def main():
    context_file = os.environ.get("POSTHUMOUS_CONTEXT_FILE")
    if context_file:
        with open(context_file) as f:
            context = json.load(f)

    event = os.environ.get("POSTHUMOUS_EVENT")
    node = os.environ.get("POSTHUMOUS_NODE")

    if event == "trigger":
        # Upload encrypted archive to cloud storage
        subprocess.run(["rclone", "copy", "/encrypted/vault", "remote:backup/"])
        print(f"Vault uploaded from {node}")

    return 0

if __name__ == "__main__":
    exit(main())
```

Scripts must be executable (`chmod +x`) or be Python files (`.py` extension, run with the current Python interpreter). Place them in `~/.posthumous/scripts/` or use absolute paths.

## Web Interface & API

When running, Posthumous serves a web interface and JSON API.

### Web Check-in

- `GET /` — Redirects to `/checkin`
- `GET /checkin` — Dark-themed check-in form with status display
- `POST /checkin` — Submit TOTP code (form or JSON)

### JSON API

**Check in:**
```bash
# With TOTP code
curl -X POST http://localhost:8420/checkin \
  -H "Content-Type: application/json" \
  -d '{"totp": "123456"}'

# With API token
curl -X POST http://localhost:8420/checkin \
  -H "Content-Type: application/json" \
  -d '{"token": "my-api-token"}'
```

Response:
```json
{
  "success": true,
  "status": "armed",
  "next_deadline": "2026-02-24T14:30:00+00:00"
}
```

**Get status:**
```bash
curl http://localhost:8420/status
```

Response:
```json
{
  "node_name": "laptop",
  "status": "armed",
  "last_checkin": "2026-02-10T14:30:00+00:00",
  "trigger_time": null,
  "time_remaining": {
    "until_warning": 172800.0,
    "until_grace": 518400.0,
    "until_trigger": 691200.0
  }
}
```

**Health check:**
```bash
curl http://localhost:8420/health
```

### Peer Sync Endpoints

These are used by federated nodes (HMAC-signed):

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/sync/checkin` | POST | Receive check-in broadcast from peer |
| `/sync/trigger` | POST | Receive trigger broadcast from peer |
| `/sync/scheduled` | POST | Receive scheduled item completion from peer |
| `/sync/state` | GET | Return current state for peer sync |

## Federation

Multiple nodes form a federation by sharing the same TOTP secret and listing each other as peers.

### Setup

```bash
# On the first node
posthumous init --node-name primary

# On additional nodes — join with the shared secret
posthumous init --node-name backup --join https://primary:8420
# Enter the secret from the primary node when prompted
```

Or manually set the same `secret_key` and add `peers:` to each node's config.

### How Sync Works

1. **Check-in broadcast**: When any node accepts a check-in, it broadcasts to all peers. Peers apply the check-in locally, resetting their timers.
2. **Trigger broadcast**: When any node triggers, it broadcasts the trigger event. All peers transition to TRIGGERED.
3. **Scheduled item sync**: When a node completes a scheduled item, it broadcasts completion so peers skip it (deduplication).
4. **Health monitoring**: Background loop checks peer status every `peer_check_interval`. Alerts after `peer_down_threshold`.

### Failure Modes

- **Network partition**: Nodes operate independently. If one triggers, the other won't know until connectivity restores. Both may trigger independently — this is by design (duplicates > silence).
- **All peers down**: The surviving node operates normally and triggers on its own.
- **Split brain**: Multiple nodes may fire the same actions. Period-based dedup keys prevent repeats on the same node.

### HMAC Signing

All peer sync messages are signed with HMAC-SHA256 using the shared `secret_key`:

```
signature = HMAC-SHA256(secret_key, "checkin:<timestamp>")
signature = HMAC-SHA256(secret_key, "trigger:<timestamp>")
signature = HMAC-SHA256(secret_key, "scheduled:<item_name>:<period>")
signature = HMAC-SHA256(secret_key, "trigger_intent:<intent_id>:<timestamp>")
signature = HMAC-SHA256(secret_key, "confirm:<intent_id>:<timestamp>:<peer_url>")
```

### Quorum (opt-in, v0.7+)

By default, any single node can broadcast a trigger and all peers apply it. Quorum changes this: a node that decides to trigger first broadcasts an intent, collects signed confirmations from peers, and only applies the trigger when at least `required` confirmations (including self) arrive within `window_seconds`.

```yaml
quorum:
  required: 2            # M: minimum confirmations including self
  window_seconds: 30     # how long to wait for peer confirmations
```

Peers vote `confirm` only if their own local timer has also elapsed, so quorum genuinely requires independent agreement across peers. If quorum fails (partition, disagreement), the node stays in `GRACE` and retries on the next tick. This is a **fail-closed** design: stuck without a trigger is preferred to a trigger from a single compromised host.

**Known limitation (see Security below):** because all nodes share the same HMAC secret, an attacker who obtains the secret can assemble a quorum bundle without actually compromising M peers. v0.7 quorum raises the bar against specific compromise paths (a single rogue peer cannot unilaterally trigger) but does not provide true Byzantine fault tolerance. Planned for v0.8: per-peer identity keys.

## Security

### TOTP

Posthumous uses RFC 6238 TOTP with 6-digit codes and a 30-second time step. The secret is generated as 32-character Base32 during `init`.

### Brute Force Protection

After `max_failed_attempts` (default: 5) failed authentication attempts within `lockout_duration` (default: 15 minutes), the node locks out further attempts for `lockout_duration`.

### API Token

For automation, set `api_token` in config. Use with `posthumous checkin --token <token>` or via the JSON API. The token provides the same access as a valid TOTP code — use with caution.

### Peer Authentication

All peer communication is signed with the shared TOTP secret using HMAC-SHA256. Invalid signatures are rejected and logged.

### Threat Model and Known Limitations

Posthumous's security boundaries:

- **Unauthenticated network attackers** cannot trigger the deadman switch. All sync endpoints verify HMAC signatures using the shared secret. Messages older than 5 minutes are rejected (replay protection).
- **A single compromised peer** *without quorum configured* CAN trigger the entire federation. With quorum enabled, a single compromised peer without the shared secret cannot.
- **An attacker who obtains the shared `secret_key`** can currently forge arbitrary sync messages, including quorum-confirmed trigger bundles. Quorum's "M-of-N must agree" guarantee assumes the secret is not compromised. This is a consequence of using a single shared HMAC secret across all federation members.
- **State at rest** is optionally encrypted (`encrypt_at_rest: true`) using PBKDF2-HMAC-SHA256 key derivation with per-file random salts. The encryption key is derived from the shared secret.

Planned for v0.8: per-peer signing keys so that forging a quorum bundle genuinely requires compromising M peers, not just one secret holder.

## CLI Reference

Both `posthumous` and `phm` are available as entry points.

```
posthumous [OPTIONS] COMMAND

Options:
  --version          Show version
  -v, --verbose      Enable debug logging
  -c, --config PATH  Custom config file path

Commands:
  init                Initialize a new node
    --node-name TEXT  Node name (required)
    --join URL        Join existing federation

  config              Configuration management
    path              Show file locations
    show              Print config (secret redacted)
    validate          Check config for errors
    edit              Open in $EDITOR, validate on close

  run                 Start the daemon
    -d, --daemon      Background mode (not yet implemented)

  checkin             Check in to reset timer
    -t, --token TEXT  Use API token instead of TOTP

  status              Show current status and time remaining

  peers               Show peer status

  test-notify         Send test notifications
    -c, --channel     Test specific channel (default: all)

  test-trigger        Dry-run trigger actions

  export PATH         Export state and config to YAML
  import PATH         Import state from YAML backup
```

## Troubleshooting

### Common Issues

**"Config not found"**
Run `posthumous init --node-name <name>` to create the initial config.

**"secret_key must be valid base32"**
The secret key was edited manually and is no longer valid Base32. Re-run `posthumous init` or use a proper Base32 string.

**"locked out"**
Too many failed attempts. Wait for the lockout duration (default: 15 minutes) or restart the daemon.

**Notifications not sending**
Test with `posthumous test-notify`. Check that your Apprise URLs are correct. See the [Apprise wiki](https://github.com/caronc/apprise/wiki) for URL formats.

### File Locations

| File | Location |
|------|----------|
| Config | `~/.posthumous/config.yaml` |
| State | `~/.posthumous/state.yaml` |
| Logs | `~/.posthumous/logs/posthumous.log` |
| Scripts | `~/.posthumous/scripts/` |

Use `posthumous config path` to see the actual paths on your system.

### State Recovery

If state is corrupted, restore from a backup:

```bash
posthumous import backup.yaml
```

Or delete `~/.posthumous/state.yaml` to start fresh (timer resets, but TRIGGERED state is lost).

With federation, a recovering node can sync state from peers automatically.

### Logs

When running with `-v` (verbose), debug-level logs are written to both stdout and `~/.posthumous/logs/posthumous.log`.

## Running as a Service

### systemd

Create `/etc/systemd/system/posthumous.service`:

```ini
[Unit]
Description=Posthumous Deadman Switch
After=network.target

[Service]
Type=simple
User=your-username
ExecStart=/usr/local/bin/posthumous run
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
```

Then:

```bash
sudo systemctl enable posthumous
sudo systemctl start posthumous
sudo systemctl status posthumous
```

## License

MIT
