This commit is contained in:
jdl 2024-12-08 09:45:29 +01:00
parent 55a9bf9dc3
commit 03ff1aac80
60 changed files with 3165 additions and 2 deletions

2
.gitignore vendored
View File

@ -20,4 +20,4 @@
# Go workspace file
go.work
test-scripts

View File

@ -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@<remote>:~/
```
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 <addr>: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.

7
cmd/hub/main.go Normal file
View File

@ -0,0 +1,7 @@
package main
import "vppn/hub"
func main() {
hub.Main()
}

4
cmd/vppn/build.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/bash
go build
sudo setcap cap_net_admin+iep vppn

11
cmd/vppn/main.go Normal file
View File

@ -0,0 +1,11 @@
package main
import (
"log"
"vppn/peer"
)
func main() {
log.SetFlags(0)
peer.Main()
}

20
fasttime/time.go Normal file
View File

@ -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)
}

18
fasttime/time_test.go Normal file
View File

@ -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()
}
}

17
go.mod Normal file
View File

@ -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
)

16
go.sum Normal file
View File

@ -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=

260
hub/api/api.go Normal file
View File

@ -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)
}

3
hub/api/db/generate.go Normal file
View File

@ -0,0 +1,3 @@
package db
//go:generate sqlgen sqlite tables.defs generated.go

0
hub/api/db/generated Normal file
View File

480
hub/api/db/generated.go Normal file
View File

@ -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
}

View File

@ -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
}

25
hub/api/db/tables.defs Normal file
View File

@ -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
);

51
hub/api/db/written.go Normal file
View File

@ -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<?", timestamp)
return err
}
func Config_UpdatePassword(
tx TX,
pwdHash []byte,
) (err error) {
_, err = tx.Exec("UPDATE config SET Password=? WHERE ConfigID=1", pwdHash)
return err
}
func Peer_ListAll(tx TX) ([]*Peer, error) {
return Peer_List(tx, Peer_SelectQuery)
}
func Peer_GetByAPIKey(tx TX, apiKey string) (*Peer, error) {
return Peer_GetWhere(
tx,
Peer_SelectQuery+` WHERE APIKey=?`,
apiKey)
}
func Peer_Exists(tx TX, ip byte) (exists bool, err error) {
err = tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM peers WHERE PeerIP=?)`, ip).Scan(&exists)
return
}

13
hub/api/errors.go Normal file
View File

@ -0,0 +1,13 @@
package api
import (
"errors"
"vppn/hub/api/db"
)
var (
ErrNotAuthorized = errors.New("not authorized")
ErrNoIPAvailable = errors.New("no IP address available")
ErrInvalidIP = db.ErrInvalidIP
ErrInvalidPort = db.ErrInvalidPort
)

View File

@ -0,0 +1,27 @@
CREATE TABLE config (
ConfigID INTEGER NOT NULL PRIMARY KEY, -- Always 1.
HubAddress TEXT NOT NULL, -- https://for.example.com
VPNNetwork BLOB NOT NULL, -- Network (/24), example 10.51.50.0
Password BLOB NOT NULL -- bcrypt password for web interface
) WITHOUT ROWID;
CREATE TABLE sessions (
SessionID TEXT NOT NULL PRIMARY KEY,
CSRF TEXT NOT NULL,
SignedIn INTEGER NOT NULL,
CreatedAt INTEGER NOT NULL,
LastSeenAt INTEGER NOT NULL
) WITHOUT ROWID;
CREATE INDEX sessions_last_seen_index ON sessions(LastSeenAt);
CREATE TABLE peers (
PeerIP INTEGER NOT NULL PRIMARY KEY, -- Final byte.
APIKey TEXT NOT NULL UNIQUE,
Name TEXT NOT NULL UNIQUE, -- For humans.
IP BLOB NOT NULL,
Port INTEGER NOT NULL,
Mediator INTEGER NOT NULL DEFAULT 0, -- Boolean if peer will forward packets. Must also have public address.
EncPubKey BLOB NOT NULL,
SignPubKey BLOB NOT NULL
) WITHOUT ROWID;

