Every tool here leans on Tailscale in some way. smed finds and reaches machines through it, unit deploys over it, and Punchcard receives GitHub's webhooks through Tailscale Funnel while it runs on my laptop. This is how I set it up, including the parts that tripped me up.
1. A personal tailnet
Sign up at login.tailscale.com. The free personal plan allows 3 users and 100 devices, which is far more than this needs. I sign in with GitHub, so Tailscale calls me stianh@github.
If you already use Tailscale at work, make a separate tailnet anyway. Work policies (like approving every SSH login in a browser) get in the way of unattended deploys, and your hobby machines don't belong in your employer's network.
2. The laptop
Install Tailscale, then log in:
tailscale login
On Linux, let your own user run tailscale without sudo. You only need to do this once per account:
sudo tailscale set --operator=$USER
Run it in a real terminal: sudo needs somewhere to ask for your password.
3. Switching between work and personal
Tailscale keeps several accounts on one machine. tailscale login adds a new one next to the old one and switches to it. After that:
tailscale switch --list # * marks the active account
tailscale switch <name> # the name or ID the list shows
Only one tailnet is active at a time, and open connections drop when you switch. Funnel and serve settings belong to each account, so something you publish on one account is gone while the other one is active, and comes back when you switch back.
4. Admin console: DNS
Under DNS in the admin console, turn on:
- MagicDNS, so machines are reachable by name (
ssh vt100) instead of by address. - HTTPS Certificates, so machines can get real certificates for names like
laptop.tail1234.ts.net. Funnel needs this, and so doestailscale serve.
Certificates are recorded in public Certificate Transparency logs, so every machine that gets one has its name published. Don't give machines names you'd mind being public.
5. The policy file
Under Access controls, replace the default policy with this one. It uses two tags: tag:vt100 for servers that run vt100 things, and tag:ci for the machine that runs Punchcard. One server can carry both.
{
// Who may put which tag on a machine. Only me.
"tagOwners": {
"tag:vt100": ["autogroup:admin"],
"tag:ci": ["autogroup:admin"],
},
// Network access.
"grants": [
// My own devices reach everything.
{"src": ["autogroup:member"], "dst": ["*"], "ip": ["*"]},
// Punchcard reaches the servers it deploys to, over SSH only.
{"src": ["tag:ci"], "dst": ["tag:vt100"], "ip": ["tcp:22"]},
],
// Tailscale SSH: no authorized_keys anywhere.
"ssh": [
{"action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:self"], "users": ["autogroup:nonroot", "root"]},
{"action": "accept", "src": ["autogroup:member"], "dst": ["tag:vt100"], "users": ["root", "deploy"]},
{"action": "accept", "src": ["tag:ci"], "dst": ["tag:vt100"], "users": ["deploy"]},
],
// My own devices may publish a port to the internet with Funnel.
"nodeAttrs": [
{"target": ["autogroup:member"], "attr": ["funnel"]},
],
// Checked on save: the policy is rejected if this doesn't hold.
"sshTests": [
{"src": "tag:ci", "dst": ["tag:vt100"], "accept": ["deploy"], "deny": ["root"]},
],
}
What it means:
- My devices reach everything, and can log in to the servers as
rootordeploy. - The CI machine can reach the servers only on SSH, and only as
deploy. That's enough forunit deploy, and a build that goes wrong can't become root on a server. - The servers can't start connections to my devices. No rule lets them, so a compromised server can't reach my laptop.
- SSH rules use
accept, notcheck.checkasks a human to approve each login in a browser. That's good for people on sensitive machines, but it blocks deploys that run on their own. - No email addresses.
autogroup:adminandautogroup:membermean me, whichever account I signed up with. If you'd rather see your name, write"you@github"instead; it's the same while you're the only user.
6. Funnel, for GitHub's webhooks
While Punchcard runs on my laptop, GitHub reaches it through Funnel, which publishes one local port at a public HTTPS address:
tailscale funnel --bg 8081 # stays on across reboots
tailscale funnel status
tailscale funnel --https=443 off # turn it off
It prints the public address, like https://laptop.tail1234.ts.net/. That's the webhook URL for the GitHub App. Funnel needs both the funnel attribute in the policy and HTTPS certificates; without them it fails or waits.
A sleeping laptop misses webhooks, and GitHub doesn't retry them. Redeliver missed ones from the App's settings under Advanced, or push again. The real fix is a server.
7. Servers join on first boot
A new server joins the tailnet the first time it starts, with an auth key from Settings › Keys:
- Pre-approved, so it doesn't wait for me to approve it.
- Tags:
tag:vt100, plustag:cifor the machine that runs Punchcard. - Not ephemeral, so it stays in the tailnet after a restart.
- Single use, with a short expiry. The key ends up in the server's first-boot data, which can be read from inside the server afterwards. A used, expired key is worthless.
The server runs tailscale up --ssh with the key and tags. From then on, I reach it with plain ssh deploy@vt100. Its public firewall only opens 80 and 443; SSH never faces the internet.
What tripped me up
- A fresh policy file without
nodeAttrssilently blocks Funnel. tailscale loginswitches to the new account right away. Anything published with Funnel on the old account stops until you switch back.sudocan't ask for a password from a tool that runs commands for you without a terminal. Run it yourself.- The
checkSSH mode on my work tailnet made every login wait for a browser. Fine for me, impossible for a deploy.