home menu

Pure Internet: Feral

A Raspberry Pi Zero W solar-powered "feral server" running a lightweight Hono app exposed via Cloudflare Tunnels, with location-aware.

On this page

Next thread in pure internet: feral servers. Non-standard environments shaped by the place they sit in.

The board is a Raspberry Pi Zero W I had lying around after upgrading another project to a Zero 2 WH. The SOC is too thin for Nginx or Apache. I considered Caddy. Then I realized I didn't need any of it.

The site I wanted to host was a Hono app. Exposing the dev server directly was the smallest possible setup, and Hono's footprint left plenty of headroom. Cloudflare Tunnels handled the port exposure.

An Esprit Paradise solar panel powers the Pi. It barely keeps up, with a small battery pack to smooth the gaps. The end goal is fully portable.

When it's online: feral.pure---internet.com

The app

Two background services feed a Hono app. The location service fuzzes the coordinates:

export class LocationService {
  private cache: LocationData | null = null;
  private readonly CACHE_FILE = join(__dirname, "../../../cache/location.json");
  private readonly UPDATE_INTERVAL = 60 * 60 * 1000; // 1 hour

  private fuzzLocation(lat: number, lon: number): { latitude: number; longitude: number } {
    // Generate a random distance up to FUZZ_RADIUS_MILES
    const radiusMiles = Math.random() * FUZZ_RADIUS_MILES;
    const angle = Math.random() * 2 * Math.PI;
    
    // Convert to angular distance
    const angularDistance = radiusMiles / EARTH_RADIUS_MILES;

    // Calculate fuzzy position using spherical geometry
    const lat1 = lat * (Math.PI / 180);
    const lon1 = lon * (Math.PI / 180);

    const lat2 = Math.asin(
      Math.sin(lat1) * Math.cos(angularDistance) + 
      Math.cos(lat1) * Math.sin(angularDistance) * Math.cos(angle)
    );

    // ... coordinate calculations ...

    return {
      latitude: lat2 * (180 / Math.PI),
      longitude: ((lon2 * (180 / Math.PI) + 540) % 360) - 180,
    };
  }
}

The environment service uses those coordinates to build the ecosystem data:

export class EnvironmentService {
  private cache: EnvironmentalData | null = null;
  private readonly CACHE_FILE = join(__dirname, "../../../cache/ecosystem.json");
  private readonly UPDATE_INTERVAL = 5 * 60 * 1000; // 5 minutes

  private async updateEnvironment(): Promise<EnvironmentalData> {
    const locationData = await this.locationService.getLocation();
    
    // Get weather data from Open-Meteo
    const weatherData = await this.fetchWeather(locationData);
    
    // Get air quality if we have an API key
    const airQuality = process.env.WAQI_TOKEN 
      ? await this.fetchAirQuality(locationData)
      : this.defaultAirQuality;

    // Calculate sun and moon data
    const now = new Date();
    const times = suncalc.getTimes(now, locationData.latitude, locationData.longitude);
    const moonIllum = suncalc.getMoonIllumination(now);

    return {
      location: { /* location data */ },
      weather: { /* weather data */ },
      air: airQuality,
      astronomy: {
        sunrise: times.sunrise.toLocaleTimeString(),
        sunset: times.sunset.toLocaleTimeString(),
        moonPhase: this.getMoonPhase(moonIllum.phase),
        dayLength: this.formatDayLength(times.sunset - times.sunrise)
      }
    };
  }
}

Both feed into the Hono app:

const app = new Hono();
const locationService = new LocationService();
const environmentService = new EnvironmentService(locationService);

// Serve the main page with markdown content and ecosystem data
app.get("/", async (c) => {
  const home = readFileSync(join(__dirname, "content/home.md"), "utf-8");
  const etc = readFileSync(join(__dirname, "content/etc.md"), "utf-8");
  const data = await environmentService.getEnvironment();

  return c.html(layout(`
    ${marked(home)}
    ${renderEcosystem(data)}
    ${marked(etc)}
  `));
});

// API endpoints for direct data access
app.get("/api/environment", async (c) => c.json(await environmentService.getEnvironment()));
app.get("/api/location", async (c) => c.json(await locationService.getLocation()));

Setup

# Clone and install
git clone https://github.com/iammatthias/feral-pure-internet.git
cd feral-pure-internet
pnpm install

# Set up environment
echo "WAQI_TOKEN=your_token" > .env

# Configure Cloudflare Tunnel
cloudflared tunnel create feral
cloudflared tunnel route dns feral feral.pure---internet.com

# Run with PM2
pm2 start "pnpm dev" --name feral
pm2 save
pm2 startup systemd -u pi --hp /home/pi

The app runs at localhost:3000, exposes through the tunnel, and auto-starts on boot.

Living with the sun

Most servers are always-on, always-available, and disconnected from the world they sit in.

This one is reachable when the sun is up. When it sets and the battery drains, the site goes dark. No load balancing, no redundancy.

Portability is the next step: a self-contained unit you can drop somewhere with sun. Add a few sensors and it really starts to feel feral.

Earlier pure internet experiments

  • NFC: store a data URL on an NFC tag, load in browser on scan. Top-level navigation rejects data URLs, so the workaround pins minimal HTML on IPFS with JS bootstrapping the data URL from a query param into an iframe.

  • Bluesky: inspired by Daniel Mangum's work. Hosted on the AtProtocol via the PDS and content-addressed blob storage.

collection
posts
rkey
1733357455673-pure-internet-feral
record cid
bafkreicpllss2p53knivsf5ze5jvfzegjdpnbz76dkovruh7efoapqs5lu
record
https://content.farfield.systems/api/entries/1733357455673-pure-internet-feral
created
updated