feat: LetheanGateway binary scaffold
Replaces Python+HAProxy gateway stack with a single Go binary: - pkg/pairing: handshake with lthn.io (pair + heartbeat) - pkg/meter: per-session bandwidth metering - cmd/gateway: main binary with admin API on :8880 - Uses core/api for HTTP, pairs via /v1/gateway/* endpoints Eliminates: Python lvpn, HAProxy config templating, legacy session management. Keeps: WireGuard (managed via go-process when ready). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f7f5c303b2
commit
b33b5548ec
4 changed files with 293 additions and 0 deletions
103
cmd/gateway/main.go
Normal file
103
cmd/gateway/main.go
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// LetheanGateway is the companion binary for .lthn gateway node operators.
|
||||
// It replaces the legacy Python+HAProxy stack with a single Go service that:
|
||||
//
|
||||
// - Pairs with lthn.io using the node's chain alias identity
|
||||
// - Manages WireGuard tunnels for VPN customers
|
||||
// - Routes proxy traffic for residential/mobile/SEO services
|
||||
// - Meters bandwidth usage per session
|
||||
// - Reports heartbeat + stats to lthn.io for dispatch
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// lethean-gateway --name charon --api https://api.lthn.io --region eu-west
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"lethean.io/gateway/pkg/meter"
|
||||
"lethean.io/gateway/pkg/pairing"
|
||||
)
|
||||
|
||||
var (
|
||||
apiURL = envDefault("LTHN_API", "https://lthn.io")
|
||||
apiKey = envDefault("LTHN_API_KEY", "")
|
||||
name = envDefault("LTHN_GATEWAY_NAME", "")
|
||||
region = envDefault("LTHN_REGION", "unknown")
|
||||
wgPort = envDefault("WG_PORT", "51820")
|
||||
wgHost = envDefault("WG_HOST", "0.0.0.0")
|
||||
)
|
||||
|
||||
func envDefault(key, fallback string) string {
|
||||
if value := core.Env(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func main() {
|
||||
core.Println("Lethean Gateway")
|
||||
core.Println(core.Sprintf(" Name: %s.lthn", name))
|
||||
core.Println(core.Sprintf(" API: %s", apiURL))
|
||||
core.Println(core.Sprintf(" Region: %s", region))
|
||||
core.Println("")
|
||||
|
||||
if name == "" {
|
||||
core.Println("ERROR: LTHN_GATEWAY_NAME required (your .lthn alias)")
|
||||
return
|
||||
}
|
||||
|
||||
// Bandwidth meter
|
||||
bandwidth := meter.New()
|
||||
|
||||
// Pair with lthn.io
|
||||
client := pairing.NewClient(apiURL, apiKey, name, region, wgHost+":"+wgPort)
|
||||
err := client.Pair()
|
||||
if err != nil {
|
||||
core.Println(core.Sprintf("Pairing failed: %v", err))
|
||||
core.Println("Retrying in 30s...")
|
||||
} else {
|
||||
core.Println("Paired with lthn.io")
|
||||
}
|
||||
|
||||
// Heartbeat loop
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(60 * time.Second)
|
||||
stats := bandwidth.Snapshot()
|
||||
heartbeatErr := client.Heartbeat(stats)
|
||||
if heartbeatErr != nil {
|
||||
core.Println(core.Sprintf("Heartbeat failed: %v — will retry", heartbeatErr))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Local admin API
|
||||
http.HandleFunc("/health", func(writer http.ResponseWriter, request *http.Request) {
|
||||
json.NewEncoder(writer).Encode(map[string]interface{}{
|
||||
"name": name + ".lthn",
|
||||
"region": region,
|
||||
"status": "online",
|
||||
"sessions": bandwidth.ActiveSessions(),
|
||||
"bytes_total": bandwidth.TotalBytes(),
|
||||
})
|
||||
})
|
||||
|
||||
http.HandleFunc("/stats", func(writer http.ResponseWriter, request *http.Request) {
|
||||
json.NewEncoder(writer).Encode(bandwidth.Snapshot())
|
||||
})
|
||||
|
||||
core.Println("Admin API listening on :8880")
|
||||
http.ListenAndServe(":8880", nil)
|
||||
}
|
||||
|
||||
// Placeholder — will be replaced by core/api RouteGroup when ready
|
||||
func init() {
|
||||
_ = context.Background
|
||||
}
|
||||
8
go.mod
Normal file
8
go.mod
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
module lethean.io/gateway
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
dappco.re/go/api v0.8.0-alpha.1
|
||||
dappco.re/go/core v0.8.0-alpha.1
|
||||
)
|
||||
84
pkg/meter/meter.go
Normal file
84
pkg/meter/meter.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Package meter tracks bandwidth usage per session for billing.
|
||||
//
|
||||
// m := meter.New()
|
||||
// m.RecordBytes("session-123", 1048576)
|
||||
// snapshot := m.Snapshot()
|
||||
package meter
|
||||
|
||||
import "sync"
|
||||
|
||||
// Meter tracks bandwidth usage across sessions.
|
||||
type Meter struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]int64 // session ID → bytes
|
||||
totalBytes int64
|
||||
totalSessions int64
|
||||
activeSessions int64
|
||||
}
|
||||
|
||||
// New creates a bandwidth meter.
|
||||
//
|
||||
// m := meter.New()
|
||||
func New() *Meter {
|
||||
return &Meter{
|
||||
sessions: make(map[string]int64),
|
||||
}
|
||||
}
|
||||
|
||||
// RecordBytes adds bytes to a session's usage.
|
||||
//
|
||||
// m.RecordBytes("session-abc", 1048576)
|
||||
func (m *Meter) RecordBytes(sessionID string, bytes int64) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, exists := m.sessions[sessionID]; !exists {
|
||||
m.totalSessions++
|
||||
m.activeSessions++
|
||||
}
|
||||
m.sessions[sessionID] += bytes
|
||||
m.totalBytes += bytes
|
||||
}
|
||||
|
||||
// EndSession marks a session as complete.
|
||||
//
|
||||
// m.EndSession("session-abc")
|
||||
func (m *Meter) EndSession(sessionID string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, exists := m.sessions[sessionID]; exists {
|
||||
m.activeSessions--
|
||||
}
|
||||
}
|
||||
|
||||
// TotalBytes returns total bytes served.
|
||||
func (m *Meter) TotalBytes() int64 {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.totalBytes
|
||||
}
|
||||
|
||||
// ActiveSessions returns current active session count.
|
||||
func (m *Meter) ActiveSessions() int64 {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.activeSessions
|
||||
}
|
||||
|
||||
// Snapshot returns current stats for heartbeat reporting.
|
||||
//
|
||||
// stats := m.Snapshot()
|
||||
func (m *Meter) Snapshot() map[string]interface{} {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
return map[string]interface{}{
|
||||
"connections": m.activeSessions,
|
||||
"load": m.activeSessions * 10, // rough load estimate
|
||||
"bytes_since_last": m.totalBytes,
|
||||
"total_sessions": m.totalSessions,
|
||||
}
|
||||
}
|
||||
98
pkg/pairing/client.go
Normal file
98
pkg/pairing/client.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Package pairing handles the handshake between LetheanGateway and lthn.io.
|
||||
//
|
||||
// client := pairing.NewClient("https://lthn.io", "api-key", "charon", "eu-west", "10.69.69.165:51820")
|
||||
// client.Pair()
|
||||
// client.Heartbeat(stats)
|
||||
package pairing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
)
|
||||
|
||||
// Client manages the pairing lifecycle with lthn.io.
|
||||
type Client struct {
|
||||
apiURL string
|
||||
apiKey string
|
||||
name string
|
||||
region string
|
||||
wireguardEndpoint string
|
||||
}
|
||||
|
||||
// NewClient creates a pairing client for the given gateway.
|
||||
//
|
||||
// client := pairing.NewClient("https://lthn.io", "key", "charon", "eu-west", "10.0.0.1:51820")
|
||||
func NewClient(apiURL, apiKey, name, region, wireguardEndpoint string) *Client {
|
||||
return &Client{
|
||||
apiURL: apiURL,
|
||||
apiKey: apiKey,
|
||||
name: name,
|
||||
region: region,
|
||||
wireguardEndpoint: wireguardEndpoint,
|
||||
}
|
||||
}
|
||||
|
||||
// Pair registers this gateway with lthn.io.
|
||||
//
|
||||
// err := client.Pair()
|
||||
func (c *Client) Pair() error {
|
||||
body := map[string]interface{}{
|
||||
"name": c.name,
|
||||
"capabilities": []string{"vpn", "dns", "proxy"},
|
||||
"region": c.region,
|
||||
"bandwidth_mbps": 100,
|
||||
"max_connections": 50,
|
||||
"wireguard_endpoint": c.wireguardEndpoint,
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(body)
|
||||
request, err := http.NewRequest("POST", c.apiURL+"/v1/gateway/pair", core.NewReader(string(data)))
|
||||
if err != nil {
|
||||
return core.E("pairing.Pair", "failed to create request", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if c.apiKey != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
}
|
||||
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
return core.E("pairing.Pair", "request failed", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != 200 {
|
||||
return core.E("pairing.Pair", core.Sprintf("status %d", response.StatusCode), nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Heartbeat sends alive status + stats to lthn.io.
|
||||
//
|
||||
// err := client.Heartbeat(map[string]interface{}{"connections": 5, "load": 23})
|
||||
func (c *Client) Heartbeat(stats map[string]interface{}) error {
|
||||
stats["name"] = c.name
|
||||
data, _ := json.Marshal(stats)
|
||||
|
||||
request, err := http.NewRequest("POST", c.apiURL+"/v1/gateway/heartbeat", core.NewReader(string(data)))
|
||||
if err != nil {
|
||||
return core.E("pairing.Heartbeat", "failed to create request", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if c.apiKey != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
}
|
||||
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
return core.E("pairing.Heartbeat", "request failed", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue