diff --git a/.gitignore b/.gitignore index adf8f72..898e58e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,4 @@ # Go workspace file go.work - +test-scripts diff --git a/README.md b/README.md index 528f50a..d642881 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,86 @@ -# vppn +# vppn: Virtual Pretty Private Network +## Roadmap + +* Peer: router: create process for managing the routing table +* Peer: router: track mediators, enable / disable ... +* Hub: track peer last-seen timestamp +* Peer: local peer discovery - part of RoutingProcessor + +## 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. + +## Design + +* Append nonce to end of packet + * Then it's readable whether signed or unsiged +* Types of packets to send: + * standard: encrypt and send + * Forward via: encrypt, sign and send + * Forward to: send +* Type of packeting read from interface: + * Forward to: check signature + * Forwarded, standard + +Incoming from net: + * Data for iface + * Packet for forward + * Packet for routingHandler +* Incoming from iface: + * Data for peer + +## Hub Server Configuration + +``` +# Create user. +adduser user + +# Enable ssh. +cp -r ~/.ssh /home/user/ +chown -R user:user /home/user/.ssh + +``` + +Upload `hub` executable: + +``` +scp hub user@:~/ +``` + +Create systemd file in `/etc/systemd/system/hub.service + +``` +Description=hub +Requires=network.target + +[Service] +AmbientCapabilities=CAP_NET_BIND_SERVICE +Type=simple +User=user +WorkingDirectory=/home/user/ +ExecStart=/home/user/hub -listen :https -secure=true -root-dir=/home/user +Restart=always +RestartSec=8 +TimeoutStopSec=24 + +[Install] +WantedBy=default.target +``` + +Add and start the hub server: + +``` +systemctl daemon-reload +systemctl start hub +``` + +Get initial password from logs: + +``` +journalctl -f -u hub -n 100 +``` + +Sign-in and configure. diff --git a/cmd/hub/main.go b/cmd/hub/main.go new file mode 100644 index 0000000..b9d0f04 --- /dev/null +++ b/cmd/hub/main.go @@ -0,0 +1,7 @@ +package main + +import "vppn/hub" + +func main() { + hub.Main() +} diff --git a/cmd/vppn/build.sh b/cmd/vppn/build.sh new file mode 100755 index 0000000..ff74f39 --- /dev/null +++ b/cmd/vppn/build.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +go build +sudo setcap cap_net_admin+iep vppn diff --git a/cmd/vppn/main.go b/cmd/vppn/main.go new file mode 100644 index 0000000..5daa907 --- /dev/null +++ b/cmd/vppn/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "log" + "vppn/peer" +) + +func main() { + log.SetFlags(0) + peer.Main() +} diff --git a/fasttime/time.go b/fasttime/time.go new file mode 100644 index 0000000..5c569ac --- /dev/null +++ b/fasttime/time.go @@ -0,0 +1,20 @@ +package fasttime + +import ( + "sync/atomic" + "time" +) + +var _timestamp int64 = time.Now().Unix() + +func init() { + go func() { + for range time.Tick(1100 * time.Millisecond) { + atomic.StoreInt64(&_timestamp, time.Now().Unix()) + } + }() +} + +func Now() int64 { + return atomic.LoadInt64(&_timestamp) +} diff --git a/fasttime/time_test.go b/fasttime/time_test.go new file mode 100644 index 0000000..b0a85d0 --- /dev/null +++ b/fasttime/time_test.go @@ -0,0 +1,18 @@ +package fasttime + +import ( + "testing" + "time" +) + +func BenchmarkNow(b *testing.B) { + for i := 0; i < b.N; i++ { + Now() + } +} + +func BenchmarkTimeUnix(b *testing.B) { + for i := 0; i < b.N; i++ { + time.Now().Unix() + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..57c2727 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module vppn + +go 1.23.2 + +require ( + git.crumpington.com/lib/go v0.8.1 + git.crumpington.com/lib/webutil v0.0.7 + github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 + golang.org/x/crypto v0.29.0 +) + +require ( + github.com/mattn/go-sqlite3 v1.14.24 // indirect + golang.org/x/net v0.31.0 // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/text v0.20.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d5dc914 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +git.crumpington.com/lib/go v0.8.1 h1:rWjddllSxQ4yReraqDaGZAod4NpRD9LtGx1yV71ytcU= +git.crumpington.com/lib/go v0.8.1/go.mod h1:XjQaf2NFlje9BJ1EevZL8NNioPrAe7WwHpKUhcDw2Lk= +git.crumpington.com/lib/webutil v0.0.7 h1:1RG9CpuXYalT0NPj8fvxjOLV566LqL37APvAdASFzgA= +git.crumpington.com/lib/webutil v0.0.7/go.mod h1:efIEiuK1uqFIhI/dlsWUHMsC5bXcEbJEjmdluRoFPPQ= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8= +github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= +golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= +golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= +golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= +golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= diff --git a/hub/api/api.go b/hub/api/api.go new file mode 100644 index 0000000..18b00f3 --- /dev/null +++ b/hub/api/api.go @@ -0,0 +1,260 @@ +package api + +import ( + "crypto/rand" + "database/sql" + "embed" + "errors" + "log" + "sync" + "time" + "vppn/hub/api/db" + "vppn/m" + + "git.crumpington.com/lib/go/idgen" + "git.crumpington.com/lib/go/sqliteutil" + "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/nacl/box" + "golang.org/x/crypto/nacl/sign" +) + +//go:embed migrations +var migrations embed.FS + +type API struct { + db *sql.DB + lock sync.Mutex + peerIntents map[string]PeerCreateArgs +} + +func New(dbPath string) (*API, error) { + sqlDB, err := sql.Open("sqlite3", dbPath+"?_journal=WAL") + if err != nil { + return nil, err + } + + if err := sqliteutil.Migrate(sqlDB, migrations); err != nil { + return nil, err + } + + a := &API{ + db: sqlDB, + peerIntents: map[string]PeerCreateArgs{}, + } + + return a, a.ensurePassword() +} + +func (a *API) ensurePassword() error { + _, err := db.Config_Get(a.db, 1) + if err == nil { + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + + pwd := idgen.NewToken() + + log.Printf("Setting password: %s", pwd) + + hashed, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost) + + if err != nil { + return err + } + + conf := &Config{ + ConfigID: 1, + VPNNetwork: []byte{10, 1, 1, 0}, + Password: hashed, + } + + return db.Config_Insert(a.db, conf) +} + +func (a *API) Config_Get() *Config { + conf, err := db.Config_Get(a.db, 1) + if err != nil { + panic(err) + } + return conf +} + +func (a *API) Config_Update(conf *Config) error { + return db.Config_Update(a.db, conf) +} + +func (a *API) Config_UpdatePassword(pwdHash []byte) error { + return db.Config_UpdatePassword(a.db, pwdHash) +} + +func (a *API) Session_Delete(sessionID string) error { + return db.Session_Delete(a.db, sessionID) +} + +func (a *API) Session_Get(sessionID string) (*Session, error) { + if sessionID == "" { + return a.session_CreatePub() + } + + session, err := db.Session_Get(a.db, sessionID) + if err != nil { + return a.session_CreatePub() + } + + if timeSince(session.LastSeenAt) > 86400*21 { + return a.session_CreatePub() + } + + if timeSince(session.LastSeenAt) > 86400*7 { + session.LastSeenAt = time.Now().Unix() + if err := db.Session_UpdateLastSeenAt(a.db, session.SessionID); err != nil { + log.Printf("Failed to update session: %v", err) + } + } + + return session, nil +} + +func (a *API) session_CreatePub() (*Session, error) { + s := &Session{ + SessionID: idgen.NewToken(), + CSRF: idgen.NewToken(), + SignedIn: false, + CreatedAt: time.Now().Unix(), + LastSeenAt: time.Now().Unix(), + } + err := db.Session_Insert(a.db, s) + return s, err +} + +func (a *API) Session_DeleteBefore(timestamp int64) error { + return db.Session_DeleteBefore(a.db, timestamp) +} + +func (a *API) Session_SignIn(s *Session, pwd string) error { + conf := a.Config_Get() + if err := bcrypt.CompareHashAndPassword(conf.Password, []byte(pwd)); err != nil { + return ErrNotAuthorized + } + + return db.Session_SetSignedIn(a.db, s.SessionID) +} + +type PeerCreateArgs struct { + Name string + IP []byte + Port uint16 + Mediator bool +} + +// Create the intention to add a peer. The returned code is used to complete +// the peer creation. The code is valid for 5 minutes. +func (a *API) Peer_CreateIntent(args PeerCreateArgs) string { + a.lock.Lock() + defer a.lock.Unlock() + + code := idgen.NewToken() + a.peerIntents[code] = args + + go func() { + time.Sleep(5 * time.Minute) + a.lock.Lock() + defer a.lock.Unlock() + delete(a.peerIntents, code) + }() + + return code +} + +func (a *API) Peer_Create(creationCode string) (*m.PeerConfig, error) { + a.lock.Lock() + defer a.lock.Unlock() + + args, ok := a.peerIntents[creationCode] + if !ok { + return nil, ErrNotAuthorized + } + + delete(a.peerIntents, creationCode) + + 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 + } + + // Get peer IP. + peerIP := byte(0) + + for i := byte(1); i < 255; i++ { + exists, err := db.Peer_Exists(a.db, i) + if err != nil { + return nil, err + } + if !exists { + peerIP = i + break + } + } + + if peerIP == 0 { + return nil, ErrNoIPAvailable + } + + peer := &Peer{ + PeerIP: peerIP, + APIKey: idgen.NewToken(), + Name: args.Name, + IP: args.IP, + Port: args.Port, + Mediator: args.Mediator, + EncPubKey: encPubKey[:], + SignPubKey: signPubKey[:], + } + + if err := db.Peer_Insert(a.db, peer); err != nil { + return nil, err + } + + conf := a.Config_Get() + + return &m.PeerConfig{ + PeerIP: peer.PeerIP, + HubAddress: conf.HubAddress, + APIKey: peer.APIKey, + Network: conf.VPNNetwork, + IP: peer.IP, + Port: peer.Port, + Mediator: peer.Mediator, + EncPubKey: encPubKey[:], + EncPrivKey: encPrivKey[:], + SignPubKey: signPubKey[:], + SignPrivKey: signPrivKey[:], + }, nil +} + +func (a *API) Peer_Update(p *Peer) error { + return db.Peer_Update(a.db, p) +} + +func (a *API) Peer_Delete(ip byte) error { + return db.Peer_Delete(a.db, ip) +} + +func (a *API) Peer_List() ([]*Peer, error) { + return db.Peer_ListAll(a.db) +} + +func (a *API) Peer_Get(ip byte) (*Peer, error) { + return db.Peer_Get(a.db, ip) +} + +func (a *API) Peer_GetByAPIKey(key string) (*Peer, error) { + return db.Peer_GetByAPIKey(a.db, key) +} diff --git a/hub/api/db/generate.go b/hub/api/db/generate.go new file mode 100644 index 0000000..461cc2e --- /dev/null +++ b/hub/api/db/generate.go @@ -0,0 +1,3 @@ +package db + +//go:generate sqlgen sqlite tables.defs generated.go diff --git a/hub/api/db/generated b/hub/api/db/generated new file mode 100644 index 0000000..e69de29 diff --git a/hub/api/db/generated.go b/hub/api/db/generated.go new file mode 100644 index 0000000..3981fe1 --- /dev/null +++ b/hub/api/db/generated.go @@ -0,0 +1,480 @@ +package db + +import ( + "database/sql" + "iter" +) + +type TX interface { + Exec(query string, args ...any) (sql.Result, error) + Query(query string, args ...any) (*sql.Rows, error) + QueryRow(query string, args ...any) *sql.Row +} + +// ---------------------------------------------------------------------------- +// Table: config +// ---------------------------------------------------------------------------- + +type Config struct { + ConfigID int64 + HubAddress string + VPNNetwork []byte + Password []byte +} + +const Config_SelectQuery = "SELECT ConfigID,HubAddress,VPNNetwork,Password FROM config" + +func Config_Insert( + tx TX, + row *Config, +) (err error) { + Config_Sanitize(row) + if err = Config_Validate(row); err != nil { + return err + } + + _, err = tx.Exec("INSERT INTO config(ConfigID,HubAddress,VPNNetwork,Password) VALUES(?,?,?,?)", row.ConfigID, row.HubAddress, row.VPNNetwork, row.Password) + return err +} + +func Config_Update( + tx TX, + row *Config, +) (err error) { + Config_Sanitize(row) + if err = Config_Validate(row); err != nil { + return err + } + + result, err := tx.Exec("UPDATE config SET HubAddress=?,VPNNetwork=? WHERE ConfigID=?", row.HubAddress, row.VPNNetwork, row.ConfigID) + if err != nil { + return err + } + + n, err := result.RowsAffected() + if err != nil { + panic(err) + } + switch n { + case 0: + return sql.ErrNoRows + case 1: + return nil + default: + panic("multiple rows updated") + } +} + +func Config_UpdateFull( + tx TX, + row *Config, +) (err error) { + Config_Sanitize(row) + if err = Config_Validate(row); err != nil { + return err + } + + result, err := tx.Exec("UPDATE config SET HubAddress=?,VPNNetwork=?,Password=? WHERE ConfigID=?", row.HubAddress, row.VPNNetwork, row.Password, row.ConfigID) + if err != nil { + return err + } + + n, err := result.RowsAffected() + if err != nil { + panic(err) + } + switch n { + case 0: + return sql.ErrNoRows + case 1: + return nil + default: + panic("multiple rows updated") + } +} + +func Config_Delete( + tx TX, + ConfigID int64, +) (err error) { + result, err := tx.Exec("DELETE FROM config WHERE ConfigID=?", ConfigID) + if err != nil { + return err + } + + n, err := result.RowsAffected() + if err != nil { + panic(err) + } + switch n { + case 0: + return sql.ErrNoRows + case 1: + return nil + default: + panic("multiple rows deleted") + } +} + +func Config_Get( + tx TX, + ConfigID int64, +) ( + row *Config, + err error, +) { + row = &Config{} + r := tx.QueryRow("SELECT ConfigID,HubAddress,VPNNetwork,Password FROM config WHERE ConfigID=?", ConfigID) + err = r.Scan(&row.ConfigID, &row.HubAddress, &row.VPNNetwork, &row.Password) + return +} + +func Config_GetWhere( + tx TX, + query string, + args ...any, +) ( + row *Config, + err error, +) { + row = &Config{} + r := tx.QueryRow(query, args...) + err = r.Scan(&row.ConfigID, &row.HubAddress, &row.VPNNetwork, &row.Password) + return +} + +func Config_Iterate( + tx TX, + query string, + args ...any, +) iter.Seq2[*Config, error] { + rows, err := tx.Query(query, args...) + if err != nil { + return func(yield func(*Config, error) bool) { + yield(nil, err) + } + } + + return func(yield func(*Config, error) bool) { + defer rows.Close() + for rows.Next() { + row := &Config{} + err := rows.Scan(&row.ConfigID, &row.HubAddress, &row.VPNNetwork, &row.Password) + if !yield(row, err) { + return + } + } + } +} + +func Config_List( + tx TX, + query string, + args ...any, +) ( + l []*Config, + err error, +) { + for row, err := range Config_Iterate(tx, query, args...) { + if err != nil { + return nil, err + } + l = append(l, row) + } + return l, nil +} + +// ---------------------------------------------------------------------------- +// Table: sessions +// ---------------------------------------------------------------------------- + +type Session struct { + SessionID string + CSRF string + SignedIn bool + CreatedAt int64 + LastSeenAt int64 +} + +const Session_SelectQuery = "SELECT SessionID,CSRF,SignedIn,CreatedAt,LastSeenAt FROM sessions" + +func Session_Insert( + tx TX, + row *Session, +) (err error) { + Session_Sanitize(row) + if err = Session_Validate(row); err != nil { + return err + } + + _, err = tx.Exec("INSERT INTO sessions(SessionID,CSRF,SignedIn,CreatedAt,LastSeenAt) VALUES(?,?,?,?,?)", row.SessionID, row.CSRF, row.SignedIn, row.CreatedAt, row.LastSeenAt) + return err +} + +func Session_Delete( + tx TX, + SessionID string, +) (err error) { + result, err := tx.Exec("DELETE FROM sessions WHERE SessionID=?", SessionID) + if err != nil { + return err + } + + n, err := result.RowsAffected() + if err != nil { + panic(err) + } + switch n { + case 0: + return sql.ErrNoRows + case 1: + return nil + default: + panic("multiple rows deleted") + } +} + +func Session_Get( + tx TX, + SessionID string, +) ( + row *Session, + err error, +) { + row = &Session{} + r := tx.QueryRow("SELECT SessionID,CSRF,SignedIn,CreatedAt,LastSeenAt FROM sessions WHERE SessionID=?", SessionID) + err = r.Scan(&row.SessionID, &row.CSRF, &row.SignedIn, &row.CreatedAt, &row.LastSeenAt) + return +} + +func Session_GetWhere( + tx TX, + query string, + args ...any, +) ( + row *Session, + err error, +) { + row = &Session{} + r := tx.QueryRow(query, args...) + err = r.Scan(&row.SessionID, &row.CSRF, &row.SignedIn, &row.CreatedAt, &row.LastSeenAt) + return +} + +func Session_Iterate( + tx TX, + query string, + args ...any, +) iter.Seq2[*Session, error] { + rows, err := tx.Query(query, args...) + if err != nil { + return func(yield func(*Session, error) bool) { + yield(nil, err) + } + } + + return func(yield func(*Session, error) bool) { + defer rows.Close() + for rows.Next() { + row := &Session{} + err := rows.Scan(&row.SessionID, &row.CSRF, &row.SignedIn, &row.CreatedAt, &row.LastSeenAt) + if !yield(row, err) { + return + } + } + } +} + +func Session_List( + tx TX, + query string, + args ...any, +) ( + l []*Session, + err error, +) { + for row, err := range Session_Iterate(tx, query, args...) { + if err != nil { + return nil, err + } + l = append(l, row) + } + return l, nil +} + +// ---------------------------------------------------------------------------- +// Table: peers +// ---------------------------------------------------------------------------- + +type Peer struct { + PeerIP byte + APIKey string + Name string + IP []byte + Port uint16 + Mediator bool + EncPubKey []byte + SignPubKey []byte +} + +const Peer_SelectQuery = "SELECT PeerIP,APIKey,Name,IP,Port,Mediator,EncPubKey,SignPubKey FROM peers" + +func Peer_Insert( + tx TX, + row *Peer, +) (err error) { + Peer_Sanitize(row) + if err = Peer_Validate(row); err != nil { + return err + } + + _, err = tx.Exec("INSERT INTO peers(PeerIP,APIKey,Name,IP,Port,Mediator,EncPubKey,SignPubKey) VALUES(?,?,?,?,?,?,?,?)", row.PeerIP, row.APIKey, row.Name, row.IP, row.Port, row.Mediator, row.EncPubKey, row.SignPubKey) + return err +} + +func Peer_Update( + tx TX, + row *Peer, +) (err error) { + Peer_Sanitize(row) + if err = Peer_Validate(row); err != nil { + return err + } + + result, err := tx.Exec("UPDATE peers SET Name=?,IP=?,Port=?,Mediator=? WHERE PeerIP=?", row.Name, row.IP, row.Port, row.Mediator, row.PeerIP) + if err != nil { + return err + } + + n, err := result.RowsAffected() + if err != nil { + panic(err) + } + switch n { + case 0: + return sql.ErrNoRows + case 1: + return nil + default: + panic("multiple rows updated") + } +} + +func Peer_UpdateFull( + tx TX, + row *Peer, +) (err error) { + Peer_Sanitize(row) + if err = Peer_Validate(row); err != nil { + return err + } + + result, err := tx.Exec("UPDATE peers SET APIKey=?,Name=?,IP=?,Port=?,Mediator=?,EncPubKey=?,SignPubKey=? WHERE PeerIP=?", row.APIKey, row.Name, row.IP, row.Port, row.Mediator, row.EncPubKey, row.SignPubKey, row.PeerIP) + if err != nil { + return err + } + + n, err := result.RowsAffected() + if err != nil { + panic(err) + } + switch n { + case 0: + return sql.ErrNoRows + case 1: + return nil + default: + panic("multiple rows updated") + } +} + +func Peer_Delete( + tx TX, + PeerIP byte, +) (err error) { + result, err := tx.Exec("DELETE FROM peers WHERE PeerIP=?", PeerIP) + if err != nil { + return err + } + + n, err := result.RowsAffected() + if err != nil { + panic(err) + } + switch n { + case 0: + return sql.ErrNoRows + case 1: + return nil + default: + panic("multiple rows deleted") + } +} + +func Peer_Get( + tx TX, + PeerIP byte, +) ( + row *Peer, + err error, +) { + row = &Peer{} + r := tx.QueryRow("SELECT PeerIP,APIKey,Name,IP,Port,Mediator,EncPubKey,SignPubKey FROM peers WHERE PeerIP=?", PeerIP) + err = r.Scan(&row.PeerIP, &row.APIKey, &row.Name, &row.IP, &row.Port, &row.Mediator, &row.EncPubKey, &row.SignPubKey) + return +} + +func Peer_GetWhere( + tx TX, + query string, + args ...any, +) ( + row *Peer, + err error, +) { + row = &Peer{} + r := tx.QueryRow(query, args...) + err = r.Scan(&row.PeerIP, &row.APIKey, &row.Name, &row.IP, &row.Port, &row.Mediator, &row.EncPubKey, &row.SignPubKey) + return +} + +func Peer_Iterate( + tx TX, + query string, + args ...any, +) iter.Seq2[*Peer, error] { + rows, err := tx.Query(query, args...) + if err != nil { + return func(yield func(*Peer, error) bool) { + yield(nil, err) + } + } + + return func(yield func(*Peer, error) bool) { + defer rows.Close() + for rows.Next() { + row := &Peer{} + err := rows.Scan(&row.PeerIP, &row.APIKey, &row.Name, &row.IP, &row.Port, &row.Mediator, &row.EncPubKey, &row.SignPubKey) + if !yield(row, err) { + return + } + } + } +} + +func Peer_List( + tx TX, + query string, + args ...any, +) ( + l []*Peer, + err error, +) { + for row, err := range Peer_Iterate(tx, query, args...) { + if err != nil { + return nil, err + } + l = append(l, row) + } + return l, nil +} diff --git a/hub/api/db/sanitize-validate.go b/hub/api/db/sanitize-validate.go new file mode 100644 index 0000000..b737866 --- /dev/null +++ b/hub/api/db/sanitize-validate.go @@ -0,0 +1,69 @@ +package db + +import ( + "errors" + "net/netip" + "net/url" + "strings" +) + +var ( + ErrInvalidIP = errors.New("invalid IP") + ErrInvalidPort = errors.New("invalid port") +) + +func Config_Sanitize(c *Config) { + if u, err := url.Parse(c.HubAddress); err == nil { + c.HubAddress = u.String() + } + + if addr, ok := netip.AddrFromSlice(c.VPNNetwork); ok { + c.VPNNetwork = addr.AsSlice() + } +} + +func Config_Validate(c *Config) error { + if _, err := url.Parse(c.HubAddress); err != nil { + return err + } + + addr, ok := netip.AddrFromSlice(c.VPNNetwork) + if !ok || !addr.Is4() || addr.As4()[3] != 0 || addr.As4()[0] == 0 { + return ErrInvalidIP + } + + return nil +} + +func Session_Sanitize(s *Session) { +} + +func Session_Validate(s *Session) error { + return nil +} + +func Peer_Sanitize(p *Peer) { + p.Name = strings.TrimSpace(p.Name) + if len(p.IP) != 0 { + addr, ok := netip.AddrFromSlice(p.IP) + if ok && addr.Is4() { + p.IP = addr.AsSlice() + } + } + if p.Port == 0 { + p.Port = 515 + } +} + +func Peer_Validate(p *Peer) error { + if len(p.IP) > 0 { + _, ok := netip.AddrFromSlice(p.IP) + if !ok { + return ErrInvalidIP + } + } + if p.Port == 0 { + return ErrInvalidPort + } + return nil +} diff --git a/hub/api/db/tables.defs b/hub/api/db/tables.defs new file mode 100644 index 0000000..ddc51b5 --- /dev/null +++ b/hub/api/db/tables.defs @@ -0,0 +1,25 @@ +TABLE config OF Config ( + ConfigID int64 PK, + HubAddress string, + VPNNetwork []byte, + Password []byte NoUpdate +); + +TABLE sessions OF Session NoUpdate ( + SessionID string PK, + CSRF string, + SignedIn bool, + CreatedAt int64, + LastSeenAt int64 +); + +TABLE peers OF Peer ( + PeerIP byte PK, + APIKey string NoUpdate, + Name string, + IP []byte, + Port uint16, + Mediator bool, + EncPubKey []byte NoUpdate, + SignPubKey []byte NoUpdate +); diff --git a/hub/api/db/written.go b/hub/api/db/written.go new file mode 100644 index 0000000..65769c4 --- /dev/null +++ b/hub/api/db/written.go @@ -0,0 +1,51 @@ +package db + +import "vppn/fasttime" + +func Session_UpdateLastSeenAt( + tx TX, + id string, +) (err error) { + _, err = tx.Exec("UPDATE sessions SET LastSeenAt=? WHERE SessionID=?", fasttime.Now(), id) + return err +} + +func Session_SetSignedIn( + tx TX, + id string, +) (err error) { + _, err = tx.Exec("UPDATE sessions SET SignedIn=1 WHERE SessionID=?", id) + return err +} + +func Session_DeleteBefore( + tx TX, + timestamp int64, +) (err error) { + _, err = tx.Exec("DELETE FROM sessions WHERE LastSeenAt:first-child{margin-top:0;padding-top:0}header>:last-child{margin-bottom:0}a button,button,input[type=button],input[type=reset],input[type=submit]{font-size:1rem;display:inline-block;padding:6px 12px;text-align:center;text-decoration:none;white-space:nowrap;background:var(--nc-lk-1);color:var(--nc-lk-tx);border:0;border-radius:4px;box-sizing:border-box;cursor:pointer;color:var(--nc-lk-tx)}a button[disabled],button[disabled],input[type=button][disabled],input[type=reset][disabled],input[type=submit][disabled]{cursor:default;opacity:.5;cursor:not-allowed}.button:focus,.button:hover,button:focus,button:hover,input[type=button]:focus,input[type=button]:hover,input[type=reset]:focus,input[type=reset]:hover,input[type=submit]:focus,input[type=submit]:hover{background:var(--nc-lk-2)}code,kbd,pre,samp{font-family:var(--nc-font-mono)}code,kbd,pre,samp{background:var(--nc-bg-2);border:1px solid var(--nc-bg-3);border-radius:4px;padding:3px 6px;font-size:.9rem}kbd{border-bottom:3px solid var(--nc-bg-3)}pre{padding:1rem 1.4rem;max-width:100%;overflow:auto}pre code{background:inherit;font-size:inherit;color:inherit;border:0;padding:0;margin:0}code pre{display:inline;background:inherit;font-size:inherit;color:inherit;border:0;padding:0;margin:0}details{padding:.6rem 1rem;background:var(--nc-bg-2);border:1px solid var(--nc-bg-3);border-radius:4px}summary{cursor:pointer;font-weight:700}details[open]{padding-bottom:.75rem}details[open] summary{margin-bottom:6px}details[open]>:last-child{margin-bottom:0}dt{font-weight:700}dd::before{content:'→ '}hr{border:0;border-bottom:1px solid var(--nc-bg-3);margin:1rem auto}fieldset{margin-top:1rem;padding:2rem;border:1px solid var(--nc-bg-3);border-radius:4px}legend{padding:auto .5rem}table{border-collapse:collapse;width:100%}td,th{border:1px solid var(--nc-bg-3);text-align:left;padding:.5rem}th{background:var(--nc-bg-2)}tr:nth-child(even){background:var(--nc-bg-2)}table caption{font-weight:700;margin-bottom:.5rem}textarea{max-width:100%}ol,ul{padding-left:2rem}li{margin-top:.4rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}mark{padding:3px 6px;background:var(--nc-ac-1);color:var(--nc-ac-tx)}input,select,textarea{padding:6px 12px;margin-bottom:.5rem;background:var(--nc-bg-2);color:var(--nc-tx-2);border:1px solid var(--nc-bg-3);border-radius:4px;box-shadow:none;box-sizing:border-box}img{max-width:100%} +/*# sourceMappingURL=/sm/4a51164882967d28a74fabce02685c18fa45a529b77514edc75d708f04dd08b9.map */ \ No newline at end of file diff --git a/hub/templates/admin-config-edit.html b/hub/templates/admin-config-edit.html new file mode 100644 index 0000000..0dcb1b3 --- /dev/null +++ b/hub/templates/admin-config-edit.html @@ -0,0 +1,20 @@ +{{define "body" -}} +

