posthumous
Federated deadman switch with TOTP authentication, post-trigger scheduling, and peer-to-peer state sync
Resources & Distribution
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)
- Multi-stage escalation: Configurable warning → grace → trigger pipeline with callbacks at each stage
- Post-trigger scheduling: Recurring actions after trigger — annual emails, birthday messages, periodic scripts
- 80+ notification services: Via Apprise — 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 status display
- JSON API: Programmatic check-in and status endpoints
- Portable: Runs on laptop, Raspberry Pi, VPS — anywhere Python 3.10+ is available
Installation
From PyPI
pip install posthumous
From source
git clone https://github.com/queelius/posthumous.git
cd posthumous
pip install -e ".[dev]"
Dev setup
pip install -e ".[dev]"
pytest # Run all tests (448 tests, 98% coverage)
Quick Start
# 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:
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:
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:
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
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)
"trigger" # At trigger time
"trigger + 3 days" # 3 days after trigger
"trigger + 1 hour" # 1 hour after trigger
Trigger-Recurring
"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
"every year on trigger" # Annual trigger anniversary
Absolute (one-time)
"2030-01-01" # Specific date
Absolute Recurring
"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 for notifications. Here are popular services with their URL formats:
ntfy (recommended for self-hosting)
notifications:
alerts:
- "ntfy://my-private-topic"
- "ntfy://user:pass@ntfy.example.com/topic"
Email (SMTP)
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
notifications:
slack:
- "slack://TokenA/TokenB/TokenC/#channel"
Discord
notifications:
discord:
- "discord://webhook_id/webhook_token/"
Telegram
notifications:
telegram:
- "tgram://bot_token/chat_id"
Gotify
notifications:
gotify:
- "gotify://hostname/token"
Pushbullet
notifications:
pushbullet:
- "pbul://access_token"
See the 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:
{
"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
#!/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/checkinGET /checkin— Dark-themed check-in form with status displayPOST /checkin— Submit TOTP code (form or JSON)
JSON API
Check in:
# 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:
{
"success": true,
"status": "armed",
"next_deadline": "2026-02-24T14:30:00+00:00"
}
Get status:
curl http://localhost:8420/status
Response:
{
"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:
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
# 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
- Check-in broadcast: When any node accepts a check-in, it broadcasts to all peers. Peers apply the check-in locally, resetting their timers.
- Trigger broadcast: When any node triggers, it broadcasts the trigger event. All peers transition to TRIGGERED.
- Scheduled item sync: When a node completes a scheduled item, it broadcasts completion so peers skip it (deduplication).
- Health monitoring: Background loop checks peer status every
peer_check_interval. Alerts afterpeer_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>")
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.
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 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:
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:
[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:
sudo systemctl enable posthumous
sudo systemctl start posthumous
sudo systemctl status posthumous
License
MIT