ntfy
Push to phones, from ntfy.sh or your own server, with a token or username
and password. Self-hosted with access control is the sensible choice.
An SMS relay for shared MFA
Some services still only offer SMS codes, even for break-glass and admin accounts. Tie that account to one person's phone and it leaves when they do. moonshine gives the account its own SIM in a Raspberry Pi, and forwards every code to your team.
Use verification code 482913 to sign in. Never share this code with anyone.
Written to disk before it's deleted from the SIM. A crash means a duplicate, not a loss.
One broken webhook never blocks, or duplicates, delivery to the others.
Plain Python, two dependencies, a hardened systemd unit. Audit it over lunch.
Why
moonshine is a workaround for vendors who still haven't shipped something better. It should never be needed. Sometimes it is.
“This solution should literally never be used, but sometimes needs must. I strongly dislike that I spent time and money on this.”
And no, this totally isn't an overreaction to a certain fruit-based company whose business management portal offers nothing but SMS or a phone call for MFA, unless you've got a federated identity provider. Totally not. Why would you think that?
Destinations
Every SMS goes to every destination you configure. The code is pulled out and put first, so nobody has to squint.
Push to phones, from ntfy.sh or your own server, with a token or username
and password. Self-hosted with access control is the sensible choice.
An Adaptive Card through a Workflows webhook, with the code shown big, the full text, the sender and the time. The SMS is shown as plain text, so it can't hide a link.
An incoming webhook to a private channel. The SMS is sent as plain text, so a malicious
message can't ping @channel or hide a link.
JSON to any HTTPS endpoint, with optional HMAC-SHA256 signing in
X-Moonshine-Signature and whatever headers you need.
No. The docs explain why, at some length.
POST /hooks/sms · X-Moonshine-Signature: sha256=…
{
"event": "sms.received",
"device": "breakglass-sms",
"title": "482913 — SMS from ExampleCloud",
"from": "ExampleCloud",
"code": "482913",
"parts": 1,
"incomplete": false
}
Reliability
Break-glass accounts get used on the worst day of the year. The relay has to work then, too.
The modem announces each SMS. The SIM is also swept on a timer and at start-up, so nothing slips past a missed notification or a reboot.
Decoded and written atomically to disk: temp file, fsync, rename. Only then is it deleted from the SIM.
Each destination is tried on its own, with backoff from 5 seconds to 5 minutes. Success is recorded per destination, so working ones never get a repeat.
Security
So moonshine keeps codes out of everywhere else, and tells you plainly what it can't fix.
SMS bodies aren't logged unless you turn on log_message_content. Spool
files are deleted once delivered.
Apart from scheduled keep-alives and balance checks, an SMS goes out only when someone with a shell on the Pi asks for one.
Set a SIM PIN so a stolen SIM is useless. If the SIM rejects it, moonshine alerts you and never enters that PIN again, even after a restart.
Unknown keys are errors, so a typo can't quietly switch off a destination. Secrets live in a separate env file.
Its own system user, a read-only filesystem, no capabilities, and a 0700 state directory.
SIM swaps and SS7 attacks are out of scope because they're in SMS itself. Where a service offers FIDO2 or passkeys, use them instead.
Build
About the price of a round, if the round is large.
A Pi Zero 2 W, a Waveshare A7670E Cat-1 HAT with its antenna, a SIM that receives SMS and one 5V power supply. The HAT powers on with the Pi.
Give the header the Pi's full UART so SMS aren't corrupted. This turns off Bluetooth, so use a Pi that doesn't need it.
Run the installer, add your destinations and secrets, then send a test to every destination before you text the SIM.
$ sudo ./deploy/install.sh
$ sudo moonshine check-config
$ sudo moonshine test-notify --sms
$ sudo systemctl start moonshine
$ journalctl -u moonshine -f
moonshine starting on breakglass-sms (3 destination(s))
Modem initialised on /dev/serial0
Network: registered (home), operator ExampleNet, rssi 18/31
Received SMS from ExampleCloud (63 chars)
Delivered sms 6f1c0e5d… to ntfy
Delivered sms 6f1c0e5d… to teams
Delivered sms 6f1c0e5d… to slack
Moonshine is spirit made at home, out of sight, from whatever was lying around. It mostly works. Nobody's quite sure what's in it. You probably shouldn't, and you definitely shouldn't brag about it.
This project is much the same. A SIM in a shed-built Pi, distilling one-time codes and passing them round the team. Homemade, unregulated and every bit as sketchy as the drink. Handle with care, and don't serve it to anyone you like.
moonshine is a personal project, provided as-is under the MIT licence, with no warranty and no support. It isn't affiliated with any employer or organisation. It hasn't yet been proven on real hardware, so treat it as early.
Check your network's terms on automated messages, use a business SIM, and only relay codes for accounts whose terms allow shared access. Never use it for banking, government or anyone's personal logins. The README covers the trade-offs in full.
Free and open source under the MIT licence. I hope it's never used.