Skip to content

Repository files navigation

triggerd

Send a notification from your phone, run a task on your machine.

triggerd is a small, long-running daemon that listens for events from configurable event sources and triggers allowlisted actions through pluggable executors. The first supported source is ntfy; the first supported executor triggers a task your operating system already manages — a systemd unit on Linux, a launchd job on macOS, a Task Scheduler task on Windows.

// publish this to your ntfy topic …
{ "service": "update-resume" }
… and triggerd runs the "update-resume" entry from its allowlist.

Contents

  1. Why it exists
  2. Architecture
  3. Supported platforms
  4. Installation
  5. Configuration
  6. ntfy setup
  7. Creating the OS task
  8. Running triggerd
  9. Sending a test notification
  10. Security
  11. Concurrency, timeouts and reliability
  12. Development
  13. Running the tests
  14. Adding a new event source
  15. Adding a new executor
  16. Platform-specific behaviour
  17. Roadmap

Why it exists

Triggering a job on a machine you own usually means one of: exposing SSH, running a webhook server with its own TLS and auth, or wiring up a heavyweight automation platform. None of that is proportionate to "regenerate my resume when I tap a button on my phone".

triggerd takes the smallest useful shape:

  • the machine makes an outbound connection to a pub/sub service, so no inbound port, firewall hole or public hostname is needed;
  • the daemon can only run tasks that are named in its configuration file, so a hostile message is worth nothing beyond what you already allowed;
  • the actual work lives in a normal OS service or scheduled task, so logs, restart policy, permissions and dependencies stay where your platform already handles them.

Everything else in the project exists to keep those three properties true while the number of sources and executors grows.


Architecture

flowchart LR
Phone["Phone / laptop / CI"] -->|publish| Ntfy["ntfy topic"]
Ntfy -->|streaming HTTP| Source["EventSource\n(internal/sources/ntfy)"]
Source -->|domain.Event| Dispatcher["Dispatcher\n(worker pool, timeouts,\nconcurrency policy)"]
Dispatcher --> Decoder["EventDecoder\n(JSON protocol)"]
Decoder -->|ActionRequest| Registry["ActionRegistry\n(allowlist)"]
Registry -->|Action| Executor["ActionExecutor\n(os-task)"]
Executor --> Planner{"Platform planner"}
Planner -->|linux| Systemd["systemctl start x.service"]
Planner -->|darwin| Launchd["launchctl kickstart gui/501/x"]
Planner -->|windows| Schtasks["schtasks /Run /TN x"]
Loading

The core pipeline is fixed:

Event → decode → resolve → execute

What varies sits behind interfaces at the two ends:

InterfaceDefined inVaries because
domain.EventSourceinternal/domainntfy today; webhook, MQTT, stdin, cron later
domain.EventDecoderinternal/domainthe wire protocol is not a source's business
domain.ActionRegistryinternal/domainthe allowlist is configuration, not code
domain.ActionExecutorinternal/domainos-task today; process, HTTP, Docker later
ostask.Planner.../ostasksystemd vs launchd vs Task Scheduler
cmdrunner.Runnerinternal/cmdrunnertests must not need a real service manager

Dependencies point inwards. internal/domain imports nothing but the standard library — no ntfy, no systemd, no YAML, no HTTP.

Package layout

cmd/triggerd/ process entry point (10 lines)
internal/
domain/ Event, ActionRequest, Action, Execution, ports, errors
decoder/ JSON payload -> ActionRequest
registry/ action allowlist and executor lookup
dispatcher/ worker pool, per-action locking, timeouts, shutdown
sources/ntfy/ ntfy subscription: streaming, auth, reconnect, backoff
executors/ostask/ cross-platform OS task executor and its planners
executors/dryrun/ decorator that plans but never runs
cmdrunner/ process execution behind an interface (+ a fake)
config/ YAML schema, env expansion, validation
logging/ slog setup
app/ wiring and lifecycle
cli/ flags, subcommands, signal handling
version/ build metadata
testsupport/ shared test doubles
configs/example.yaml a working, commented configuration
docs/ architecture, configuration, platforms, security, testing
deploy/ ready-to-edit unit files, plists and PowerShell

More detail: docs/architecture.md.


Supported platforms

PlatformExecutor mechanismToolElevation
Linuxsystemd unitsystemctlnone for --user units; root/polkit for system units
macOSlaunchd joblaunchctlnone for gui/user domains; root for system
WindowsTask Scheduler taskschtasksnone for tasks the current user owns

Any other GOOS builds and runs, but os-task actions fail with unsupported platform instead of guessing.