1
hub/api/session.go Normal file
View File

@ -0,0 +1 @@
package api

7
hub/api/time.go Normal file
View File

@ -0,0 +1,7 @@
package api
import "time"
func timeSince(ts int64) int64 {
return time.Now().Unix() - ts
}

7
hub/api/types.go Normal file
View File

@ -0,0 +1,7 @@
package api
import "vppn/hub/api/db"
type Config = db.Config
type Session = db.Session
type Peer = db.Peer

52
hub/app.go Normal file
View File

@ -0,0 +1,52 @@
package hub
import (
"embed"
"html/template"
"net/http"
"path/filepath"
"vppn/hub/api"
"git.crumpington.com/lib/webutil"
)
//go:embed static
var staticFS embed.FS
//go:embed templates
var templateFS embed.FS
type Config struct {
RootDir string
ListenAddr string
Secure bool
}
type App struct {
api *api.API
mux *http.ServeMux
tmpl map[string]*template.Template
secure bool
}
func NewApp(conf Config) (*App, error) {
api, err := api.New(filepath.Join(conf.RootDir, "db.sqlite3"))
if err != nil {
return nil, err
}
app := &App{
api: api,
mux: http.NewServeMux(),
tmpl: webutil.ParseTemplateSet(templateFuncs, templateFS),
secure: conf.Secure,
}
app.registerRoutes()
return app, nil
}
var templateFuncs = template.FuncMap{
"ipToString": ipBytesTostring,
}

33
hub/cookie.go Normal file
View File

@ -0,0 +1,33 @@
package hub
import (
"net/http"
"time"
)
func (a *App) getCookie(r *http.Request, name string) string {
if c, err := r.Cookie(name); err == nil {
return c.Value
}
return ""
}
func (a *App) setCookie(w http.ResponseWriter, name, value string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/",
Secure: a.secure,
SameSite: http.SameSiteStrictMode,
MaxAge: 86400 * 365 * 10,
})
}
func (a *App) deleteCookie(w http.ResponseWriter, name string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
})
}

5
hub/global.go Normal file
View File

@ -0,0 +1,5 @@
package hub
const (
SESSION_ID_COOKIE_NAME = "SessionID"
)

66
hub/handler.go Normal file
View File

@ -0,0 +1,66 @@
package hub
import (
"log"
"net/http"
"vppn/hub/api"
"git.crumpington.com/lib/webutil"
)
type handlerFunc func(s *api.Session, w http.ResponseWriter, r *http.Request) error
func (app *App) handlePub(pattern string, fn handlerFunc) {
wrapped := func(w http.ResponseWriter, r *http.Request) {
sessionID := app.getCookie(r, SESSION_ID_COOKIE_NAME)
s, err := app.api.Session_Get(sessionID)
if err != nil {
log.Printf("Failed to get session: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if s.SessionID != sessionID {
app.setCookie(w, SESSION_ID_COOKIE_NAME, s.SessionID)
}
if r.Method == http.MethodPost {
r.ParseMultipartForm(64 * 1024)
if r.FormValue("CSRF") != s.CSRF {
log.Printf("%s != %s", r.FormValue("CSRF"), s.CSRF)
http.Error(w, "CSRF mismatch", http.StatusBadRequest)
return
}
} else {
r.ParseForm()
}
if err := fn(s, w, r); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
app.mux.HandleFunc(pattern,
webutil.WithLogging(
wrapped))
}
func (app *App) handleNotSignedIn(pattern string, fn handlerFunc) {
app.handlePub(pattern, func(s *api.Session, w http.ResponseWriter, r *http.Request) error {
if s.SignedIn {
http.Redirect(w, r, "/", http.StatusSeeOther)
return nil
}
return fn(s, w, r)
})
}
func (app *App) handleSignedIn(pattern string, fn handlerFunc) {
app.handlePub(pattern, func(s *api.Session, w http.ResponseWriter, r *http.Request) error {
if !s.SignedIn {
http.Redirect(w, r, "/", http.StatusSeeOther)
return nil
}
return fn(s, w, r)
})
}

353
hub/handlers.go Normal file
View File

@ -0,0 +1,353 @@
package hub
import (
"errors"
"log"
"net/http"
"vppn/hub/api"
"vppn/m"
"git.crumpington.com/lib/go/webutil"
"golang.org/x/crypto/bcrypt"
)
func (a *App) _root(s *api.Session, w http.ResponseWriter, r *http.Request) error {
if s.SignedIn {
return a.redirect(w, r, "/admin/config/")
} else {
return a.redirect(w, r, "/sign-in/")
}
}
func (a *App) _signin(s *api.Session, w http.ResponseWriter, r *http.Request) error {
return a.render("/sign-in.html", w, struct{ Session *api.Session }{s})
}
func (a *App) _signinSubmit(s *api.Session, w http.ResponseWriter, r *http.Request) error {
var pwd string
err := webutil.NewFormScanner(r.Form).
Scan("Password", &pwd).
Error()
if err != nil {
return err
}
if err := a.api.Session_SignIn(s, pwd); err != nil {
return err
}
return a.redirect(w, r, "/")
}
func (a *App) _adminSignOut(s *api.Session, w http.ResponseWriter, r *http.Request) error {
return a.render("/admin-sign-out.html", w, struct{ Session *api.Session }{s})
}
func (a *App) _adminSignOutSubmit(s *api.Session, w http.ResponseWriter, r *http.Request) error {
if err := a.api.Session_Delete(s.SessionID); err != nil {
log.Printf("Failed to delete session cookie %s: %v", s.SessionID, err)
}
a.deleteCookie(w, SESSION_ID_COOKIE_NAME)
return a.redirect(w, r, "/")
}
func (a *App) _adminConfig(s *api.Session, w http.ResponseWriter, r *http.Request) error {
return a.render("/admin-config.html", w, struct {
Session *api.Session
Config *api.Config
}{
s,
a.api.Config_Get(),
})
}
func (a *App) _adminConfigEdit(s *api.Session, w http.ResponseWriter, r *http.Request) error {
return a.render("/admin-config-edit.html", w, struct {
Session *api.Session
Config *api.Config
}{
s,
a.api.Config_Get(),
})
}
func (a *App) _adminConfigEditSubmit(s *api.Session, w http.ResponseWriter, r *http.Request) error {
var (
conf = a.api.Config_Get()
ipStr string
)
err := webutil.NewFormScanner(r.Form).
Scan("HubAddress", &conf.HubAddress).
Scan("VPNNetwork", &ipStr).
Error()
if err != nil {
return err
}
if conf.VPNNetwork, err = stringToIP(ipStr); err != nil {
return err
}
if err := a.api.Config_Update(conf); err != nil {
return err
}
return a.redirect(w, r, "/admin/config/")
}
func (a *App) _adminPasswordEdit(s *api.Session, w http.ResponseWriter, r *http.Request) error {
return a.render("/admin-password-edit.html", w, struct{ Session *api.Session }{s})
}
func (a *App) _adminPasswordSubmit(s *api.Session, w http.ResponseWriter, r *http.Request) error {
var (
conf = a.api.Config_Get()
curPwd string
newPwd string
newPwd2 string
)
err := webutil.NewFormScanner(r.Form).
Scan("CurrentPassword", &curPwd).
Scan("NewPassword", &newPwd).
Scan("NewPassword2", &newPwd2).
Error()
if err != nil {
return err
}
if len(newPwd) < 8 {
return errors.New("password is too short")
}
if newPwd != newPwd2 {
return errors.New("passwords don't match")
}
err = bcrypt.CompareHashAndPassword(conf.Password, []byte(curPwd))
if err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPwd), bcrypt.DefaultCost)
if err != nil {
return err
}
if err := a.api.Config_UpdatePassword(hash); err != nil {
return err
}
return a.redirect(w, r, "/admin/config/")
}
func (a *App) _adminPeerList(s *api.Session, w http.ResponseWriter, r *http.Request) error {
peers, err := a.api.Peer_List()
if err != nil {
return err
}
return a.render("/admin-peer-list.html", w, struct {
Session *api.Session
Peers []*api.Peer
}{
s,
peers,
})
}
func (a *App) _adminPeerCreate(s *api.Session, w http.ResponseWriter, r *http.Request) error {
return a.render("/admin-peer-create.html", w, struct{ Session *api.Session }{s})
}
func (a *App) _adminPeerCreateSubmit(s *api.Session, w http.ResponseWriter, r *http.Request) error {
var ipStr string
args := api.PeerCreateArgs{}
err := webutil.NewFormScanner(r.Form).
Scan("Name", &args.Name).
Scan("IP", &ipStr).
Scan("Port", &args.Port).
Scan("Mediator", &args.Mediator).
Error()
if err != nil {
return err
}
if args.IP, err = stringToIP(ipStr); err != nil {
return err
}
code := a.api.Peer_CreateIntent(args)
return a.redirect(w, r, "/admin/peer/intent-created/?Code=%s", code)
}
func (a *App) _adminPeerIntentCreated(s *api.Session, w http.ResponseWriter, r *http.Request) error {
code := r.FormValue("Code")
if code == "" {
return errors.New("missing Code")
}
return a.render("/admin-peer-intent.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 {
var peerIP byte
err := webutil.NewFormScanner(r.Form).Scan("PeerIP", &peerIP).Error()
if err != nil {
return err
}
peer, err := a.api.Peer_Get(peerIP)
if err != nil {
return err
}
return a.render("/admin-peer-view.html", w, struct {
Session *api.Session
Peer *api.Peer
}{s, peer})
}
func (a *App) _adminPeerEdit(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
}
peer, err := a.api.Peer_Get(peerIP)
if err != nil {
return err
}
return a.render("/admin-peer-edit.html", w, struct {
Session *api.Session
Peer *api.Peer
}{s, peer})
}
func (a *App) _adminPeerEditSubmit(s *api.Session, w http.ResponseWriter, r *http.Request) error {
var (
peerIP byte
ipStr string
)
err := webutil.NewFormScanner(r.Form).Scan("PeerIP", &peerIP).Error()
if err != nil {
return err
}
peer, err := a.api.Peer_Get(peerIP)
if err != nil {
return err
}
err = webutil.NewFormScanner(r.Form).
Scan("Name", &peer.Name).
Scan("IP", &ipStr).
Scan("Port", &peer.Port).
Scan("Mediator", &peer.Mediator).
Error()
if err != nil {
return err
}
if peer.IP, err = stringToIP(ipStr); err != nil {
return err
}
if err = a.api.Peer_Update(peer); err != nil {
return err
}
return a.redirect(w, r, "/admin/peer/view/?PeerIP=%d", peer.PeerIP)
}
func (a *App) _adminPeerDelete(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
}
peer, err := a.api.Peer_Get(peerIP)
if err != nil {
return err
}
return a.render("/admin-peer-delete.html", w, struct {
Session *api.Session
Peer *api.Peer
}{s, peer})
}
func (a *App) _adminPeerDeleteSubmit(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
}
if err := a.api.Peer_Delete(peerIP); err != nil {
return err
}
return a.redirect(w, r, "/admin/peer/list/")
}
func (a *App) _peerCreate(s *api.Session, w http.ResponseWriter, r *http.Request) error {
code := r.FormValue("Code")
conf, err := a.api.Peer_Create(code)
if err != nil {
return err
}
return a.sendJSON(w, conf)
}
func (a *App) _peerFetchState(s *api.Session, w http.ResponseWriter, r *http.Request) error {
_, apiKey, ok := r.BasicAuth()
if !ok {
log.Printf("1")
return api.ErrNotAuthorized
}
peer, err := a.api.Peer_GetByAPIKey(apiKey)
if err != nil {
log.Printf("2")
return err
}
peers, err := a.api.Peer_List()
if err != nil {
log.Printf("3")
return err
}
conf := a.api.Config_Get()
state := m.NetworkState{
HubAddress: conf.HubAddress,
Network: conf.VPNNetwork,
PeerIP: peer.PeerIP,
IP: peer.IP,
Port: peer.Port,
}
for _, p := range peers {
state.Peers[p.PeerIP] = &m.Peer{
PeerIP: p.PeerIP,
Name: p.Name,
IP: p.IP,
Port: p.Port,
Mediator: p.Mediator,
EncPubKey: p.EncPubKey,
SignPubKey: p.SignPubKey,
}
}
return a.sendJSON(w, state)
}

38
hub/main.go Normal file
View File

@ -0,0 +1,38 @@
package hub
import (
"flag"
"log"
"net/http"
"os"
"git.crumpington.com/lib/webutil"
)
func Main() {
log.SetFlags(0)
conf := Config{}
flag.StringVar(&conf.RootDir, "root-dir", "", "[REQUIRED] Root directory.")
flag.StringVar(&conf.ListenAddr, "listen", "", "[REQUIRED] Listen address.")
flag.BoolVar(&conf.Secure, "secure", false, "Use secure cookies.")
flag.Parse()
if conf.RootDir == "" || conf.ListenAddr == "" {
flag.Usage()
os.Exit(1)
}
app, err := NewApp(conf)
if err != nil {
log.Fatal(err)
}
srv := &http.Server{
Addr: conf.ListenAddr,
Handler: app.mux,
}
log.Fatal(webutil.ListenAndServe(srv))
}

31
hub/routes.go Normal file
View File

@ -0,0 +1,31 @@
package hub
import "net/http"
func (a *App) registerRoutes() {
a.mux.Handle("GET /static/", http.FileServerFS(staticFS))
a.handlePub("GET /", a._root)
a.handleNotSignedIn("GET /sign-in/", a._signin)
a.handleNotSignedIn("POST /sign-in/", a._signinSubmit)
a.handleSignedIn("GET /admin/config/", a._adminConfig)
a.handleSignedIn("GET /admin/config/edit/", a._adminConfigEdit)
a.handleSignedIn("POST /admin/config/edit/", a._adminConfigEditSubmit)
a.handleSignedIn("GET /admin/sign-out/", a._adminSignOut)
a.handleSignedIn("POST /admin/sign-out/", a._adminSignOutSubmit)
a.handleSignedIn("GET /admin/password/edit/", a._adminPasswordEdit)
a.handleSignedIn("POST /admin/password/edit/", a._adminPasswordSubmit)
a.handleSignedIn("GET /admin/peer/list/", a._adminPeerList)
a.handleSignedIn("GET /admin/peer/create/", a._adminPeerCreate)
a.handleSignedIn("POST /admin/peer/create/", a._adminPeerCreateSubmit)
a.handleSignedIn("GET /admin/peer/intent-created/", a._adminPeerIntentCreated)
a.handleSignedIn("GET /admin/peer/view/", a._adminPeerView)
a.handleSignedIn("GET /admin/peer/edit/", a._adminPeerEdit)
a.handleSignedIn("POST /admin/peer/edit/", a._adminPeerEditSubmit)
a.handleSignedIn("GET /admin/peer/delete/", a._adminPeerDelete)
a.handleSignedIn("POST /admin/peer/delete/", a._adminPeerDeleteSubmit)
a.handleNotSignedIn("GET /peer/create/", a._peerCreate)
a.handleNotSignedIn("GET /peer/fetch-state/", a._peerFetchState)
}

22
hub/static/custom.css Normal file
View File

@ -0,0 +1,22 @@
body {
max-width: 1920px;
}
.def-list tr td:first-child {
text-align:right;
width:1%;
white-space:nowrap;
}
input, textarea, select {
width:100%;
max-width:640px;
}
input[type='checkbox'] {
width:unset;
}
a {
text-decoration: none;
}

8
hub/static/new.min.css vendored Normal file
View File

@ -0,0 +1,8 @@
/**
* Minified by jsDelivr using clean-css v4.2.1.
* Original file: /npm/@exampledev/new.css@1.1.3/new.css
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
:root{--nc-font-sans:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";--nc-font-mono:Consolas,monaco,'Ubuntu Mono','Liberation Mono','Courier New',Courier,monospace;--nc-tx-1:#000000;--nc-tx-2:#1A1A1A;--nc-bg-1:#FFFFFF;--nc-bg-2:#F6F8FA;--nc-bg-3:#E5E7EB;--nc-lk-1:#0070F3;--nc-lk-2:#0366D6;--nc-lk-tx:#FFFFFF;--nc-ac-1:#79FFE1;--nc-ac-tx:#0C4047}@media (prefers-color-scheme:dark){:root{--nc-tx-1:#ffffff;--nc-tx-2:#eeeeee;--nc-bg-1:#000000;--nc-bg-2:#111111;--nc-bg-3:#222222;--nc-lk-1:#3291FF;--nc-lk-2:#0070F3;--nc-lk-tx:#FFFFFF;--nc-ac-1:#7928CA;--nc-ac-tx:#FFFFFF}}*{margin:0;padding:0}address,area,article,aside,audio,blockquote,datalist,details,dl,fieldset,figure,form,iframe,img,input,meter,nav,ol,optgroup,option,output,p,pre,progress,ruby,section,table,textarea,ul,video{margin-bottom:1rem}button,html,input,select{font-family:var(--nc-font-sans)}body{margin:0 auto;max-width:750px;padding:2rem;border-radius:6px;overflow-x:hidden;word-break:break-word;overflow-wrap:break-word;background:var(--nc-bg-1);color:var(--nc-tx-2);font-size:1.03rem;line-height:1.5}::selection{background:var(--nc-ac-1);color:var(--nc-ac-tx)}h1,h2,h3,h4,h5,h6{line-height:1;color:var(--nc-tx-1);padding-top:.875rem}h1,h2,h3{color:var(--nc-tx-1);padding-bottom:2px;margin-bottom:8px;border-bottom:1px solid var(--nc-bg-2)}h4,h5,h6{margin-bottom:.3rem}h1{font-size:2.25rem}h2{font-size:1.85rem}h3{font-size:1.55rem}h4{font-size:1.25rem}h5{font-size:1rem}h6{font-size:.875rem}a{color:var(--nc-lk-1)}a:hover{color:var(--nc-lk-2)}abbr:hover{cursor:help}blockquote{padding:1.5rem;background:var(--nc-bg-2);border-left:5px solid var(--nc-bg-3)}abbr{cursor:help}blockquote :last-child{padding-bottom:0;margin-bottom:0}header{background:var(--nc-bg-2);border-bottom:1px solid var(--nc-bg-3);padding:2rem 1.5rem;margin:-2rem calc(0px - (50vw - 50%)) 2rem;padding-left:calc(50vw - 50%);padding-right:calc(50vw - 50%)}header h1,header h2,header h3{padding-bottom:0;border-bottom:0}header>: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 */

View File

@ -0,0 +1,20 @@
{{define "body" -}}
<h2>Config</h2>
<form method="POST">
<input type="hidden" name="CSRF" value="{{.Session.CSRF}}">
<p>
<label>Hub Address</label><br>
<input type="url" name="HubAddress" value="{{.Config.HubAddress}}">
</p>
<p>
<label>VPN Network</label><br>
<input type="text" name="VPNNetwork" value="{{ipToString .Config.VPNNetwork}}">
</p>
<p>
<button type="submit">Save</button>
<a href="/admin/config/">Cancel</a>
</p>
</form>
{{- end}}

View File

@ -0,0 +1,19 @@
{{define "body" -}}
<h2>Config</h2>
<p>
<a href="/admin/config/edit/">Edit</a> /
<a href="/admin/password/edit/">Change Password</a>
</p>
<table class="def-list">
<tr>
<td>Hub Address</td>
<td>{{.Config.HubAddress}}</td>
</tr>
<tr>
<td>VPN Network</td>
<td>{{ipToString .Config.VPNNetwork}}</td>
</tr>
</table>
{{- end}}

View File

@ -0,0 +1,23 @@
{{define "body" -}}
<h2>Change Password</h2>
<form method="POST">
<input type="hidden" name="CSRF" value="{{.Session.CSRF}}">
<p>
<label>Current Password</label><br>
<input type="password" name="CurrentPassword">
</p>
<p>
<label>New Password</label><br>
<input type="password" name="NewPassword">
</p>
<p>
<label>Repeat New Password</label><br>
<input type="password" name="NewPassword2">
</p>
<p>
<button type="submit">Save</button>
<a href="/admin/config/">Cancel</a>
</p>
</form>
{{- end}}

View File

@ -0,0 +1,30 @@
{{define "body" -}}
<h2>New Peer</h2>
<form method="POST">
<input type="hidden" name="CSRF" value="{{.Session.CSRF}}">
<p>
<label>Name</label><br>
<input type="text" name="Name">
</p>
<p>
<label>IP</label><br>
<input type="text" name="IP">
</p>
<p>
<label>Port</label><br>
<input type="number" name="Port" value="515">
</p>
<p>
<label>
<input type="checkbox" name="Mediator">
Mediator
</label>
</p>
<p>
<button type="submit">Save</button>
<a href="/admin/config/">Cancel</a>
</p>
</form>
{{- end}}

View File

@ -0,0 +1,36 @@
{{define "body" -}}
<h2>Delete Peer</h2>
{{with .Peer -}}
<form method="POST">
<input type="hidden" name="CSRF" value="{{$.Session.CSRF}}">
<p>
<label>Peer IP</label><br>
<input type="number" name="PeerIP" value="{{.PeerIP}}" disabled>
</p>
<p>
<label>Name</label><br>
<input type="text" value="{{.Name}}" disabled>
</p>
<p>
<label>IP</label><br>
<input type="text" value="{{ipToString .IP}}" disabled>
</p>
<p>
<label>Port</label><br>
<input type="number" value="{{.Port}}" disabled>
</p>
<p>
<label>
<input type="checkbox" {{if .Mediator}}checked{{end}} disabled>
Mediator
</label>
</p>
<p>
<button type="submit">Delete</button>
<a href="/admin/peer/view/?PeerIP={{.PeerIP}}">Cancel</a>
</p>
</form>
{{- end}}
{{- end}}

View File

@ -0,0 +1,36 @@
{{define "body" -}}
<h2>Edit Peer</h2>
{{with .Peer -}}
<form method="POST">
<input type="hidden" name="CSRF" value="{{$.Session.CSRF}}">
<p>
<label>Peer IP</label><br>
<input type="number" name="PeerIP" value="{{.PeerIP}}" disabled>
</p>
<p>
<label>Name</label><br>
<input type="text" name="Name" value="{{.Name}}">
</p>
<p>
<label>IP</label><br>
<input type="text" name="IP" value="{{ipToString .IP}}">
</p>
<p>
<label>Port</label><br>
<input type="number" name="Port" value="{{.Port}}">
</p>
<p>
<label>
<input type="checkbox" name="Mediator" {{if .Mediator}}checked{{end}}>
Mediator
</label>
</p>
<p>
<button type="submit">Save</button>
<a href="/admin/peer/view/?PeerIP={{.PeerIP}}">Cancel</a>
</p>
</form>
{{- end}}
{{- end}}

View File

@ -0,0 +1,13 @@
{{define "body" -}}
<h2>Create Peer</h2>
<p>
Configure the peer with the following URL:
</p>
<pre>
{{.HubAddress}}/peer/create/?Code={{.Code}}
</pre>
<p>
<a href="/admin/peer/list/">Done</a>
</p>
{{- end}}

View File

@ -0,0 +1,39 @@
{{define "body" -}}
<h2>Peers</h2>
<p>
<a href="/admin/peer/create/">Add Peer</a>
</p>
{{if .Peers -}}
<table>
<thead>
<tr>
<th>PeerIP</th>
<th>Name</th>
<th>IP</th>
<th>Port</th>
<th>Mediator</th>
</tr>
</thead>
<tbody>
{{range .Peers -}}
<tr>
<td>
<a href="/admin/peer/view/?PeerIP={{.PeerIP}}">
{{.PeerIP}}
</a>
</td>
<td>{{.Name}}</td>
<td>{{ipToString .IP}}</td>
<td>{{.Port}}</td>
<td>{{if .Mediator}}T{{else}}F{{end}}</td>
</tr>
</tbody>
{{- end}}
</table>
{{- else}}
<p>No peers.</p>
{{- end}}
{{- end}}

View File

@ -0,0 +1,20 @@
{{define "body" -}}
<h2>Peer</h2>
<p>
<a href="/admin/peer/edit/?PeerIP={{.Peer.PeerIP}}">Edit</a> /
<a href="/admin/peer/delete/?PeerIP={{.Peer.PeerIP}}">Delete</a>
</p>
{{with .Peer -}}
<table class="def-list">
<tr><td>Peer IP</td><td>{{.PeerIP}}</td></tr>
<tr><td>Name</td><td>{{.Name}}</td></tr>
<tr><td>IP</td><td>{{ipToString .IP}}</td></tr>
<tr><td>Port</td><td>{{.Port}}</td></tr>
<tr><td>Mediator</td><td>{{if .Mediator}}T{{else}}F{{end}}</td></tr>
<tr><td>API Key</td><td>{{.APIKey}}</td></tr>
</table>
{{- end}}
{{- end}}

View File

@ -0,0 +1,11 @@
{{define "body" -}}
<h2>Sign Out</h2>
<form method="POST">
<input type="hidden" name="CSRF" value="{{.Session.CSRF}}">
<p>
<button type="submit">Sign Out</button>
<a href="/admin/config/">Cancel</a>
</p>
</form>
{{- end}}

21
hub/templates/base.html Normal file
View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>VPPN Hub</title>
<link rel="stylesheet" href="/static/new.min.css">
<link rel="stylesheet" href="/static/custom.css">
</head>
<body>
<header>
<h1>VPPN</h1>
<nav>
{{if .Session.SignedIn -}}
<a href="/admin/config/">Config</a> /
<a href="/admin/peer/list/">Peers</a> /
<a href="/admin/sign-out/">Sign out</a>
{{- end}}
</nav>
</header>
{{block "body" .}}There's nothing here.{{end}}
</body>
</html>

View File

View File

@ -0,0 +1,14 @@
{{define "body" -}}
<h2>Sign In</h2>
<form method="POST">
<input type="hidden" name="CSRF" value="{{.Session.CSRF}}">
<p>
<label>Password</label><br>
<input type="password" name="Password">
</p>
<p>
<button type="submit">Submit</button>
</p>
</form>
{{- end}}

1
hub/time.go Normal file
View File

@ -0,0 +1 @@
package hub

59
hub/util.go Normal file
View File

@ -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 ""
}

39
m/models.go Normal file
View File

@ -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
}

32
peer/crypto.go Normal file
View File

@ -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[:]
}

129
peer/crypto_test.go Normal file
View File

@ -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")
}
}

82
peer/files.go Normal file
View File

@ -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)
}

16
peer/globals.go Normal file
View File

@ -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
)

86
peer/main.go Normal file
View File

@ -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.")
}

26
peer/nonce.go Normal file
View File

@ -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)
}

25
peer/nonce_test.go Normal file
View File

@ -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)
}
}

114
peer/peer-ifreader.go Normal file
View File

@ -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
}
}

158
peer/peer-netreader.go Normal file
View File

@ -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
}
}

67
peer/peer.go Normal file
View File

@ -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()
}

18
peer/router-ping.go Normal file
View File

@ -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
}

123
peer/router.go Normal file
View File

@ -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
}

156
peer/startup.go Normal file
View File

@ -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
}

22
peer/types.go Normal file
View File

@ -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.
}