Config

+ +
+ +

+
+ +

+

+
+ +

+

+ + Cancel +

+
+ +{{- end}} diff --git a/hub/templates/admin-config.html b/hub/templates/admin-config.html new file mode 100644 index 0000000..da0f8ae --- /dev/null +++ b/hub/templates/admin-config.html @@ -0,0 +1,19 @@ +{{define "body" -}} +

Config

+ +

+ Edit / + Change Password +

+ + + + + + + + + + +
Hub Address{{.Config.HubAddress}}
VPN Network{{ipToString .Config.VPNNetwork}}
+{{- end}} diff --git a/hub/templates/admin-password-edit.html b/hub/templates/admin-password-edit.html new file mode 100644 index 0000000..32c8d43 --- /dev/null +++ b/hub/templates/admin-password-edit.html @@ -0,0 +1,23 @@ +{{define "body" -}} +

Change Password

+ +
+ +

+
+ +

+

+
+ +

+

+
+ +

+

+ + Cancel +

+
+{{- end}} diff --git a/hub/templates/admin-peer-create.html b/hub/templates/admin-peer-create.html new file mode 100644 index 0000000..2eb6c70 --- /dev/null +++ b/hub/templates/admin-peer-create.html @@ -0,0 +1,30 @@ +{{define "body" -}} +