Installation

From source

go install github.com/itsyourap/triggerd/cmd/triggerd@latest

Build locally

git clone https://github.com/itsyourap/triggerd
cd triggerd
go build -o triggerd ./cmd/triggerd # add .exe on Windows

Prebuilt binaries

Every tagged release ships binaries for linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64 and windows/arm64, plus a SHA256SUMS file:

tag=v1.0.0
curl -fsSLO "https://github.com/itsyourap/triggerd/releases/download/$tag/triggerd_${tag}_linux_amd64"
curl -fsSLO "https://github.com/itsyourap/triggerd/releases/download/$tag/SHA256SUMS"
sha256sum --check --ignore-missing SHA256SUMS
install -m 0755 "triggerd_${tag}_linux_amd64" /usr/local/bin/triggerd

The same binaries are attached to every CI run as workflow artifacts if you need an untagged build.

Cutting a release

Push a v* tag. Once the tests, the linter and all six cross-compiles pass, CI creates the GitHub release, generates its notes from the commit history and uploads the binaries and checksums:

git tag -a v1.0.0 -m "v1.0.0"
git push origin v1.0.0

A tag containing a hyphen (v1.0.0-rc.1) is published as a pre-release and does not become "Latest". The tag name is also compiled into the binary, so triggerd version reports it.


Configuration

Start from configs/example.yaml. The short version:

log:
level: info # debug | info | warn | errorformat: text # text | jsondispatcher:
workers: 4# maximum actions running at oncequeue_size: 64# requests that may wait for a workerdefault_timeout: 5mshutdown_grace: 30ssources:
- name: phone # appears on every event this source producestype: ntfyntfy:
server: "https://ntfy.sh"topic: "run-service"token_env: NTFY_TOKEN # optional; reads the token from the environmentactions:
update-resume:
executor: os-tasktarget: "update-resume"# systemd unit / launchd label / task nametimeout: 5mallow_concurrent: false

