Actions

Operator-defined remote commands triggered over APRS

An Action turns a specially-formatted APRS message into a local command or webhook call. You define the Action in the web UI, hand out a one-time-password (OTP) credential to whoever is allowed to fire it, and from then on a packet of the form @@123456#unlock coming over the air can run a script, hit a webhook, flip a relay, or whatever else you wire up. Every attempt — successful, denied, malformed — is logged, and the result is replied to the sender on the same band the request arrived on.

Actions live on the Actions page in the sidebar. Three sections share the page: a list of defined Actions, a list of OTP credentials, and a live tail of recent invocations.

Actions page showing the Actions table, OTP Credentials table, and Recent Invocations log
The Actions page: the top table holds your Actions, the middle table holds OTP credentials, and the bottom panel tails every invocation in real time.
Before writing a handler: read Writing safe Action handlers. Action handlers run with the privileges of the graywolf service user; safe defaults are not automatic.

Message grammar

Graywolf treats an inbound APRS text message as an Action invocation when both are true:

The body grammar is:

@@<otp>#<action> [k=v ...]

Examples

# An OTP-protected Action with no args:
@@482103#unlock

# Same Action, with a key=value arg:
@@482103#unlock door=garage

# Public Action that does not require OTP:
@@#status

# Public Action with two args:
@@#bulb room=kitchen state=on

