Writing safe Action handlers
An Action handler is a handler — a shell script, a PowerShell script, a webhook receiver — that you control. graywolf gives it a payload from a remote APRS sender. Your handler decides what to do with that payload. Anything an attacker can persuade your handler to do, they can do over the air.
This page is the overview: the two argument modes, the contract graywolf passes to your handler, and the final safety checklist that applies to every handler.
Three sub-pages drill into worked examples for each handler kind, with line-by-line explanations and anti-patterns to avoid:
- Shell handlers — bash freeform and kv worked examples, shellcheck guidance.
- PowerShell handlers — the freeform SMS Action wired for a Windows host, PSScriptAnalyzer guidance.
- Webhook handlers — a Python Flask receiver, custom body templates and the filter syntax for templated user data.
Every example is also shipped in examples/actions/ in
the source tree.
Two argument modes
Each Action has a mode that controls how the wire-format arguments reach your handler:
| Mode | Wire format | Use when |
|---|---|---|
kv (default) |
@@<otp>#name k1=v1 k2=v2 |
You have multiple, named, individually-validated arguments. |
freeform |
@@<otp>#name <raw text> |
Everything after the verb is one payload (e.g. an SMS). |
Pick freeform when you want a sender to type something natural like
@@482910#sms +15555551212 hello there instead of
@@482910#sms to=+15555551212 msg="hello there".
The cost: the operator (you) takes on more responsibility for
parsing and revalidating the payload. The sub-pages are about
discharging that responsibility correctly.
kv vs freeform — when to pick which
- If your Action has 2+ structured fields each with their own regex (an entity ID and a state, a phone number and a message), kv is friendlier. Each field gets its own validator. Mistakes from senders are reported per-field ("bad arg: state").
- If the natural form of the input is one chunk of text (an SMS body, a notification body, a message-to-be-spoken), freeform reads better on-air. The cost is that you do the splitting.
- If the value is going to be passed through your handler to a downstream API that already accepts it as one string, freeform avoids needless ceremony.
- Freeform is much easier on the sender,
especially from a limited user interface like an HT keypad.
@@482103#sms +15555551212 hello thereis realistic to thumb in on a radio's number-pad multi-tap;@@482103#sms to=+15555551212 msg="hello there"— with the=, the quotes, and the second key — is not. If your senders are likely to be on portables, prefer freeform whenever the input is naturally one chunk.
What graywolf passes to your handler
Command Actions (shell or PowerShell)
The runner exec's your binary directly — no shell, no PATH
lookup funkiness, no system(). argv layout depends on
mode:
kv | freeform | |
|---|---|---|
| $1 / $args[0] | action name | action name |
| $2 / $args[1] | sender callsign | sender callsign |
| $3 / $args[2] | true | false (OTP verified) | same |
| $4..$N / $args[3..N] | k=v tokens, schema order | the entire raw payload, one token |
Environment variables are also set:
GW_ACTION_NAME,GW_SENDER_CALL,GW_OTP_VERIFIED,GW_OTP_CRED_NAME,GW_SOURCE,GW_INVOCATION_ID— always.- kv:
GW_ARG_<KEY>=<value>per declared arg. - freeform:
GW_ARG=<raw payload>(single var, no key suffix).
Webhook Actions
graywolf POSTs URL-encoded form data by default (or your custom template body if you set one). Default form fields:
action,sender_callsign,otp_verified,otp_cred,source- kv: one field per declared arg (key = your schema key).
- freeform: one field named
argwith the raw payload.
Custom body templates support these tokens:
{{action}},{{sender-callsign}},{{otp-verified}},{{otp-cred}},{{source}}- kv:
{{arg.<key>}} - freeform:
{{arg}}(bare) - Filters:
{{arg|json}},{{arg|url}},{{arg|html}}— same for any token. Use these. See the webhook handlers page for worked examples.
Reply policy and multi-line output
For successful invocations (status ok) graywolf may
emit up to the Action's Max reply lines setting (default
1, ceiling 5) as separate APRS messages. Each non-empty stdout line
becomes one outbound DM with its own message id, ack, and retries.
Lines are capped at 67 characters; longer lines are truncated with
…. Blank lines are dropped.
Failure statuses (bad_arg, timeout, denied, etc.) always collapse
to a single reply regardless of Max reply lines —
fanning out a failure across multiple frames is never useful.
Each extra line is one extra RF frame plus its own ack and retry
window. Keep Max reply lines at 1 unless your handler
genuinely needs to deliver multiple short messages.
Final safety checklist
Before deploying any Action handler:
- Run
shellcheck(shell),Invoke-ScriptAnalyzer(PowerShell), orruff(Python). Fix everything it flags. Don't disable rules. - Quote every variable expansion. Search for
$GW_not preceded by". - Confirm there is no
eval, nosh -cwith user data, noInvoke-Expression. - Confirm any external command takes user data via
---terminated argv (or array-ArgumentListin PowerShell), not concatenated strings. - Confirm any database write uses parameterized placeholders.
- Confirm any HTML render uses framework auto-escape.
- For webhooks: confirm the receiver authenticates (HMAC / shared secret), caps body size, and is reachable only via TLS.
- Test the handler with payloads designed to break it: leading
-, embedded'and",&and=, max-length values. Use the Test dialog in the operator UI for this — it runs the handler bypassing OTP and the sender allowlist.