New Peer

+ +
+ +

+
+ +

+

+
+ +

+

+
+ +

+

+ +

+

+ + Cancel +

+
+ +{{- end}} diff --git a/hub/templates/admin-peer-delete.html b/hub/templates/admin-peer-delete.html new file mode 100644 index 0000000..4e0618b --- /dev/null +++ b/hub/templates/admin-peer-delete.html @@ -0,0 +1,36 @@ +{{define "body" -}} +

Delete Peer

+ +{{with .Peer -}} +
+ +

+
+ +

+

+
+ +

+

+
+ +

+

+
+ +

+

+ +

+

+ + Cancel +

+
+{{- end}} + +{{- end}} diff --git a/hub/templates/admin-peer-edit.html b/hub/templates/admin-peer-edit.html new file mode 100644 index 0000000..3e04184 --- /dev/null +++ b/hub/templates/admin-peer-edit.html @@ -0,0 +1,36 @@ +{{define "body" -}} +

Edit Peer

+ +{{with .Peer -}} +
+ +

+
+ +

+

+
+ +

+

+
+ +

+

+
+ +

+

+ +

+

+ + Cancel +

+
+{{- end}} + +{{- end}} diff --git a/hub/templates/admin-peer-intent.html b/hub/templates/admin-peer-intent.html new file mode 100644 index 0000000..0cb5686 --- /dev/null +++ b/hub/templates/admin-peer-intent.html @@ -0,0 +1,13 @@ +{{define "body" -}} +