The Actions page carries a help banner at the top with a worked example of the wire grammar (@@482910#SetGarageLights state=on), so you can verify the shape without referring back to this page.

Argument mode: kv vs freeform

Each Action has an argument mode that decides how the part of the wire body after the verb is parsed:

# kv mode (default):
@@482103#sms to=+15555551212 msg="hello there"

# freeform mode:
@@482103#sms +15555551212 hello there

Freeform shifts more responsibility to the handler — the operator parses and revalidates the payload inside the script or receiver. The handler safety guide walks through both modes with worked examples and is required reading before deploying a freeform Action.

Defining an Action

Click + New Action at the top of the Actions table. The modal that opens covers every field of the Action in one form; the form is type-aware, so the lower half changes shape depending on whether you pick command or webhook.

Edit Action modal for an ECHO command Action, showing name, description, type, command path, timeout, and OTP fields
The Edit Action form. Fields below the Type radio change shape for command vs. webhook; OTP, sender allowlist, timeout, and queue settings live further down the same scroll.

Common fields:

Command Actions

A command Action runs an executable on the graywolf host. Specific fields:

Execution environment

Each invocation runs as the graywolf service user. Graywolf passes the inbound metadata two ways:

argv (in this order):

  1. argv[0] — the binary itself
  2. argv[1] — action name
  3. argv[2] — sender callsign
  4. argv[3]true or false (whether OTP was verified)
  5. argv[4] onwards — one literal key=value token per argument, in the order they appeared on the wire

Environment variables:

VariableValue
GW_ACTION_NAMEthe Action’s name
GW_SENDER_CALLcallsign that sent the inbound
GW_OTP_VERIFIEDtrue / false
GW_OTP_CRED_NAMEname of the OTP credential used (or empty)
GW_SOURCErf or is
GW_INVOCATION_IDnumeric ID of the audit row for this invocation
GW_ARG_<KEY>one variable per arg; key uppercased, non-alphanumerics replaced with _
PATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

The script’s exit code 0 is success; anything else replies error: exit N. Stdout and stderr are merged and captured up to 4 KiB; the first ~50 characters of the captured output are echoed in the on-air reply as ok: .... The full capture lives in the audit row.

Are my scripts safe from injection?

Short answer: yes, by default, and you don’t need to do anything special to keep it that way. The longer answer is worth understanding so you don’t accidentally undo it in your own script.

A classic worry is “what if someone sends me an arg like foo;rm -rf /?” Two things stop that from being dangerous:

The one way to undo all of this is to write a script that hands its arguments back to a shell yourself. Anything that builds a command line from $1 / $2 / $GW_ARG_* and runs it through eval, bash -c, sh -c, or os.system() reintroduces the shell that graywolf worked hard to keep out. So:

# DANGEROUS — this re-introduces the shell:
bash -c "do-thing $1"

# SAFE — quote the variable, or use it directly as an argv slot:
do-thing "$1"
do-thing -- "$GW_ARG_ROOM"

The same rule applies in any language: pass arguments as separate list items to the next process you invoke (e.g. Python’s subprocess.run([...]) as a list, not as a single string), and keep the value out of any text that you eval, format, or otherwise reparse. As long as your script doesn’t go out of its way to glue arguments back into a shell command, the layered defenses — argv-only execution, the default regex, the per-Action rate limit, OTP, and the audit log — combine into a safe remote-control surface.

Webhook Actions

A webhook Action issues an HTTP request when fired. Specific fields:

Tokens

The URL, header values, and body template can reference invocation metadata using {{name}} tokens. Tokens are substituted in a single pass — substituted text is never reconsidered, so an arg value containing {{action}} cannot leak through to a second expansion.

TokenValue
{{action}}action name
{{sender-callsign}}sender’s callsign
{{otp-verified}}true / false
{{otp-cred}}OTP credential name (or empty)
{{source}}rf or is
{{arg.<key>}}value of the named arg

Examples:

# URL-template; values get URL-encoded:
https://hass.local:8123/api/services/light/turn_on?room={{arg.room}}

# JSON body template; values are inserted literally:
{"action":"{{action}}","sender":"{{sender-callsign}}","room":"{{arg.room}}"}

An HTTP 2xx response is success; anything else replies error: http NNN. The first 1 KiB of the response body is captured for the audit row; the first ~50 characters appear in the on-air reply.

Webhook requests do not follow redirects. A 3xx response surfaces as error: http 3NN and the redirect target is not chased. This is deliberate — a remote attacker who can influence webhook URLs (or persuade your endpoint to issue a redirect) cannot pivot the request against an internal address through your station.

Creating an OTP credential

An OTP credential is a TOTP secret (the same kind your bank or GitHub generates 6-digit codes against), with a friendly name. You attach the credential to one or more Actions, and the codes it produces become the <otp> in the wire grammar.

Click + New Credential on the Actions page, give it a descriptive name (e.g. field-laptop, repeater-tech), and click Create. Graywolf generates a fresh secret server-side and returns the one-time reveal panel:

New OTP Credential dialog with a Name field and Create credential button
The New OTP Credential dialog. Name is the only field you set; algorithm parameters are fixed at TOTP/SHA1/6 digits/30s in v1.

The reveal panel shows the secret only once. After you close it, graywolf retains only what it needs to verify codes — the secret itself is gone for good. Save it immediately:

  1. Scan the QR code with an authenticator app (see the next section), or
  2. Copy the Secret Key shown on the right and type/paste it into your authenticator manually.

Click I’ve saved it to dismiss the panel. Once dismissed the secret is unreadable from the UI — it is stored only as the verification material that graywolf needs to check 6-digit codes. If you lose the device, delete the credential and create a new one.

Single-station design. All credentials use graywolf as the issuer and your station callsign as the account label inside the authenticator app. The UI does not expose the issuer/account fields because graywolf is a single-operator station.

Want to let another graywolf fire these? Copy the Secret Key shown in the reveal panel and send it (over a trusted side channel) to the operator at the other graywolf — the one who wants to fire this Action remotely. They paste it into their Messages → Remote Actions drawer (Manage One-Time Passwords→ New Secret → Secret Key). Same string, two sides. See Remote Actions.

Authenticator apps

Any standard TOTP authenticator works. The QR code in the reveal panel encodes the standard otpauth:// URI, and the Secret Key is plain RFC 4648 base32.

A code is valid for 30 seconds (the standard TOTP step), and graywolf accepts the code from the previous and next step too, giving each code a roughly 90-second window of validity. Once a code has been used successfully, an in-memory replay ring rejects that exact (credential, step, code) tuple for the remainder of its window, so a sniffed RF code cannot be replayed by a third party.

Sender allowlist and rate limits

Two soft layers sit on top of OTP:

Both are tunable per Action. APRS callsigns are spoofable, so treat the allowlist as defense in depth on top of OTP, not as a standalone gate.

Reply format

Every invocation produces an APRS reply on the band the request arrived on:

The first word of the reply is always one of these status tokens:

StatusMeaning
okcommand exit 0 / HTTP 2xx; first ~50 chars of output follow after a colon
unknownparse failure or no Action by that name
deniedsender not in the allowlist
no_credentialAction requires OTP but the assigned credential is missing or was deleted
bad_otp / bad_otp: missing / bad_otp: replay / bad_otp: verifyOTP wrong, empty when required, replayed within the ring window, or verifier rejection
bad_arg: <key>argument failed the schema; first offending key reported
disabledAction exists but is toggled off
busyper-Action queue full
rate_limitedwithin the rate-limit window of the previous invocation
timeoutcommand/webhook exceeded its timeout
error: ...command non-zero exit, HTTP non-2xx, or transport failure

The hard ceiling on the reply is the APRS message cap (~67 bytes). When the captured output exceeds the budget, graywolf truncates and flags the reply with a trailing ellipsis — the full capture remains in the audit log.

Multi-line replies

Each Action carries a Max reply lines setting (defaults to 1, hard ceiling 5). Leave it at 1 and the on-air reply is the single legacy frame described above. Raise it and stdout lines 2..N each ride an additional APRS message, capped at 67 runes per line; the ok: prefix only rides the first frame. Lines past the cap are silently dropped and Truncated is set on the audit row.

Airtime cost is real. Every extra line is one more RF frame plus its own ack and retries on a shared channel. An Action set to 5 turns one trigger into five outbound frames back-to-back, which is antisocial on 144.39 even if it works. Reach for multi-line only when the operator genuinely needs structured output (a multi-field weather summary, a short status block); single-line is the right default for almost every Action. Failure replies (denied, bad_arg, timeout, etc.) always collapse to one frame regardless of the setting.

Audit log

Every attempt — successful, denied, malformed, rate-limited — produces an audit row, surfaced as the Recent Invocations panel at the bottom of the Actions page. Filters at the top of the panel narrow by Action name, sender callsign, status, and time range; the panel live-polls every five seconds.

Each row records:

Retention. The audit table is pruned daily. Graywolf keeps the last 1000 rows or rows younger than 30 days, whichever bound is reached first. There is currently no UI knob to change those defaults; if you need longer retention, export rows yourself periodically.

Security notes

Troubleshooting

Use the Test button before you go on the air

Each row in the Actions table has a Test button. It opens a dialog that lets you enter values for the Action’s args (one input per schema row) and fire the executor through the same code path as a real invocation. The Test path bypasses OTP and the sender allowlist — you are already authenticated to the web UI — but exercises the full sanitization, executor, and audit-write path. The dialog warns about the bypass in its subhead so you do not conclude an Action is unprotected based on Test results.

Test ECHO dialog with a banner explaining that OTP and sender-allowlist checks are bypassed for the test fire
The Test dialog. The banner reminds you that OTP and the sender allowlist are bypassed; the Fire button still goes through sanitization, the executor, and the audit log.

Reading invocation rows

Listener addressees

In addition to your primary callsign and tactical aliases, graywolf can register extra Actions-only addressees called listener addressees. A listener addressee widens the Actions trigger surface so a packet addressed to (say) GW-CMD with an @@-prefixed body fires an Action just like one addressed to your primary callsign would. A packet addressed to a listener that does not begin with @@ doesn’t fire an Action and also doesn’t land in your Messaging inbox — the Messaging router only claims packets addressed to your station call or to a tactical you’ve enabled, so listener-targeted chatter without the sentinel falls on the floor. Use listeners when you want a public command surface without overloading your station’s primary callsign.

Listener addressees are managed today via the REST API only (GET / POST / DELETE on /api/v1/actions/listeners); a UI editor on the Actions page is on the roadmap but not present yet. See the REST API reference for the request shape.