| author | Bronwen Aker |
|---|---|
| updated | 2026-08-05 |
| presentation_type | Workshop |
| venue | Antisyphon AI Summit |
title: # Table of Contents
minLevel: 0
maxLevel: 3
Up to now everything has lived on a single VM. HeartOfGold runs [[10 Glossary#Ollama|Ollama]], and you talk to it from HeartOfGold itself. The point of this workshop is a service you can actually reach from somewhere else, securely, so this lesson connects a second machine, Marvin, to HeartOfGold over an encrypted mesh network built with Tailscale.
You have a private [[10 Glossary#Large Language Model (LLM)|LLM]] on HeartOfGold. You want to use it from Marvin, and later from your laptop, your phone, or a teammate's box, without doing any of the things that usually make a service reachable: no opening firewall ports, no port forwarding on a router, no exposing Ollama to the local network or the public internet. Every one of those options widens your [[10 Glossary#Attack Surface|attack surface]], which is exactly what this workshop is trying to avoid.
[[10 Glossary#Tailscale|Tailscale]] solves this with a [[10 Glossary#Mesh VPN|mesh VPN]] built on the [[10 Glossary#WireGuard|WireGuard]] protocol. Each device you enroll joins a private network called a [[10 Glossary#Tailnet|tailnet]] and gets a stable address in the 100.x.y.z range. Devices then talk directly to each other through encrypted tunnels, even when they sit on different networks behind different routers. Nothing is published to the outside world; the two machines simply find each other and connect.
[!info] Control Plane vs Data Plane Tailscale runs a [[10 Glossary#Coordination Server|coordination server]] that handles identity, key exchange, and access policy. That is the [[10 Glossary#Control Plane|control plane]]. Your actual traffic, the [[10 Glossary#Data Plane|data plane]], flows directly between your devices and is encrypted end to end. Tailscale's servers help your machines find each other, but your private traffic never passes through them.
[!tip] The Easy Button, and the Fully Local Alternative We use Tailscale here because it gets you a working mesh in about two minutes, and because it earns its place on engagements. The tradeoff for this workshop is that the control plane is Tailscale's hosted server, a third party. When you need that control plane inside your own perimeter too, for an internal network or a stricter [[10 Glossary#Trust Boundary|trust boundary]], you self-host it with [[10 Glossary#Headscale|Headscale]] and keep the same client and the same steps. See [[05a Self-Hosting the Mesh with Headscale]].
This is the final architecture once the mesh is up and nginx ([[06 Locking It Down with nginx]]) is in place. Before nginx exists, Marvin's two interfaces talk to Ollama directly instead of going through the basic auth layer.
![[heartofgold_marvin_architecture.png|center]]
The same properties that make Tailscale convenient here make it useful on an engagement. This is why the workshop spends time on it rather than just hardcoding an IP address.
- Persistent, encrypted access. A Tailscale node on a foothold gives you durable access back to that host. The tunnel is encrypted, survives the host changing networks or IP, and traverses network address translation (NAT) without any inbound ports, so there is nothing listening on the perimeter for a defender to find.
- Reaching internal services. A [[10 Glossary#Subnet Router|subnet router]] advertises an entire internal subnet into your tailnet. From your own box you can then reach hosts on the target's internal network as if you were sitting next to them, without standing up a separate tunnel for every host.
- Pivoting with exit nodes. An [[10 Glossary#Exit Node|exit node]] routes your traffic out through the foothold, so your requests appear to originate from inside the target environment.
- Clean, scoped access. [[10 Glossary#MagicDNS|MagicDNS]] gives every node a name instead of an IP, and [[10 Glossary#Access Control List (ACL)|access control lists]] let you scope exactly which nodes can reach which, so a shared tailnet stays controlled rather than wide open.
[!warning] Authorization and Opsec Use this only on engagements you are authorized for, and stay inside your rules of engagement. Remember that Tailscale's coordination server is a third party that sees device metadata such as names, keys, and connection times, even though it never sees your traffic. Factor that into your opsec. When that metadata exposure is unacceptable, self-host the control plane with [[05a Self-Hosting the Mesh with Headscale]].
Before a device can join a tailnet, there has to be a tailnet for it to join, and that is what your account is. Tailscale's coordination server uses your identity to decide which devices belong to your private network and to hand out the keys that let those devices find and trust each other. No account means no tailnet, which is why registration comes first.
Tailscale does not run its own username and password system. It hands [[10 Glossary#Authentication|authentication]] off to an [[10 Glossary#Identity Provider|identity provider]] you already use, such as Google, Microsoft, GitHub, or Apple, over standard [[10 Glossary#OAuth|OAuth]]. You pick a provider, log in there, and Tailscale trusts that provider to vouch for who you are.
[!tip] Signing In Is Fast, and the Free Plan Is Enough If you already have a GitHub account, signing in with GitHub is the quickest path through this step: one click, no new account to create. Signing up with any personal account, GitHub or otherwise, puts you on Tailscale's free Personal plan, which covers everything in this lab, including MagicDNS, exit nodes, and access control lists. It is a real free tier, not a trial.
To register, go to login.tailscale.com/start, choose your identity provider, and authenticate. That creates your tailnet and drops you into the admin console at login.tailscale.com, where every device you enroll will show up.
Note
Tailscale is already installed on HeartOfGold and Marvin, so you can skip this step on the lab VMs. It is here so you can set Tailscale up on your own machines later.
On Linux, one script installs it:
curl -fsSL https://tailscale.com/install.sh | shTailscale also has installers for macOS, Windows, iOS, and Android, available at tailscale.com/download.
On each VM, bring Tailscale up and authenticate it into your tailnet:
[!bothvms] Both VMs
sudo tailscale up
The command prints a URL. Open it in a browser, log in with the account you just created, and that device joins your tailnet. Run this on both HeartOfGold and Marvin, logging into the same account each time so they land in the same tailnet.
Confirm both machines are connected:
[!bothvms] Both VMs
tailscale status
You will see each device with its name and 100.x.y.z address. The same list appears in the admin console. Because MagicDNS is on by default, each machine also picks up a name, so heartofgold and marvin resolve to their tailnet addresses without you memorizing the numbers, once those names are actually set to heartofgold and marvin.
That name is not automatic. On a cloud instance, the OS hostname Tailscale picks up is usually something like ip-10-10-128-174, not heartofgold or marvin, so before you move on, rename both machines in the admin console.
If you are not sure which listed machine is which, ask each VM directly:
[!bothvms] Both VMs
tailscale ip -4
[!danger] IMPORTANT! I'M NOT KIDDING!!! Take note of these IP addresses as you will need them. Soon.
I mean it!
Match the address each command prints to the corresponding entry in the machines list, then rename it:
- Go to console.tailscale.com/admin/machines.
- Open the ··· menu next to the machine and choose Edit machine name…
- Turn off Auto-generate from OS hostname.
- Type
heartofgoldormarvin, whichever this machine is, and click Update name. THIS is why you need those IP addresses. - Repeat for the other machine.
![[tailscale-steps.png|center]]
[!tip] Use the Names, Not the Numbers Anywhere you would type a tailnet IP, you can use the MagicDNS name instead.
heartofgoldis easier to remember and read than100.75.98.11.
With both machines named, here is a shortcut worth adopting right away:
[!tip] Hot Tip: Drive HeartOfGold from Marvin over SSH Now that both machines are on the tailnet, you no longer have to sit at HeartOfGold's headless console for the rest of this lab. From Marvin, open a terminal and connect:
ssh frankie@heartofgoldEvery HeartOfGold command from here on can run inside that SSH session, and its output lands in Marvin's terminal, where you can select and copy it like any other text. That sidesteps the headless copy/paste problem entirely, including grabbing the tailnet IP in the next step. HeartOfGold ships with its SSH server running, so this should connect straight away. If it is refused, confirm the service is up with
sudo systemctl status ssh, and if it is missing entirely,sudo apt install -y openssh-serverthensudo systemctl enable --now ssh.Two terminal windows side by side works well: one logged in to Marvin, one holding the SSH session to HeartOfGold. With the manual open on Marvin, you can copy a command straight into whichever window it belongs to.
Once you do this, both windows are on your Marvin desktop, so watch the command boxes rather than the window. A
HeartOfGold · frankiebox means the command runs on HeartOfGold, which now means the SSH window. The shell prompt is the tiebreaker:frankie@heartofgoldorbenjy@marvin.Widen the lens on this for a second: a tailnet is not just a way to reach a served model, it is a way to get a secure shell onto any device you have enrolled, from anywhere. That is the same persistent, encrypted CLI access named above under "Why Tailscale Matters for Red Teamers," now working for you directly.
By default Ollama listens only on 127.0.0.1, so nothing off the machine can reach it, including Marvin. You will bind it to HeartOfGold's tailnet address instead, so the model answers only over the encrypted mesh and not on the VM's other interfaces.
[!tip]- Write This Address Down Now! (Unless You're Already on SSH) The next command prints HeartOfGold's tailnet address, and you will need it in a minute. If you're driving HeartOfGold over SSH per the Hot Tip above, you already have this beat: select and copy the address straight out of your terminal like any other text.
If you're still working at HeartOfGold's headless console, there is no easy copy/paste there. Capture the IP address as soon as it prints: jot the four octets on paper, or take a screenshot of the [[10 Glossary#Hypervisor|hypervisor]] console window on your host so you can read the address back while you type.
Find HeartOfGold's tailnet address:
[!hog] HeartOfGold · frankie
tailscale ip -4
Then add a [[10 Glossary#systemd|systemd]] override so Ollama binds to that address. Run:
[!hog] HeartOfGold · frankie
sudo systemctl edit ollama
and add the following, replacing the address with the one tailscale ip -4 gave you:
[Service]
Environment="OLLAMA_HOST=100.x.y.z:11434"
Save and exit: Ctrl+O writes the file (press Enter to confirm the filename), and Ctrl+X leaves nano.
Reload and restart so the change takes effect:
[!hog] HeartOfGold · frankie
sudo systemctl daemon-reload sudo systemctl restart ollama
Confirm Ollama is now listening on the tailnet address rather than [[10 Glossary#localhost (Loopback)|localhost]]:
[!hog] HeartOfGold · frankie
ss -tlnp | grep 11434
You should see it bound to your 100.x.y.z address on port 11434, not 127.0.0.1.
[!info]- Using the Ollama CLI on the Server after This Once Ollama binds to the tailnet address, its own command line on HeartOfGold can no longer reach it at the default localhost. If you want to run
ollamaon HeartOfGold itself, point it at the tailnet address first:export OLLAMA_HOST=$(tailscale ip -4):11434
[!note] Minimal Exposure by Design Binding to the tailnet address means the model responds only to devices on your encrypted mesh, and nothing else. [[06 Locking It Down with nginx]] takes the next step: moving Ollama back to localhost and standing up nginx on the mesh with authentication in front of it, so even mesh peers have to prove who they are.
Everything is wired up. From Marvin, reach HeartOfGold's Ollama across the mesh:
[!marvin] Marvin · benjy
curl http://heartofgold:11434/api/tags
You should get back a JSON list of the models installed on HeartOfGold, which is llama3.2 unless you pulled others in [[03 Working with Ollama]]. That response is the proof: Marvin reached the model on HeartOfGold over the encrypted tailnet, with nothing exposed to the wider network.
To watch it actually generate, send a prompt:
[!marvin] Marvin · benjy
curl http://heartofgold:11434/api/generate -d '{"model":"llama3.2","prompt":"Say hello in one sentence.","stream":false}'
[!info] What Are All Those Numbers? Alongside
response, the JSON you get back carries a handful of fields you did not ask for:total_duration,load_duration,prompt_eval_count,prompt_eval_duration,eval_count,eval_duration, and so on. These are Ollama's own performance metrics for that one request, in nanoseconds and [[10 Glossary#Token|tokens]].eval_countis how many tokens the model generated,eval_durationis how long that took, and dividing one by the other gives you tokens per second, the number that tells you whether HeartOfGold's hardware is comfortably keeping up with a given model or straining under it. Worth watching if you pull a larger model later and want to know whether it is still usable.
That is the mesh doing its job: a private model, reachable by name, answering only the device that asked.
[!checkpoint] Checkpoint You have finished this lesson when all of the boxes below are ticked. Work through them in order, and if one does not hold, go back to the section it came from before moving on. Tick each box as you confirm it.
- Both VMs have joined the tailnet and can see each other
- Both machines answer to their real names,
heartofgoldandmarvin, not IPs or generic hostnames- Ollama is reachable over the encrypted mesh only, not the open network
- From Marvin, you've reached HeartOfGold's model across the mesh
[!nav] [[04 Model Customization with Modelfiles]]
[[06 Locking It Down with nginx]]