Create Peer

+ +

+ Configure the peer with the following URL: +

+
+  {{.HubAddress}}/peer/create/?Code={{.Code}}
+
+

+ Done +

+{{- end}} diff --git a/hub/templates/admin-peer-list.html b/hub/templates/admin-peer-list.html new file mode 100644 index 0000000..4f09f1a --- /dev/null +++ b/hub/templates/admin-peer-list.html @@ -0,0 +1,39 @@ +{{define "body" -}} +

Peers

+ +

+ Add Peer +

+ +{{if .Peers -}} + + + + + + + + + + + + {{range .Peers -}} + + + + + + + + + {{- end}} +
PeerIPNameIPPortMediator
+ + {{.PeerIP}} + + {{.Name}}{{ipToString .IP}}{{.Port}}{{if .Mediator}}T{{else}}F{{end}}
+{{- else}} +

No peers.

+{{- end}} + +{{- end}} diff --git a/hub/templates/admin-peer-view.html b/hub/templates/admin-peer-view.html new file mode 100644 index 0000000..09f254b --- /dev/null +++ b/hub/templates/admin-peer-view.html @@ -0,0 +1,20 @@ +{{define "body" -}} +

Peer

+ +

+ Edit / + Delete +

+ +{{with .Peer -}} + + + + + + + +
Peer IP{{.PeerIP}}
Name{{.Name}}
IP{{ipToString .IP}}
Port{{.Port}}
Mediator{{if .Mediator}}T{{else}}F{{end}}
API Key{{.APIKey}}
+{{- end}} + +{{- end}} diff --git a/hub/templates/admin-sign-out.html b/hub/templates/admin-sign-out.html new file mode 100644 index 0000000..812d103 --- /dev/null +++ b/hub/templates/admin-sign-out.html @@ -0,0 +1,11 @@ +{{define "body" -}} +

Sign Out

+ +
+ +

+ + Cancel +

+
+{{- end}} diff --git a/hub/templates/base.html b/hub/templates/base.html new file mode 100644 index 0000000..e582e83 --- /dev/null +++ b/hub/templates/base.html @@ -0,0 +1,21 @@ + + + + VPPN Hub + + + + +
+

VPPN

+ +
+ {{block "body" .}}There's nothing here.{{end}} + + diff --git a/hub/templates/share/common.html b/hub/templates/share/common.html new file mode 100644 index 0000000..e69de29 diff --git a/hub/templates/sign-in.html b/hub/templates/sign-in.html new file mode 100644 index 0000000..07e9d87 --- /dev/null +++ b/hub/templates/sign-in.html @@ -0,0 +1,14 @@ +{{define "body" -}} +

Sign In

+ +
+ +

+
+ +

+

+ +

+
+{{- end}} diff --git a/hub/time.go b/hub/time.go new file mode 100644 index 0000000..8485440 --- /dev/null +++ b/hub/time.go @@ -0,0 +1 @@ +package hub diff --git a/hub/util.go b/hub/util.go new file mode 100644 index 0000000..5503cc9 --- /dev/null +++ b/hub/util.go @@ -0,0 +1,59 @@ +package hub + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "net/netip" + "strings" +) + +func (app *App) render(name string, w http.ResponseWriter, data any) error { + tmpl, ok := app.tmpl[name] + if !ok { + log.Printf("Template not found: %s", name) + return errors.New("not found") + } + if err := tmpl.Execute(w, data); err != nil { + log.Printf("Failed to render template %s: %v", name, err) + } + return nil +} + +func (app *App) redirect(w http.ResponseWriter, r *http.Request, url string, args ...any) error { + if len(args) > 0 { + url = fmt.Sprintf(url, args...) + } + http.Redirect(w, r, url, http.StatusSeeOther) + return nil +} + +func (app *App) sendJSON(w http.ResponseWriter, data any) error { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(data); err != nil { + log.Printf("Failed to send JSON: %v", err) + } + return nil +} + +func stringToIP(in string) ([]byte, error) { + in = strings.TrimSpace(in) + if len(in) == 0 { + return []byte{}, nil + } + + addr, err := netip.ParseAddr(in) + if err != nil { + return nil, err + } + return addr.AsSlice(), nil +} + +func ipBytesTostring(in []byte) string { + if addr, ok := netip.AddrFromSlice(in); ok { + return addr.String() + } + return "" +} diff --git a/m/models.go b/m/models.go new file mode 100644 index 0000000..d6d3103 --- /dev/null +++ b/m/models.go @@ -0,0 +1,39 @@ +// The package `m` contains models shared between the hub and peer programs. +package m + +type PeerConfig struct { + PeerIP byte + HubAddress string + Network []byte + APIKey string + IP []byte + Port uint16 + Mediator bool + EncPubKey []byte + EncPrivKey []byte + SignPubKey []byte + SignPrivKey []byte +} + +type Peer struct { + PeerIP byte + Name string + IP []byte + Port uint16 + Mediator bool + EncPubKey []byte + SignPubKey []byte +} + +type NetworkState struct { + HubAddress string + + // The requester's data: + Network []byte + PeerIP byte + IP []byte + Port uint16 + + // All peer data. + Peers [256]*Peer +} diff --git a/peer/crypto.go b/peer/crypto.go new file mode 100644 index 0000000..135df55 --- /dev/null +++ b/peer/crypto.go @@ -0,0 +1,32 @@ +package peer + +import ( + "golang.org/x/crypto/nacl/box" + "golang.org/x/crypto/nacl/sign" +) + +func encryptPacket(sharedKey, nonce, packet, out []byte) []byte { + out = box.SealAfterPrecomputation(out[:0], packet, (*[24]byte)(nonce), (*[32]byte)(sharedKey)) + return append(out, nonce...) +} + +func decryptPacket(sharedKey, packet, out []byte) (decrypted []byte, ok bool) { + cut := len(packet) - NONCE_SIZE + decrypted, ok = box.OpenAfterPrecomputation(out[:0], packet[:cut], (*[24]byte)(packet[cut:]), (*[32]byte)(sharedKey)) + return decrypted, ok +} + +// Signed packet should be encrypted with the encryptPacket function first. +func signPacket(privKey, packet, out []byte) []byte { + return sign.Sign(out[:0], packet, (*[64]byte)(privKey)) +} + +func openPacket(pubKey, packet, out []byte) (encPacket []byte, ok bool) { + return sign.Open(out[:0], packet, (*[32]byte)(pubKey)) +} + +func computeSharedKey(peerPubKey, privKey []byte) []byte { + shared := [32]byte{} + box.Precompute(&shared, (*[32]byte)(peerPubKey), (*[32]byte)(privKey)) + return shared[:] +} diff --git a/peer/crypto_test.go b/peer/crypto_test.go new file mode 100644 index 0000000..8a0f34a --- /dev/null +++ b/peer/crypto_test.go @@ -0,0 +1,129 @@ +package peer + +import ( + "bytes" + "crypto/rand" + "testing" + + "golang.org/x/crypto/nacl/box" + "golang.org/x/crypto/nacl/sign" +) + +func TestEncryptDecryptPacket(t *testing.T) { + pubKey1, privKey1, err := box.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + pubKey2, privKey2, err := box.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + sharedEncKey := [32]byte{} + box.Precompute(&sharedEncKey, pubKey2, privKey1) + + sharedDecKey := [32]byte{} + box.Precompute(&sharedDecKey, pubKey1, privKey2) + + original := make([]byte, MTU) + rand.Read(original) + + nonce := make([]byte, NONCE_SIZE) + rand.Read(nonce) + + encrypted := make([]byte, BUFFER_SIZE) + encrypted = encryptPacket(sharedEncKey[:], nonce, original, encrypted) + + decrypted := make([]byte, MTU) + var ok bool + decrypted, ok = decryptPacket(sharedDecKey[:], encrypted, decrypted) + if !ok { + t.Fatal(ok) + } + + if !bytes.Equal(original, decrypted) { + t.Fatal("mismatch") + } +} + +func BenchmarkEncryptPacket(b *testing.B) { + _, privKey1, err := box.GenerateKey(rand.Reader) + if err != nil { + b.Fatal(err) + } + + pubKey2, _, err := box.GenerateKey(rand.Reader) + if err != nil { + b.Fatal(err) + } + + sharedEncKey := [32]byte{} + box.Precompute(&sharedEncKey, pubKey2, privKey1) + + original := make([]byte, MTU) + rand.Read(original) + + nonce := make([]byte, NONCE_SIZE) + rand.Read(nonce) + + encrypted := make([]byte, BUFFER_SIZE) + + for i := 0; i < b.N; i++ { + encrypted = encryptPacket(sharedEncKey[:], nonce, original, encrypted) + } +} + +func BenchmarkDecryptPacket(b *testing.B) { + pubKey1, privKey1, err := box.GenerateKey(rand.Reader) + if err != nil { + b.Fatal(err) + } + + pubKey2, privKey2, err := box.GenerateKey(rand.Reader) + if err != nil { + b.Fatal(err) + } + + sharedEncKey := [32]byte{} + box.Precompute(&sharedEncKey, pubKey2, privKey1) + + sharedDecKey := [32]byte{} + box.Precompute(&sharedDecKey, pubKey1, privKey2) + + original := make([]byte, MTU) + rand.Read(original) + + nonce := make([]byte, NONCE_SIZE) + rand.Read(nonce) + + encrypted := make([]byte, BUFFER_SIZE) + encrypted = encryptPacket(sharedEncKey[:], nonce, original, encrypted) + + decrypted := make([]byte, MTU) + for i := 0; i < b.N; i++ { + decrypted, _ = decryptPacket(sharedDecKey[:], encrypted, decrypted) + } +} + +func TestSignOpenPacket(t *testing.T) { + pubKey, privKey, err := sign.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + packet := make([]byte, MTU) + + rand.Read(packet) + + signedPacket := signPacket(privKey[:], packet, make([]byte, BUFFER_SIZE)) + + encPacket, ok := openPacket(pubKey[:], signedPacket, make([]byte, BUFFER_SIZE)) + if !ok { + t.Fatal(ok) + } + + if !bytes.Equal(encPacket, packet) { + t.Fatal("not equal") + } +} diff --git a/peer/files.go b/peer/files.go new file mode 100644 index 0000000..3b74427 --- /dev/null +++ b/peer/files.go @@ -0,0 +1,82 @@ +package peer + +import ( + "encoding/json" + "log" + "os" + "path/filepath" + "vppn/m" +) + +func configDir(netName string) string { + d, err := os.UserHomeDir() + if err != nil { + log.Fatalf("Failed to get user home directory: %v", err) + } + return filepath.Join(d, ".vppn", netName) +} + +func peerConfigPath(netName string) string { + return filepath.Join(configDir(netName), "peer-config.json") +} + +func peerStatePath(netName string) string { + return filepath.Join(configDir(netName), "peer-state.json") +} + +func storeJson(x any, outPath string) error { + outDir := filepath.Dir(outPath) + _ = os.MkdirAll(outDir, 0700) + + tmpPath := outPath + ".tmp" + buf, err := json.Marshal(x) + if err != nil { + return err + } + + f, err := os.Create(tmpPath) + if err != nil { + return err + } + + if _, err := f.Write(buf); err != nil { + f.Close() + return err + } + + if err := f.Sync(); err != nil { + f.Close() + return err + } + + if err := f.Close(); err != nil { + return err + } + + return os.Rename(tmpPath, outPath) +} + +func storePeerConfig(netName string, pc m.PeerConfig) error { + return storeJson(pc, peerConfigPath(netName)) +} + +func storePeerState(netName string, ps m.NetworkState) error { + return storeJson(ps, peerStatePath(netName)) +} + +func loadJson(dataPath string, ptr any) error { + data, err := os.ReadFile(dataPath) + if err != nil { + return err + } + + return json.Unmarshal(data, ptr) +} + +func loadPeerConfig(netName string) (pc m.PeerConfig, err error) { + return pc, loadJson(peerConfigPath(netName), &pc) +} + +func loadPeerState(netName string) (ps m.NetworkState, err error) { + return ps, loadJson(peerStatePath(netName), &ps) +} diff --git a/peer/globals.go b/peer/globals.go new file mode 100644 index 0000000..0cfebf2 --- /dev/null +++ b/peer/globals.go @@ -0,0 +1,16 @@ +package peer + +const ( + DEFAULT_PORT = 515 + NONCE_SIZE = 24 + KEY_SIZE = 32 + SIG_SIZE = 64 + MTU = 1408 + BUFFER_SIZE = MTU + NONCE_SIZE + SIG_SIZE + + STREAM_DATA = 0 + STREAM_ROUTING = 1 // Routing queries and responses. + + // Basic packet types + PACKET_TYPE_DATA = 0 +) diff --git a/peer/main.go b/peer/main.go new file mode 100644 index 0000000..94a6f67 --- /dev/null +++ b/peer/main.go @@ -0,0 +1,86 @@ +package peer + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "runtime/debug" + "vppn/m" + + _ "net/http/pprof" +) + +func Main() { + defer func() { + if r := recover(); r != nil { + fmt.Println("stacktrace from panic: \n" + string(debug.Stack())) + } + }() + + go func() { + log.Printf("Serving on localhost:6060...") + log.Println(http.ListenAndServe("localhost:6060", nil)) + }() + + var ( + netName string + initURL string + listenIP string + port int + ) + + flag.StringVar(&netName, "name", "", "[REQUIRED] The network name.") + flag.StringVar(&initURL, "init-url", "", "Initializes peer from the hub URL.") + flag.StringVar(&listenIP, "listen-ip", "", "IP address to listen on.") + flag.IntVar(&port, "port", 0, "Port to listen on.") + flag.Parse() + + if netName == "" { + flag.Usage() + os.Exit(1) + } + + if initURL != "" { + mainInit(netName, initURL) + return + } + + peer, err := NewPeer(netName, listenIP, uint16(port)) + if err != nil { + log.Fatalf("Failed to create peer: %v", err) + } + + peer.Run() +} + +func mainInit(netName, initURL string) { + if _, err := loadPeerConfig(netName); err == nil { + log.Fatalf("Network is already initialized.") + } + + resp, err := http.Get(initURL) + if err != nil { + log.Fatalf("Failed to fetch data from hub: %v", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatalf("Failed to read response body: %v", err) + } + + peerConfig := m.PeerConfig{} + if err := json.Unmarshal(data, &peerConfig); err != nil { + log.Fatalf("Failed to parse configuration: %v", err) + } + + if err := storePeerConfig(netName, peerConfig); err != nil { + log.Fatalf("Failed to store configuration: %v", err) + } + + log.Print("Initialization successful.") +} diff --git a/peer/nonce.go b/peer/nonce.go new file mode 100644 index 0000000..4ebabf4 --- /dev/null +++ b/peer/nonce.go @@ -0,0 +1,26 @@ +package peer + +import "unsafe" + +func ParseNonceBytes(nb []byte, nonce *Nonce) { + nonce.Counter = *(*uint64)(unsafe.Pointer(&nb[0])) + nonce.SourceIP = nb[8] + nonce.ViaIP = nb[9] + nonce.DestIP = nb[10] + nonce.StreamID = nb[11] + nonce.PacketType = nb[12] +} + +func MarshalNonce(nonce Nonce, buf []byte) { + *(*uint64)(unsafe.Pointer(&buf[0])) = nonce.Counter + buf[8] = nonce.SourceIP + buf[9] = nonce.ViaIP + buf[10] = nonce.DestIP + buf[11] = nonce.StreamID + buf[12] = nonce.PacketType + clear(buf[13:]) +} + +func CounterTimestamp(counter uint64) int64 { + return int64(counter >> 30) +} diff --git a/peer/nonce_test.go b/peer/nonce_test.go new file mode 100644 index 0000000..fd6f883 --- /dev/null +++ b/peer/nonce_test.go @@ -0,0 +1,25 @@ +package peer + +import ( + "testing" +) + +func TestMarshalParseNonce(t *testing.T) { + nIn := Nonce{ + Counter: 3212, + SourceIP: 34, + ViaIP: 20, + DestIP: 200, + StreamID: 4, + PacketType: 44, + } + + buf := make([]byte, NONCE_SIZE) + MarshalNonce(nIn, buf) + + nOut := Nonce{} + ParseNonceBytes(buf, &nOut) + if nIn != nOut { + t.Fatal(nIn, nOut) + } +} diff --git a/peer/peer-ifreader.go b/peer/peer-ifreader.go new file mode 100644 index 0000000..1a57e97 --- /dev/null +++ b/peer/peer-ifreader.go @@ -0,0 +1,114 @@ +package peer + +import ( + "fmt" + "log" + "net" + "net/netip" + "runtime/debug" + "vppn/fasttime" +) + +func (peer *Peer) ifReader() { + defer func() { + if r := recover(); r != nil { + fmt.Println("stacktrace from panic: \n" + string(debug.Stack())) + } + }() + + var ( + n int + destIP byte + router = peer.router + route *route + iface = peer.iface + nonce = Nonce{ + SourceIP: peer.ip, + PacketType: PACKET_TYPE_DATA, + StreamID: STREAM_DATA, + } + err error + now uint64 + counterTS uint64 + counter uint64 + packet = make([]byte, BUFFER_SIZE) + encrypted = make([]byte, BUFFER_SIZE) + nonceBuf = make([]byte, NONCE_SIZE) + toSend []byte + signingKey = peer.signPrivKey + + reqPool = make(chan udpWriteReq, 1024) + writeChan = make(chan udpWriteReq, 1024) + ) + + for range cap(reqPool) { + reqPool <- udpWriteReq{Packet: make([]byte, BUFFER_SIZE)} + } + + go udpWriter(writeChan, peer.conn, reqPool) + + for { + n, err = iface.Read(packet[:BUFFER_SIZE]) + if err != nil { + log.Fatalf("Failed to read from interface: %v", err) + } + + if n < 20 { + log.Printf("Dropping small packet: %d", n) + continue + } + + packet = packet[:n] + + destIP = packet[19] + route = router.GetRoute(destIP) + if route == nil { + log.Printf("Dropping packet for non-existent IP: %d", destIP) + continue + } + + now = uint64(fasttime.Now()) + if counterTS < now { + counterTS = now + counter = now << 30 + } + + counter++ + + nonce.Counter = counter + nonce.ViaIP = route.ViaIP + nonce.DestIP = destIP + + MarshalNonce(nonce, nonceBuf) + + encrypted = encryptPacket(route.EncSharedKey, nonceBuf, packet, encrypted) + + if route.ViaIP != 0 { + toSend = signPacket(signingKey, encrypted, packet) + } else { + toSend = encrypted + } + + req := <-reqPool + req.Addr = route.Addr + req.Packet = req.Packet[:len(toSend)] + copy(req.Packet, toSend) + + writeChan <- req + } +} + +type udpWriteReq struct { + Addr netip.AddrPort + Packet []byte +} + +func udpWriter(in chan udpWriteReq, conn *net.UDPConn, reqPool chan udpWriteReq) { + var err error + for req := range in { + if _, err = conn.WriteToUDPAddrPort(req.Packet, req.Addr); err != nil { + log.Fatalf("Failed to write UDP packet: %v", err) + } + reqPool <- req + } +} diff --git a/peer/peer-netreader.go b/peer/peer-netreader.go new file mode 100644 index 0000000..b8e719d --- /dev/null +++ b/peer/peer-netreader.go @@ -0,0 +1,158 @@ +package peer + +import ( + "fmt" + "io" + "log" + "runtime/debug" + "vppn/fasttime" +) + +func (peer *Peer) netReader() { + defer func() { + if r := recover(); r != nil { + fmt.Println("stacktrace from panic: \n" + string(debug.Stack())) + } + }() + + var ( + n int + //srcAddr *net.UDPAddr + nonce Nonce + packet = make([]byte, BUFFER_SIZE) + decrypted = make([]byte, BUFFER_SIZE) + toWrite []byte + route *route + ok bool + err error + conn = peer.conn + ip = peer.ip + counters = [2][256]uint64{} // Counter by stream and IP. + + ifaceChan = make(chan []byte, 1024) + reqPool = make(chan []byte, 1024) + ) + + for range cap(reqPool) { + reqPool <- make([]byte, BUFFER_SIZE) + } + + go ifWriter(ifaceChan, peer.iface, reqPool) + +NEXT_PACKET: + + n, _, err = conn.ReadFromUDPAddrPort(packet[:BUFFER_SIZE]) + if err != nil { + log.Fatalf("Failed to read UDP packet: %v", err) + } + + if n < NONCE_SIZE { + log.Printf("Dropping short UDP packet: %d", n) + goto NEXT_PACKET + } + + packet = packet[:n] + + ParseNonceBytes(packet[n-NONCE_SIZE:], &nonce) + + // Drop after 8 seconds. + if CounterTimestamp(nonce.Counter) < fasttime.Now()-8 { + log.Printf("Dropping old packet: %d", CounterTimestamp(nonce.Counter)) + goto NEXT_PACKET + } + + if nonce.StreamID > 1 { + log.Printf("Dropping invalid stream ID: %v", nonce) + goto NEXT_PACKET + } + + // Check source counter. + if nonce.Counter <= counters[nonce.StreamID][nonce.SourceIP] { + log.Printf("Dropping packet with bad counter: %v", nonce) + goto NEXT_PACKET + } + + counters[nonce.StreamID][nonce.SourceIP] = nonce.Counter + + route = peer.router.GetRoute(nonce.SourceIP) + if route == nil { + log.Printf("Dropping packet without route: %v", nonce) + goto NEXT_PACKET + } + + switch ip { + case nonce.DestIP: + goto DECRYPT + case nonce.ViaIP: + goto VALIDATE_SIGNATURE + default: + log.Printf("Bad packet: %v", nonce) + goto NEXT_PACKET + } + +DECRYPT: + + decrypted, ok = decryptPacket(route.EncSharedKey, packet, decrypted) + if !ok { + log.Printf("Failed to decrypt packet: %v", nonce) + goto NEXT_PACKET + } + + switch nonce.StreamID { + case STREAM_DATA: + goto WRITE_IFACE_DATA + case STREAM_ROUTING: + goto WRITE_ROUTING_PACKET + default: + log.Printf("Invalid stream ID: %d", nonce.StreamID) + goto NEXT_PACKET + } + +WRITE_IFACE_DATA: + + //toWrite = toWrite[:len(decrypted)] + //copy(toWrite, decrypted) + + toWrite = <-reqPool + ifaceChan <- append(toWrite[:0], decrypted...) + + goto NEXT_PACKET + +WRITE_ROUTING_PACKET: + + //peer.processRoutingPacket(decrypted) + //peer.routeManager.ProcessPacket(decrypted) + + goto NEXT_PACKET + +VALIDATE_SIGNATURE: + + // We don't forward twice. + if route.Mediator { + log.Printf("Dropping double-forward packet: %v", nonce) + goto NEXT_PACKET + } + + decrypted, ok = openPacket(route.SignPubKey, packet, decrypted) + if !ok { + log.Printf("Failed to open signed packet: %v", nonce) + goto NEXT_PACKET + } + + if _, err = conn.WriteToUDPAddrPort(decrypted, route.Addr); err != nil { + log.Fatalf("Failed to forward packet: %v", err) + } + + goto NEXT_PACKET +} + +func ifWriter(in chan []byte, iface io.ReadWriteCloser, out chan []byte) { + var err error + for packet := range in { + if _, err = iface.Write(packet); err != nil { + log.Printf("Size: %d", len(packet)) + log.Fatalf("Failed to write to interface: %v", err) + } + out <- packet + } +} diff --git a/peer/peer.go b/peer/peer.go new file mode 100644 index 0000000..0b767e6 --- /dev/null +++ b/peer/peer.go @@ -0,0 +1,67 @@ +package peer + +import ( + "io" + "log" + "net" +) + +type Peer struct { + // Immutable data. + ip byte // Last byte of IPv4 address. + hubAddr string + apiKey string + + encPubKey []byte + encPrivKey []byte + signPubKey []byte + signPrivKey []byte + conn *net.UDPConn + iface io.ReadWriteCloser + + router *Router +} + +func NewPeer(netName, listenIP string, port uint16) (*Peer, error) { + conf, err := loadPeerConfig(netName) + if err != nil { + return nil, err + } + + peer := &Peer{ + ip: conf.PeerIP, + hubAddr: conf.HubAddress, + apiKey: conf.APIKey, + encPubKey: conf.EncPubKey, + encPrivKey: conf.EncPrivKey, + signPubKey: conf.SignPubKey, + signPrivKey: conf.SignPrivKey, + } + + peer.router = NewRouter(conf) + + port = determinePort(conf.Port, port) + + conn, err := openUDPConn(listenIP, port) + if err != nil { + return nil, err + } + + peer.conn = conn + + peer.iface, err = openInterface(conf.Network, conf.PeerIP, netName) + if err != nil { + log.Fatal(err) + } + + if err != nil { + return nil, err + } + + return peer, nil +} + +func (p *Peer) Run() { + go p.netReader() + p.ifReader() +} diff --git a/peer/router-ping.go b/peer/router-ping.go new file mode 100644 index 0000000..a624188 --- /dev/null +++ b/peer/router-ping.go @@ -0,0 +1,18 @@ +package peer + +type RoutingPingReq struct { + PeerIP byte + Type byte // 0 => local, 1 => direct, 2 => Via + Addr []byte + Port int + SentAt int64 // unix milli +} + +type RoutingPingResp struct { + PeerIP byte + Type byte // 0 => local, 1 => direct, 2 => Via + Addr []byte + Port int + SentAt int64 + RecvdAt int64 +} diff --git a/peer/router.go b/peer/router.go new file mode 100644 index 0000000..507804a --- /dev/null +++ b/peer/router.go @@ -0,0 +1,123 @@ +package peer + +import ( + "encoding/json" + "io" + "log" + "net/http" + "net/netip" + "net/url" + "sync/atomic" + "time" + "vppn/m" +) + +var zeroAddrPort netip.AddrPort + +type routeInfo struct { + Up bool + Route route +} + +type Router struct { + conf m.PeerConfig + routes [256]*atomic.Pointer[routeInfo] +} + +func NewRouter(conf m.PeerConfig) *Router { + rm := &Router{ + conf: conf, + } + + for i := range rm.routes { + rm.routes[i] = &atomic.Pointer[routeInfo]{} + rm.routes[i].Store(&routeInfo{}) + } + + go rm.pollHub() + return rm +} + +func (rm *Router) GetRoute(ip byte) *route { + if route := rm.routes[ip].Load(); route != nil && route.Up { + return &route.Route + } + return nil +} + +func (rm *Router) pollHub() { + u, err := url.Parse(rm.conf.HubAddress) + if err != nil { + log.Fatalf("Failed to parse hub address %s: %v", rm.conf.HubAddress, err) + } + u.Path = "/peer/fetch-state/" + + client := &http.Client{Timeout: 8 * time.Second} + + req := &http.Request{ + Method: http.MethodGet, + URL: u, + Header: http.Header{}, + } + req.SetBasicAuth("", rm.conf.APIKey) + + rm._pollHub(client, req) + + for range time.Tick(time.Minute) { + rm._pollHub(client, req) + } +} + +func (rm *Router) _pollHub(client *http.Client, req *http.Request) { + var state m.NetworkState + + log.Printf("Fetching peer state from %s...", rm.conf.HubAddress) + resp, err := client.Do(req) + if err != nil { + log.Printf("Failed to fetch peer state: %v", err) + return + } + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + log.Printf("Failed to read body: %v", err) + return + } + + if err := json.Unmarshal(body, &state); err != nil { + log.Printf("Failed to unmarshal response from hub: %v", err) + return + } + + for i, peer := range state.Peers { + if peer == nil { + continue + } + route := rm.routes[i].Load() + rm.routes[i].Store(rm.updateRoute(route, peer)) + } +} + +func (rm *Router) updateRoute(routePtr *routeInfo, peer *m.Peer) *routeInfo { + if peer == nil { + return &routeInfo{} + } + + route := *routePtr + + addr, ok := netip.AddrFromSlice(peer.IP) + if !ok { + return &routeInfo{} + } + + route.Up = true + route.Route.Addr = netip.AddrPortFrom(addr, peer.Port) + route.Route.Mediator = peer.Mediator + route.Route.ViaIP = 0 + if len(route.Route.SignPubKey) == 0 { + route.Route.SignPubKey = peer.SignPubKey + route.Route.EncSharedKey = computeSharedKey(peer.EncPubKey, rm.conf.EncPrivKey) + } + + return &route +} diff --git a/peer/startup.go b/peer/startup.go new file mode 100644 index 0000000..d2e6c52 --- /dev/null +++ b/peer/startup.go @@ -0,0 +1,156 @@ +package peer + +import ( + "fmt" + "io" + "net" + "os" + "syscall" + + "golang.org/x/sys/unix" +) + +func determinePort(confPort, portFromCommandLine uint16) uint16 { + if portFromCommandLine != 0 { + return portFromCommandLine + } + if confPort != 0 { + return confPort + } + return DEFAULT_PORT +} + +func openUDPConn(listenIP string, port uint16) (*net.UDPConn, error) { + myAddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", listenIP, port)) + if err != nil { + return nil, fmt.Errorf("failed to construct UDP address: %w", err) + } + + return net.ListenUDP("udp", myAddr) +} + +func openInterface(network []byte, localIP byte, name string) (io.ReadWriteCloser, error) { + if len(network) != 4 { + return nil, fmt.Errorf("expected network to be 4 bytes, got %d", len(network)) + } + ip := net.IPv4(network[0], network[1], network[2], localIP) + + ////////////////////////// + // Create TUN Interface // + ////////////////////////// + + tunFD, err := syscall.Open("/dev/net/tun", syscall.O_RDWR|unix.O_CLOEXEC, 0600) + if err != nil { + return nil, fmt.Errorf("failed to open TUN device: %w", err) + } + + // New interface request. + req, err := unix.NewIfreq(name) + if err != nil { + return nil, fmt.Errorf("failed to create new TUN interface request: %w", err) + } + + // Flags: + // + // IFF_NO_PI => don't add packet info data to packets sent to the interface. + // IFF_TUN => create a TUN device handling IP packets. + req.SetUint16(unix.IFF_NO_PI | unix.IFF_TUN) + + err = unix.IoctlIfreq(tunFD, unix.TUNSETIFF, req) + if err != nil { + return nil, fmt.Errorf("failed to set TUN device settings: %w", err) + } + + // Name may not be exactly the same? + name = req.Name() + + ///////////// + // Set MTU // + ///////////// + + // We need a socket file descriptor to set other options for some reason. + sockFD, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, unix.IPPROTO_IP) + if err != nil { + return nil, fmt.Errorf("failed to open socket: %w", err) + } + defer unix.Close(sockFD) + + req, err = unix.NewIfreq(name) + if err != nil { + return nil, fmt.Errorf("failed to create MTU interface request: %w", err) + } + + req.SetUint32(MTU) + if err = unix.IoctlIfreq(sockFD, unix.SIOCSIFMTU, req); err != nil { + return nil, fmt.Errorf("failed to set interface MTU: %w", err) + } + + ////////////////////// + // Set Queue Length // + ////////////////////// + + req, err = unix.NewIfreq(name) + if err != nil { + return nil, fmt.Errorf("failed to create IP interface request: %w", err) + } + + req.SetUint16(1000) + if err = unix.IoctlIfreq(sockFD, unix.SIOCSIFTXQLEN, req); err != nil { + return nil, fmt.Errorf("failed to set interface queue length: %w", err) + } + + ///////////////////// + // Set IP and Mask // + ///////////////////// + + req, err = unix.NewIfreq(name) + if err != nil { + return nil, fmt.Errorf("failed to create IP interface request: %w", err) + } + + if err := req.SetInet4Addr(ip.To4()); err != nil { + return nil, fmt.Errorf("failed to set interface request IP: %w", err) + } + + if err = unix.IoctlIfreq(sockFD, unix.SIOCSIFADDR, req); err != nil { + return nil, fmt.Errorf("failed to set interface IP: %w", err) + } + + // SET MASK - must happen after setting address. + req, err = unix.NewIfreq(name) + if err != nil { + return nil, fmt.Errorf("failed to create mask interface request: %w", err) + } + + if err := req.SetInet4Addr(net.IPv4(255, 255, 255, 0).To4()); err != nil { + return nil, fmt.Errorf("failed to set interface request mask: %w", err) + } + + if err := unix.IoctlIfreq(sockFD, unix.SIOCSIFNETMASK, req); err != nil { + return nil, fmt.Errorf("failed to set interface mask: %w", err) + } + + //////////////////////// + // Bring Interface Up // + //////////////////////// + + req, err = unix.NewIfreq(name) + if err != nil { + return nil, fmt.Errorf("failed to create up interface request: %w", err) + } + + // Get current flags. + if err = unix.IoctlIfreq(sockFD, unix.SIOCGIFFLAGS, req); err != nil { + return nil, fmt.Errorf("failed to get interface flags: %w", err) + } + + flags := req.Uint16() | unix.IFF_UP | unix.IFF_RUNNING + + // Set UP flag / broadcast flags. + req.SetUint16(flags) + if err = unix.IoctlIfreq(sockFD, unix.SIOCSIFFLAGS, req); err != nil { + return nil, fmt.Errorf("failed to set interface up: %w", err) + } + + return os.NewFile(uintptr(tunFD), "tun"), nil +} diff --git a/peer/types.go b/peer/types.go new file mode 100644 index 0000000..415e9d6 --- /dev/null +++ b/peer/types.go @@ -0,0 +1,22 @@ +package peer + +import ( + "net/netip" +) + +type route struct { + Mediator bool // Route is via a mediator. + SignPubKey []byte + EncSharedKey []byte // Shared key for encoding / decoding packets. + Addr netip.AddrPort // Address to send to. + ViaIP byte // If != 0, this is a forwarding address. +} + +type Nonce struct { + Counter uint64 + SourceIP byte + ViaIP byte + DestIP byte + StreamID byte // The stream, see STREAM_* constants + PacketType byte // The packet type. See PACKET_* constants. +}