Skip to content

queelius/posthumous

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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 /checkin
  • GET /checkin — Dark-themed check-in form with status display
  • POST /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

  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>")

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

About

Federated deadman switch with TOTP authentication, post-trigger scheduling, and peer-to-peer state sync

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages