This commit is contained in:
jdl 2025-01-02 07:42:00 +01:00
parent 5d97cccb98
commit f0076939d5
12 changed files with 131 additions and 197 deletions

View File

@ -1,41 +1,8 @@
# vppn: Virtual Pretty Private Network # vppn: Virtual Pretty Private Network
## Roadmap ## TODO
* Use probe and relayed-probe packets vs ping/pong. * Add `-force-init` argument to `node` main?
* Rename Mediator -> Relay
* Use default port 456
* Remove signing key from hub
* Peer: UDP hole-punching
* Peer: local peer discovery - part of RoutingProcessor
* Peer: update hub w/ latest port on startup
## Learnings
* Encryption / decryption is 20x faster than signing/opening.
* Allowing out-of order packets is massively important for throughput with TCP
## Principles
* Creates an IPv4/24 network with a maximum of 254 peers. (1-254)
* Simple setup: via setup link from the hub.
* Each peer has full network state replicated from the hub.
## Routing
* Routing is different for public vs non-public peers
* Public: routes are initialized via incoming ping requests
* NonPub: routes are initialized via incoming ping responses
A non-public peer needs to maintain connections with every public peer.
* Sending:
* Public: send to address
* Non-public: send to a mediator
* Pings:
* Servers don't need to ping
* Clients need to ping all public and local peers to keep connections open
## Hub Server Configuration ## Hub Server Configuration
@ -106,7 +73,7 @@ AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_NET_ADMIN
Type=simple Type=simple
User=user User=user
WorkingDirectory=/home/user/ WorkingDirectory=/home/user/
ExecStart=/home/user/vppn -name vppn ExecStart=/home/user/vppn -name vppn -hub-address https://my.hub -api-key 1234567890
Restart=always Restart=always
RestartSec=8 RestartSec=8
TimeoutStopSec=24 TimeoutStopSec=24

View File