Rules worth knowing:

  • Unknown keys are errors. A typo fails at startup instead of silently doing nothing.
  • Durations are strings: 30s, 5m, 1h30m.
  • ${VAR} and ${VAR:-default} are expanded everywhere in the file (including comments); an unset variable without a default is a startup error. $${ is a literal ${. For secrets prefer token_env: / password_env:, which read the value directly and never touch the YAML.
  • Every problem is reported at once, so one edit can fix them all.

Check a file without starting anything:

triggerd validate-config --config configs/example.yaml
configuration configs/example.yaml is valid
log: level=info format=text
dispatcher: workers=4 queue_size=64 default_timeout=5m0s shutdown_grace=30s
sources:
- phone (ntfy, enabled) server=https://ntfy.sh topic=run-service auth=none
actions:
- update-resume: executor=os-task target=update-resume timeout=5m0s allow_concurrent=false
planned commands on this host:
- update-resume: schtasks /Run /TN update-resume

Full reference: docs/configuration.md.


ntfy setup

  1. Pick a topic name that is long and unguessable — on the public ntfy.sh server, a topic name is the only thing protecting it:

    run-service-8Fj2kQn7cZ
    
  2. Subscribe to the same topic in the ntfy app on your phone (optional, but handy for publishing).

  3. For anything beyond personal use, run your own ntfy server or enable access control and give triggerd a read-only token:

    ntfy:
    server: "https://ntfy.example.com"topic: "run-service"token_env: NTFY_TOKEN
    NTFY_TOKEN=tk_… triggerd --config /etc/triggerd/config.yaml

Tokens and passwords are never written to logs, and validate-config reports only whether credentials are present.


Creating the OS task

Ready-to-edit files live in deploy/. Summary:

Linux (systemd, user scope)~/.config/systemd/user/update-resume.service:

[Unit]Description=Update resume
[Service]Type=oneshot
ExecStart=%h/bin/update-resume.sh
systemctl --user daemon-reload
systemctl --user start update-resume.service # verify it works

Then set options: { systemd.scope: user } on the action.

macOS (launchd)~/Library/LaunchAgents/com.example.update-resume.plist with RunAtLoad=false, loaded via launchctl bootstrap gui/$UID …. Use the label as the action target.

Windows (Task Scheduler):

schtasks /Create /TN "update-resume"/TR "powershell -File C:\scripts\update-resume.ps1"/SC ONCE /ST 00:00/F

Full instructions, including how to run triggerd itself as a service on each platform: docs/platforms.md.


Running triggerd

triggerd # uses ./triggerd.yaml or $TRIGGERD_CONFIG
triggerd --config /etc/triggerd/config.yaml
triggerd --config config.yaml --dry-run --log-level debug
triggerd validate-config --config config.yaml
triggerd version
triggerd --help

Dry-run mode runs the entire pipeline — subscribe, receive, decode, resolve, plan — and logs the command it would run without executing it:

level=INFO msg="dry run: action not executed" action.action=update-resume
would_execute="schtasks /Run /TN update-resume"

It is the fastest way to develop against a real topic from a laptop.

Exit codes: 0 clean shutdown, 1 runtime failure, 2 usage error, 3 configuration error (restarting will not help).


Sending a test notification

curl -d '{"service":"update-resume"}' https://ntfy.sh/run-service-8Fj2kQn7cZ

With a token:

curl -H "Authorization: Bearer $NTFY_TOKEN" \
-d '{"service":"update-resume"}' \
https://ntfy.example.com/run-service-8Fj2kQn7cZ

From the ntfy Android/iOS app, send the same JSON as the message body.

The daemon logs each stage:

level=INFO msg="action started" action.action=update-resume status=started
level=INFO msg="action completed" action.action=update-resume status=completed duration=1.2s

Security

triggerd is a remote execution system. It is built accordingly:

  • Allowlist only. An event selects a name; the name must already exist in actions:. Unknown names are logged and dropped.
  • No commands on the wire. The protocol has no field for a command, target, executor or timeout. {"command":"rm -rf /"} is rejected as an unknown field, and {"service":"rm -rf /"} fails action-name validation before any lookup happens.
  • No shell, ever. Executors build an argument list and use exec.CommandContext. There is no sh -c anywhere in the codebase.
  • No argument injection. Targets come only from configuration, and are still rejected if they start with - or /, contain quotes, control characters or (where the platform requires it) whitespace.
  • Params never reach the command line. The optional params field is carried to the executor, and the os-task executor ignores it by design.
  • Secrets stay out of logs.domain.Event and domain.ActionRequest implement slog.LogValuer and deliberately omit payload bytes and param values; auth headers are set in exactly one function and never logged.
  • Bounded everything. Payload size, param count, name length, queue depth, worker count, captured command output and action runtime are all capped.

Details and threat model: docs/security.md.


Concurrency, timeouts and reliability

  • Bounded worker pool.dispatcher.workers actions run at once; dispatcher.queue_size may wait. When the queue is full, new events are dropped and logged rather than buffered without limit — memory stays bounded and the operator sees it.
  • No overlapping runs by default. A second request for an action that is already running is rejected (allow_concurrent: true opts out). This is a dispatcher policy, not executor behaviour, so it works for every executor.
  • Timeouts. Every execution gets context.WithTimeout from the action's timeout or dispatcher.default_timeout.
  • Retries where they are safe. The ntfy source reconnects with exponential backoff and jitter, forever. Failed actions are never retried automatically: most real tasks are not idempotent.
  • Graceful shutdown. SIGINT/SIGTERM cancels the root context; sources stop; queued-but-unstarted requests are discarded; running actions get shutdown_grace to finish before their context is cancelled; then the process exits.
  • Unrecoverable source failures stop the daemon (bad credentials, wrong URL) instead of leaving a process that is running but deaf. A service manager can restart it once the configuration is fixed.
  • Duplicate suppression. The last 256 ntfy message ids are remembered, so a reconnect that replays messages cannot run an action twice.

Development

The project is developed on Windows and tested on all three platforms; nothing in the test suite assumes /bin/sh, systemctl, Unix paths or Unix signals.

go build ./...
go vet ./...
go test ./...
go test -race ./...
gofmt -l .

One Windows-specific gotcha: PowerShell splits an unquoted -coverprofile=coverage.out into two arguments, and go test then treats .out as a package name. Quote the flag (go test "-coverprofile=coverage.out" ./...) or use bash. CI pins every matrix job to bash for the same reason.

Handy loop while developing:

go run ./cmd/triggerd validate-config --config configs/example.yaml
go run ./cmd/triggerd --config configs/example.yaml --dry-run --log-level debug

A Makefile wraps the same commands (make test, make lint, make build, make dist).


Running the tests

CommandWhat it covers
go test ./...everything below except the integration tag
go test -race ./...the same, with the race detector
go test -tags=integration ./...additionally talks to the real service manager and a real ntfy server

The suite is split deliberately:

Platform-independent unit tests — decoding, validation, registries, routing, executor selection, dispatcher concurrency, timeouts, cancellation, shutdown, configuration parsing and error handling. No OS service required.

Platform adapter tests — the systemd, launchd and Task Scheduler planners are ordinary functions compiled on every platform, so all three are tested from any host. They assert exact argument lists:

systemctl--userstartupdate-resume.servicelaunchctlkickstart-kgui/501/com.example.update-resumeschtasks/Run/TNupdate-resume

The executor itself is driven through a fake cmdrunner.Runner, which covers success, non-zero exit, missing tool, unknown target, cancellation, timeout and stderr handling without touching the machine. A separate test asserts that the planner selected by the build constraints matches runtime.GOOS, which ties those tests back to real behaviour on each OS.

ntfy tests run against httptest servers that speak the real newline-delimited JSON stream: subscription, keepalives, malformed frames, reconnect, HTTP 500 retry, HTTP 401 giving up, auth headers, duplicate suppression, resume-on-reconnect and the read-idle watchdog.

Integration tests (-tags=integration) are opt-in and documented in docs/integration-tests.md.

CI runs build, vet, tests and -race on ubuntu-latest, macos-latest and windows-latest, lints on Linux, and cross-compiles six targets.


Adding a new event source

Say you want an HTTP webhook. You touch two files plus the new package; the dispatcher, decoder, registries and executors are not modified.

  1. Implement the interface:

    // internal/sources/webhook/source.gotypeSourcestruct{ /* … */ }
    func (s*Source) Name() string { returns.name }
    func (s*Source) Run(ctx context.Context, outchan<- domain.Event) error {
    // serve until ctx is done; for each request:select {
    caseout<- domain.Event{
    ID: requestID,
    Source: s.name,
    Type: domain.EventTypeMessage,
    Payload: body, // not interpreted hereMetadata: map[string]string{"http.remote": addr}, // source-specific, generic shape
    }:
    case<-ctx.Done():
    returnctx.Err()
    }
    returnnil
    }
  2. Add the schema: a SourceTypeWebhook constant, a Webhook *WebhookConfig block on config.SourceConfig, and a case in validateSources.

  3. Add a case to buildSources in internal/app/app.go.

That is the whole extension point. Payload format, action names and execution are unaffected, because a source's only job is to produce domain.Event.


Adding a new executor

Say you want to run a process directly.

  1. Implement the interface:

    // internal/executors/process/executor.goconstName="process"func (e*Executor) Name() string { returnName }
    func (e*Executor) Execute(ctx context.Context, ex domain.Execution) error {
    // use cmdrunner.Runner so the executor stays testable_, err:=e.runner.Run(ctx, ex.Action.Target, e.argsFrom(ex.Action.Options)...)
    returnerr
    }
  2. Optionally implement domain.ExecutionDescriber (gives you --dry-run and validate-config output for free) and domain.ActionValidator (gives you startup validation for free).

  3. Register it in buildExecutors in internal/app/app.go.

Then executor: process works in configuration. ntfy, the decoder, the dispatcher and the systemd/launchd/schtasks code are untouched.


Platform-specific behaviour

BehaviourLinuxmacOSWindows
Commandsystemctl [--user] <verb> <unit>launchctl kickstart [-k] <domain>/<uid>/<label> or launchctl start <label>schtasks /Run|/End /TN <name>
Target suffix.service appended unless the target already names a unit typenonenone; \folder\task is supported
Optionssystemd.scope, systemd.verblaunchd.domain, launchd.verb, launchd.restartschtasks.verb
Blockingsystemctl start waits for a oneshot unit to finishkickstart returns once the job is launched/Run returns once the task is started
"Not found" detectionexit code 5 or a "not found" messageexit code 113 or "Could not find service""cannot find the file specified" (localised)

Because launchd and Task Scheduler return as soon as the job starts, a triggerd action succeeding means "the task was started", not "the task finished". On Linux with Type=oneshot, it means the task finished. That difference is inherent to the platforms, and is why the executor reports the exit status of the trigger, not of your script.

Options that belong to another platform are ignored, so one configuration file can be deployed to Linux, macOS and Windows hosts unchanged.


Roadmap

Not implemented, but the architecture is shaped for it:

  • Sources: HTTP webhook, Unix socket / named pipe, stdin, MQTT, cron, filesystem watch.
  • Executors: direct process, HTTP request, Docker container.
  • Policies: per-action rate limiting, an optional "queue instead of reject" concurrency policy, opt-in retries for actions declared idempotent.
  • Observability: Prometheus metrics from the dispatcher counters that already exist, and a health endpoint.
  • Protocol: signed payloads for use on shared topics.

License

MIT.

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages