<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Artem Mishurovskyi</title>
    <description>The latest articles on DEV Community by Artem Mishurovskyi (@follownet).</description>
    <link>https://dev.to/follownet</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4063139%2F9984fbf9-62c6-4ff9-9910-a9e6220ff371.png</url>
      <title>DEV Community: Artem Mishurovskyi</title>
      <link>https://dev.to/follownet</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/follownet"/>
    <language>en</language>
    <item>
      <title>A year solo-building a real VPN: stack, abuse controls, and what broke in production.</title>
      <dc:creator>Artem Mishurovskyi</dc:creator>
      <pubDate>Tue, 04 Aug 2026 21:46:54 +0000</pubDate>
      <link>https://dev.to/follownet/a-year-solo-building-a-real-vpn-stack-abuse-controls-and-what-broke-in-production-2lok</link>
      <guid>https://dev.to/follownet/a-year-solo-building-a-real-vpn-stack-abuse-controls-and-what-broke-in-production-2lok</guid>
      <description>&lt;p&gt;Hi — I'm a full-stack engineer. For almost a year I've been solo-building FollowNet: iOS VPN client, Chrome extension, NestJS backend, VPN nodes, billing, monitoring.&lt;/p&gt;

&lt;p&gt;Not a landing-page MVP post — this is what the stack looks like and what broke after shipping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it is&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;iOS: IKEv2, WireGuard, AmneziaWG, Hysteria2, DNS profiles, auto-connect, Shortcuts, Smart Connect (pick protocol/server from network context)&lt;/li&gt;
&lt;li&gt;Chrome: browser proxy via SOCKS/gateway (not a system-wide VPN)&lt;/li&gt;
&lt;li&gt;Live in the App Store&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Architecture (why modular monolith)&lt;/strong&gt;&lt;br&gt;
I'm one person. One NestJS API with domain modules beats microservices for me — fewer moving parts at 2am.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Postgres (Prisma) = source of truth&lt;/li&gt;
&lt;li&gt;Redis = rate limits, bans, short-lived state, pub/sub&lt;/li&gt;
&lt;li&gt;FreeRADIUS = who gets into VPN and with which group (anon / free / premium)&lt;/li&gt;
&lt;li&gt;Nodes provisioned with Ansible (StrongSwan / WG / Amnezia / Hy2)&lt;/li&gt;
&lt;li&gt;Billing: StoreKit 2 + Apple Server Notifications on iOS; WayForPay on web&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Protocol evolution&lt;/strong&gt;&lt;br&gt;
IKEv2 first (fastest path on iOS without a custom tunnel). Then WireGuard as the default dataplane. Then AmneziaWG + Hysteria2 for networks where plain UDP dies.&lt;/p&gt;

&lt;p&gt;On iOS each protocol is a separate Network Extension target. Easier to debug early; cost is duplicated health-check / quota / reconnect logic. If I started today I'd put one Libbox-based core under them.&lt;/p&gt;

&lt;p&gt;Smart Connect isn't "nearest ping wins" — geo/ASN rules + client success/fail telemetry so we don't start with a protocol that usually fails on that network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auth / access is a chain, not a flag&lt;/strong&gt;&lt;br&gt;
Three different credentials:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;App JWT → API&lt;/li&gt;
&lt;li&gt;RADIUS user/pass → VPN server (esp. IKEv2)&lt;/li&gt;
&lt;li&gt;Short-lived proxy JWT (~4h) → Chrome SOCKS gateway&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Weekly free traffic resets Monday 00:00 UTC. Device slots: 2 free / 5 premium. Oldest lastSeenAt gets kicked when full.&lt;/p&gt;

&lt;p&gt;Biggest pain: state drift. API says premium, RADIUS still free (or the reverse). Cron sync + Socket.IO pushes + sessionId so an old disconnect doesn't kill a fresh reconnect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backend abuse controls (honest scope)&lt;/strong&gt;&lt;br&gt;
Not "anti-DDoS for everything". Mostly API abuse:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Helmet, CORS allowlist, DTO validation&lt;/li&gt;
&lt;li&gt;Redis sliding-window rate limits with escalating bans&lt;/li&gt;
&lt;li&gt;Disposable email blocklist&lt;/li&gt;
&lt;li&gt;Traffic exhausted → notify client → RADIUS CoA → (WG/AWG only) SSH peer remove as narrow fallback if CoA fails&lt;/li&gt;
&lt;li&gt;Apple JWS + WayForPay HMAC webhooks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Certificate pinning + dual API mirrors (.com / .net) on the client — some networks blackhole one host and the app looks "dead" even when VPN nodes are fine.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl3hqmxibg0rxdz6z3lfd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl3hqmxibg0rxdz6z3lfd.png" alt="Grafana: VPN connects and CoA metrics" width="800" height="281"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnnsklgvegf2b3k8itywy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnnsklgvegf2b3k8itywy.png" alt="Admin Panel" width="800" height="309"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What actually broke in prod&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Connected but no internet — green tunnel, dead traffic. Fix: health monitors in extensions + quota gate at tunnel start.&lt;/li&gt;
&lt;li&gt;API config ≠ VPN access — RADIUS out of sync. Fix: CoA retries, CoA agent on node, sessionId race guards.&lt;/li&gt;
&lt;li&gt;LTE↔Wi-Fi handover — too-aggressive health checks flap; too soft = sit on a dead tunnel.&lt;/li&gt;
&lt;li&gt;Node deploy — Ansible helps; Amnezia installs can reboot forever; every new NAS must be in RADIUS clients or IKEv2 EAP times out.&lt;/li&gt;
&lt;li&gt;Chrome is a different product — no system VPN API; SOCKS + TLS gateway + Apple Sign-In relay via backend.&lt;/li&gt;
&lt;li&gt;Apple CONSUMPTION_REQUEST on refunds — answer with session usage, then on REFUND strip premium + update RADIUS.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;What I'd do differently&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One shared session model across client / API / VPN from month one&lt;/li&gt;
&lt;li&gt;One Libbox core instead of multiple NE targets early&lt;/li&gt;
&lt;li&gt;Stabilize WG + accounting before Amnezia/Hy2&lt;/li&gt;
&lt;li&gt;More synthetic probes, fewer "wait for tickets"&lt;/li&gt;
&lt;li&gt;Write down "why this way" from the start&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Three notes to past me&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;API OK ≠ VPN works — design the chain to RADIUS/node, not just config download&lt;/li&gt;
&lt;li&gt;Connected ≠ internet — health + quota belong in the tunnel&lt;/li&gt;
&lt;li&gt;Don't multiply protocols/targets early — one stable path + accounting first&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Happy to answer engineering questions. What would you redesign first on a solo VPN?&lt;/p&gt;

&lt;p&gt;If you want to see the product: &lt;a href="https://follow-net.com/" rel="noopener noreferrer"&gt;follow-net.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ios</category>
      <category>vpn</category>
      <category>indiehackers</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