@ -1,7 +1,6 @@
package api package api
import ( import (
"crypto/rand"
"database/sql" "database/sql"
"embed" "embed"
"errors" "errors"
@ -14,17 +13,14 @@ import (
"git.crumpington.com/lib/go/idgen" "git.crumpington.com/lib/go/idgen"
"git.crumpington.com/lib/go/sqliteutil" "git.crumpington.com/lib/go/sqliteutil"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/nacl/box"
"golang.org/x/crypto/nacl/sign"
) )
//go:embed migrations //go:embed migrations
var migrations embed.FS var migrations embed.FS
type API struct { type API struct {
db *sql.DB db *sql.DB
lock sync.Mutex lock sync.Mutex
initIntents map[string]byte // Map from intent key to peer IP
} }
func New(dbPath string) (*API, error) { func New(dbPath string) (*API, error) {
@ -38,8 +34,7 @@ func New(dbPath string) (*API, error) {
} }
a := &API{ a := &API{
db: sqlDB, db: sqlDB,
initIntents: map[string]byte{},
} }
return a, a.ensurePassword() return a, a.ensurePassword()
@ -151,55 +146,13 @@ func (a *API) Peer_CreateNew(p *Peer) error {
return db.Peer_Insert(a.db, p) return db.Peer_Insert(a.db, p)
} }
// Create the intention to initialize a peer. The returned code is used to func (a *API) Peer_Init(peer *Peer, args m.PeerInitArgs) (*m.PeerConfig, error) {
// complete the peer initialization. The code is valid for 5 minutes.
func (a *API) Peer_CreateInitIntent(peerIP byte) string {
a.lock.Lock() a.lock.Lock()
defer a.lock.Unlock() defer a.lock.Unlock()
code := idgen.NewToken()
a.initIntents[code] = peerIP
go func() {
time.Sleep(5 * time.Minute)
a.lock.Lock()
defer a.lock.Unlock()
delete(a.initIntents, code)
}()
return code
}
func (a *API) Peer_Init(initCode string) (*m.PeerConfig, error) {
a.lock.Lock()
defer a.lock.Unlock()
ip, ok := a.initIntents[initCode]
if !ok {
return nil, ErrNotAuthorized
}
peer, err := a.Peer_Get(ip)
if err != nil {
return nil, err
}
delete(a.initIntents, initCode)
encPubKey, encPrivKey, err := box.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
signPubKey, signPrivKey, err := sign.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
peer.Version = idgen.NextID(0) peer.Version = idgen.NextID(0)
peer.APIKey = idgen.NewToken() peer.PubKey = args.EncPubKey
peer.PubKey = encPubKey[:] peer.PubSignKey = args.PubSignKey
peer.PubSignKey = signPubKey[:]
if err := db.Peer_UpdateFull(a.db, peer); err != nil { if err := db.Peer_UpdateFull(a.db, peer); err != nil {
return nil, err return nil, err
@ -208,17 +161,11 @@ func (a *API) Peer_Init(initCode string) (*m.PeerConfig, error) {
conf := a.Config_Get() conf := a.Config_Get()
return &m.PeerConfig{ return &m.PeerConfig{
PeerIP: peer.PeerIP, PeerIP: peer.PeerIP,
HubAddress: conf.HubAddress, Network: conf.VPNNetwork,
APIKey: peer.APIKey, PublicIP: peer.PublicIP,
Network: conf.VPNNetwork, Port: peer.Port,
PublicIP: peer.PublicIP, Relay: peer.Relay,
Port: peer.Port,
Relay: peer.Relay,
PubKey: encPubKey[:],
PrivKey: encPrivKey[:],
PubSignKey: signPubKey[:],
PrivSignKey: signPrivKey[:],
}, nil }, nil
} }

View File

@ -65,13 +65,26 @@ func (app *App) handleSignedIn(pattern string, fn handlerFunc) {
}) })
} }
type peerHandlerFunc func(w http.ResponseWriter, r *http.Request) error type peerHandlerFunc func(p *api.Peer, w http.ResponseWriter, r *http.Request) error
func (app *App) handlePeer(pattern string, fn peerHandlerFunc) { func (app *App) handlePeer(pattern string, fn peerHandlerFunc) {
wrapped := func(w http.ResponseWriter, r *http.Request) { wrapped := func(w http.ResponseWriter, r *http.Request) {
_, apiKey, ok := r.BasicAuth()
if !ok {
http.Error(w, "Not authorized", http.StatusUnauthorized)
return
}
peer, err := app.api.Peer_GetByAPIKey(apiKey)
if err != nil {
http.Error(w, "Not authorized", http.StatusUnauthorized)
return
}
r.ParseForm() r.ParseForm()
if err := fn(w, r); err != nil { if err := fn(peer, w, r); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
} }

View File

@ -1,6 +1,7 @@
package hub package hub
import ( import (
"encoding/json"
"errors" "errors"
"log" "log"
"net/http" "net/http"
@ -201,22 +202,6 @@ func (a *App) _adminPeerCreateSubmit(s *api.Session, w http.ResponseWriter, r *h
return a.redirect(w, r, "/admin/peer/view/?PeerIP=%d", p.PeerIP) return a.redirect(w, r, "/admin/peer/view/?PeerIP=%d", p.PeerIP)
} }
func (a *App) _adminPeerInit(s *api.Session, w http.ResponseWriter, r *http.Request) error {
var peerIP byte
err := webutil.NewFormScanner(r.Form).Scan("PeerIP", &peerIP).Error()
if err != nil {
return err
}
code := a.api.Peer_CreateInitIntent(peerIP)
log.Printf("Got code: %v / %v", peerIP, code)
return a.render("/admin-peer-init.html", w, struct {
Session *api.Session
HubAddress string
Code string
}{s, a.api.Config_Get().HubAddress, code})
}
func (a *App) _adminPeerView(s *api.Session, w http.ResponseWriter, r *http.Request) error { func (a *App) _adminPeerView(s *api.Session, w http.ResponseWriter, r *http.Request) error {
var peerIP byte var peerIP byte
err := webutil.NewFormScanner(r.Form).Scan("PeerIP", &peerIP).Error() err := webutil.NewFormScanner(r.Form).Scan("PeerIP", &peerIP).Error()
@ -321,9 +306,13 @@ func (a *App) _adminPeerDeleteSubmit(s *api.Session, w http.ResponseWriter, r *h
return a.redirect(w, r, "/admin/peer/list/") return a.redirect(w, r, "/admin/peer/list/")
} }
func (a *App) _peerInit(w http.ResponseWriter, r *http.Request) error { func (a *App) _peerInit(peer *api.Peer, w http.ResponseWriter, r *http.Request) error {
code := r.FormValue("Code") args := m.PeerInitArgs{}
conf, err := a.api.Peer_Init(code) if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
return err
}
conf, err := a.api.Peer_Init(peer, args)
if err != nil { if err != nil {
return err return err
} }
@ -331,31 +320,13 @@ func (a *App) _peerInit(w http.ResponseWriter, r *http.Request) error {
return a.sendJSON(w, conf) return a.sendJSON(w, conf)
} }
func (a *App) _peerFetchState(w http.ResponseWriter, r *http.Request) error { func (a *App) _peerFetchState(peer *api.Peer, w http.ResponseWriter, r *http.Request) error {
_, apiKey, ok := r.BasicAuth()
if !ok {
return api.ErrNotAuthorized
}
peer, err := a.api.Peer_GetByAPIKey(apiKey)
if err != nil {
return err
}
peers, err := a.api.Peer_List() peers, err := a.api.Peer_List()
if err != nil { if err != nil {
return err return err
} }
conf := a.api.Config_Get() state := m.NetworkState{}
state := m.NetworkState{
HubAddress: conf.HubAddress,
Network: conf.VPNNetwork,
PeerIP: peer.PeerIP,
PublicIP: peer.PublicIP,
Port: peer.Port,
}
for _, p := range peers { for _, p := range peers {
if len(p.PubKey) != 0 { if len(p.PubKey) != 0 {

View File

@ -19,13 +19,12 @@ func (a *App) registerRoutes() {
a.handleSignedIn("GET /admin/peer/hosts/", a._adminHosts) a.handleSignedIn("GET /admin/peer/hosts/", a._adminHosts)
a.handleSignedIn("GET /admin/peer/create/", a._adminPeerCreate) a.handleSignedIn("GET /admin/peer/create/", a._adminPeerCreate)
a.handleSignedIn("POST /admin/peer/create/", a._adminPeerCreateSubmit) a.handleSignedIn("POST /admin/peer/create/", a._adminPeerCreateSubmit)
a.handleSignedIn("GET /admin/peer/init/", a._adminPeerInit)
a.handleSignedIn("GET /admin/peer/view/", a._adminPeerView) a.handleSignedIn("GET /admin/peer/view/", a._adminPeerView)
a.handleSignedIn("GET /admin/peer/edit/", a._adminPeerEdit) a.handleSignedIn("GET /admin/peer/edit/", a._adminPeerEdit)
a.handleSignedIn("POST /admin/peer/edit/", a._adminPeerEditSubmit) a.handleSignedIn("POST /admin/peer/edit/", a._adminPeerEditSubmit)
a.handleSignedIn("GET /admin/peer/delete/", a._adminPeerDelete) a.handleSignedIn("GET /admin/peer/delete/", a._adminPeerDelete)
a.handleSignedIn("POST /admin/peer/delete/", a._adminPeerDeleteSubmit) a.handleSignedIn("POST /admin/peer/delete/", a._adminPeerDeleteSubmit)
a.handlePeer("GET /peer/init/", a._peerInit) a.handlePeer("POST /peer/init/", a._peerInit)
a.handlePeer("GET /peer/fetch-state/", a._peerFetchState) a.handlePeer("GET /peer/fetch-state/", a._peerFetchState)
} }

View File

@ -10,6 +10,7 @@
<h1>VPPN</h1> <h1>VPPN</h1>
<nav> <nav>
{{if .Session.SignedIn -}} {{if .Session.SignedIn -}}
<a href="/admin/config/">Home</a> /
<a href="/admin/sign-out/">Sign out</a> <a href="/admin/sign-out/">Sign out</a>
{{- end}} {{- end}}
</nav> </nav>

View File

@ -1,18 +1,17 @@
// The package `m` contains models shared between the hub and peer programs. // The package `m` contains models shared between the hub and peer programs.
package m package m
type PeerInitArgs struct {
EncPubKey []byte
PubSignKey []byte
}
type PeerConfig struct { type PeerConfig struct {
PeerIP byte PeerIP byte
HubAddress string Network []byte
Network []byte PublicIP []byte
APIKey string Port uint16
PublicIP []byte Relay bool
Port uint16
Relay bool
PubKey []byte
PrivKey []byte
PubSignKey []byte
PrivSignKey []byte
} }
type Peer struct { type Peer struct {
@ -27,14 +26,5 @@ type Peer struct {
} }
type NetworkState struct { type NetworkState struct {
HubAddress string
// The requester's data:
Network []byte
PeerIP byte
PublicIP []byte
Port uint16
// All peer data.
Peers [256]*Peer Peers [256]*Peer
} }

View File

@ -56,7 +56,7 @@ func storeJson(x any, outPath string) error {
return os.Rename(tmpPath, outPath) return os.Rename(tmpPath, outPath)
} }
func storePeerConfig(netName string, pc m.PeerConfig) error { func storePeerConfig(netName string, pc localConfig) error {
return storeJson(pc, peerConfigPath(netName)) return storeJson(pc, peerConfigPath(netName))
} }
@ -73,7 +73,7 @@ func loadJson(dataPath string, ptr any) error {
return json.Unmarshal(data, ptr) return json.Unmarshal(data, ptr)
} }
func loadPeerConfig(netName string) (pc m.PeerConfig, err error) { func loadPeerConfig(netName string) (pc localConfig, err error) {
return pc, loadJson(peerConfigPath(netName), &pc) return pc, loadJson(peerConfigPath(netName), &pc)
} }

View File

@ -3,6 +3,7 @@ package node
import ( import (
"net" "net"
"net/netip" "net/netip"
"net/url"
"sync/atomic" "sync/atomic"
"time" "time"
) )
@ -33,6 +34,9 @@ type peerRoute struct {
} }
var ( var (
hubURL *url.URL
apiKey string
// Configuration for this peer. // Configuration for this peer.
netName string netName string
localIP byte localIP byte

View File

@ -5,7 +5,6 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"net/url"
"time" "time"
"vppn/m" "vppn/m"
) )
@ -16,21 +15,18 @@ type hubPoller struct {
versions [256]int64 versions [256]int64
} }
func newHubPoller(conf m.PeerConfig) *hubPoller { func newHubPoller() *hubPoller {
u, err := url.Parse(conf.HubAddress) u := *hubURL
if err != nil {
log.Fatalf("Failed to parse hub address %s: %v", conf.HubAddress, err)
}
u.Path = "/peer/fetch-state/" u.Path = "/peer/fetch-state/"
client := &http.Client{Timeout: 8 * time.Second} client := &http.Client{Timeout: 8 * time.Second}
req := &http.Request{ req := &http.Request{
Method: http.MethodGet, Method: http.MethodGet,
URL: u, URL: &u,
Header: http.Header{}, Header: http.Header{},
} }
req.SetBasicAuth("", conf.APIKey) req.SetBasicAuth("", apiKey)
return &hubPoller{ return &hubPoller{
client: client, client: client,
@ -71,7 +67,7 @@ func (hp *hubPoller) pollHub() {
} }
if err := json.Unmarshal(body, &state); err != nil { if err := json.Unmarshal(body, &state); err != nil {
log.Printf("Failed to unmarshal response from hub: %v", err) log.Printf("Failed to unmarshal response from hub: %v\n%s", err, body)
return return
} }

View File

@ -1,6 +1,8 @@
package node package node
import ( import (
"bytes"
"crypto/rand"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
@ -9,10 +11,14 @@ import (
"net" "net"
"net/http" "net/http"
"net/netip" "net/netip"
"net/url"
"os" "os"
"runtime/debug" "runtime/debug"
"time" "time"
"vppn/m" "vppn/m"
"golang.org/x/crypto/nacl/box"
"golang.org/x/crypto/nacl/sign"
) )
func panicHandler() { func panicHandler() {
@ -24,33 +30,61 @@ func panicHandler() {
func Main() { func Main() {
defer panicHandler() defer panicHandler()
var ( var hubAddress string
initURL string
listenIP string
)
flag.StringVar(&netName, "name", "", "[REQUIRED] The network name.") flag.StringVar(&netName, "name", "", "[REQUIRED] The network name.")
flag.StringVar(&initURL, "init-url", "", "Initializes peer from the hub URL.") flag.StringVar(&hubAddress, "hub-address", "", "[REQUIRED] The hub address.")
flag.StringVar(&listenIP, "listen-ip", "", "IP address to listen on.") flag.StringVar(&apiKey, "api-key", "", "[REQUIRED] The node's API key.")
flag.Parse() flag.Parse()
if netName == "" { if netName == "" || hubAddress == "" || apiKey == "" {
flag.Usage() flag.Usage()
os.Exit(1) os.Exit(1)
} }
if initURL != "" { var err error
mainInit(initURL)
return hubURL, err = url.Parse(hubAddress)
if err != nil {
log.Fatalf("Failed to parse hub address: %v", err)
} }
main(listenIP) main()
} }
func mainInit(initURL string) { func initPeerWithHub() {
resp, err := http.Get(initURL) encPubKey, encPrivKey, err := box.GenerateKey(rand.Reader)
if err != nil { if err != nil {
log.Fatalf("Failed to fetch data from hub: %v", err) log.Fatalf("Failed to generate encryption keys: %v", err)
}
signPubKey, signPrivKey, err := sign.GenerateKey(rand.Reader)
if err != nil {
log.Fatalf("Failed to generate signing keys: %v", err)
}
initURL := *hubURL
initURL.Path = "/peer/init/"
args := m.PeerInitArgs{
EncPubKey: encPubKey[:],
PubSignKey: signPubKey[:],
}
buf := &bytes.Buffer{}
if err := json.NewEncoder(buf).Encode(args); err != nil {
log.Fatalf("Failed to encode init args: %v", err)
}
req, err := http.NewRequest(http.MethodPost, initURL.String(), buf)
if err != nil {
log.Fatalf("Failed to construct request: %v", err)
}
req.SetBasicAuth("", apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatalf("Failed to init with hub: %v", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
@ -59,11 +93,16 @@ func mainInit(initURL string) {
log.Fatalf("Failed to read response body: %v", err) log.Fatalf("Failed to read response body: %v", err)
} }
peerConfig := m.PeerConfig{} peerConfig := localConfig{}
if err := json.Unmarshal(data, &peerConfig); err != nil { if err := json.Unmarshal(data, &peerConfig.PeerConfig); err != nil {
log.Fatalf("Failed to parse configuration: %v", err) log.Fatalf("Failed to parse configuration: %v\n%s", err, data)
} }
peerConfig.PubKey = encPubKey[:]
peerConfig.PrivKey = encPrivKey[:]
peerConfig.PubSignKey = signPubKey[:]
peerConfig.PrivSignKey = signPrivKey[:]
if err := storePeerConfig(netName, peerConfig); err != nil { if err := storePeerConfig(netName, peerConfig); err != nil {
log.Fatalf("Failed to store configuration: %v", err) log.Fatalf("Failed to store configuration: %v", err)
} }
@ -73,10 +112,17 @@ func mainInit(initURL string) {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
func main(listenIP string) { func main() {
config, err := loadPeerConfig(netName) config, err := loadPeerConfig(netName)
if err != nil { if err != nil {
log.Fatalf("Failed to load configuration: %v", err) log.Printf("Failed to load configuration: %v", err)
log.Printf("Initializing...")
initPeerWithHub()
config, err = loadPeerConfig(netName)
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
} }
iface, err := openInterface(config.Network, config.PeerIP, netName) iface, err := openInterface(config.Network, config.PeerIP, netName)
@ -84,7 +130,7 @@ func main(listenIP string) {
log.Fatalf("Failed to open interface: %v", err) log.Fatalf("Failed to open interface: %v", err)
} }
myAddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", listenIP, config.Port)) myAddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf(":%d", config.Port))
if err != nil { if err != nil {
log.Fatalf("Failed to resolve UDP address: %v", err) log.Fatalf("Failed to resolve UDP address: %v", err)
} }
@ -137,7 +183,7 @@ func main(listenIP string) {
} }
}() }()
go newHubPoller(config).Run() go newHubPoller().Run()
go readFromConn(conn) go readFromConn(conn)
readFromIFace(iface) readFromIFace(iface)
} }
@ -258,7 +304,7 @@ func handleDataPacket(h header, data []byte, decBuf []byte) {
destRoute := routingTable[h.DestIP].Load() destRoute := routingTable[h.DestIP].Load()
if !destRoute.Up { if !destRoute.Up {
log.Printf("Not connected (relay): %v", destRoute) log.Printf("Not connected (relay): %d", destRoute.IP)
return return
} }

View File

@ -214,7 +214,7 @@ func (s *peerSupervisor) server() stateFunc {
s.sendControlPacketTo(probePacket{TraceID: msg.Packet.TraceID}, msg.SrcAddr) s.sendControlPacketTo(probePacket{TraceID: msg.Packet.TraceID}, msg.SrcAddr)
case pingTimerMsg: case pingTimerMsg:
if time.Since(lastSeen) > timeoutInterval { if time.Since(lastSeen) > timeoutInterval && s.staged.Up {
logf("Connection timeout") logf("Connection timeout")
s.staged.Up = false s.staged.Up = false
s.publish() s.publish()