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

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