Pure Internet: Feral

Published 12/4/2024.

#articles#raspberry-pi#solar-powered#cloudflare-tunnel#cloudflared

Continuing my exploration of "pure internet", I started exploring "feral" servers. Non-standard environments that are influenced by their surrounding environment.

I settled on a Raspberry Pi Zero W, a spare board floating around from an older project that was upgraded to a Zero 2 WH. Given the sparse nature of the SOC, eschewing traditional server stacks was needed. No Nginx, Apache, or anything like that. Caddy was a brief consideration, but then I had a thought.

The page I wanted to host was a simple Hono app. Exposing the Hono dev server would be the simplest solution, and the lightweight footprint meant it could run without being resource intensive. Cloudflare Tunnels solved the next piece, letting me securely expose the port.

The Pi is currently powered by an Esprit Paradise solar panel, which barely provides enough power and needs a small battery pack to supplement. Ideally the final form will be portable.

When it is live, you can access the site here: feral.pure---internet.com

The App

The core is two background services and a Hono app. First, the location service handles getting and fuzzing our 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 takes those coordinates and builds our 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)
      }
    };
  }
}

These feed into our main 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

After setup, the app runs at http://localhost:3000, is accessible via Cloudflare tunnel, and auto-starts on boot.

On Living With The Sun

The internet runs on servers. Always-on, always available, almost completely divorced from the physical world.

The site can only be reached when the sun is up. When the sun sets, and the battery drains, the site goes dark. No fancy load balancing, no redundancy.

The next step is making it portable. A self-contained unit that can be placed anywhere with decent sun exposure. With some extra sensors, it could really become a "feral" server.

Previous Pure Internet Experiments