aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLeonardo Bishop <me@leonardobishop.com>2025-02-06 15:22:34 +0000
committerLeonardo Bishop <me@leonardobishop.com>2025-02-06 15:22:34 +0000
commit2475f5a8b92ef0dd28e7af5f36d01b25243ed778 (patch)
tree12f8931d241db4159f8d30f7bf2b648709a94166
Initial commit
-rw-r--r--.gitignore25
-rw-r--r--api/handlers/index.go22
-rw-r--r--api/handlers/peer.go75
-rw-r--r--api/handlers/reverseProxy.go35
-rw-r--r--example.config.yaml6
-rw-r--r--go.mod23
-rw-r--r--go.sum39
-rw-r--r--main.go176
-rw-r--r--pkg/config/service.go99
-rw-r--r--pkg/store/service.go81
-rw-r--r--pkg/wireguard/service.go196
-rw-r--r--web/index.html59
-rw-r--r--web/web.go13
13 files changed, 849 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..fdf29bd
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,25 @@
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Dependency directories (remove the comment below to include it)
+# vendor/
+
+# Go workspace file
+go.work
+go.work.sum
+
+# env file
+.env
+
+gunnel
+config.yaml
diff --git a/api/handlers/index.go b/api/handlers/index.go
new file mode 100644
index 0000000..c165f3b
--- /dev/null
+++ b/api/handlers/index.go
@@ -0,0 +1,22 @@
+package handlers
+
+import (
+ "net/http"
+
+ "github.com/LMBishop/gunnel/pkg/config"
+ "github.com/LMBishop/gunnel/web"
+)
+
+func Index(configService config.Service) func(http.ResponseWriter, *http.Request) {
+ return func(w http.ResponseWriter, r *http.Request) {
+ web.Index().Execute(w, struct {
+ Host string
+ ExpireAfter int
+ Iface string
+ }{
+ Host: configService.Config().Hostname,
+ ExpireAfter: configService.Config().ExpireAfter,
+ Iface: configService.Config().WireGuard.InterfaceName,
+ })
+ }
+}
diff --git a/api/handlers/peer.go b/api/handlers/peer.go
new file mode 100644
index 0000000..51fa047
--- /dev/null
+++ b/api/handlers/peer.go
@@ -0,0 +1,75 @@
+package handlers
+
+import (
+ "fmt"
+ "log/slog"
+ "net/http"
+
+ "github.com/LMBishop/gunnel/pkg/config"
+ "github.com/LMBishop/gunnel/pkg/store"
+ "github.com/LMBishop/gunnel/pkg/wireguard"
+ "github.com/gorilla/mux"
+)
+
+const script = `#!/bin/bash
+
+# Your IP address: %s
+# Private key: %s
+# Unique slug: %s
+
+# Run this script as root to set up your client
+
+set -euo pipefail
+
+sudo ip link delete dev %s 2>/dev/null || true
+sudo ip link add %s type wireguard
+sudo ip addr add %s dev %s
+echo "%s" | sudo tee /tmp/tunnel-private > /dev/null
+sudo wg set %s private-key /tmp/tunnel-private
+sudo wg set %s peer %s allowed-ips %s endpoint %s:%s persistent-keepalive 21
+sudo ip link set up dev %s
+sudo ip route add %s dev %s
+
+echo "http://0.0.0.0:%s is now reachable at http://%s.%s"`
+
+func NewPeer(storeService store.Service, wireguardService wireguard.Service, configService config.Service) func(http.ResponseWriter, *http.Request) {
+ return func(w http.ResponseWriter, r *http.Request) {
+ params := mux.Vars(r)
+ port := params["port"]
+
+ peer, err := wireguardService.NewPeer()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ slug := storeService.GetUnusedSlug()
+
+ ipAddr := peer.IPAddr.String()
+
+ storeService.NewForwardingRule(slug, peer, port)
+
+ iface := configService.Config().WireGuard.InterfaceName
+ wireguardPort := configService.Config().WireGuard.Port
+ hostname := configService.Config().Hostname
+ network := configService.Config().WireGuard.Network
+ publicKey := wireguardService.PublicKey()
+
+ slog.Info("new peer", "peer", peer.PrivateKey)
+
+ fmt.Fprintf(w, script,
+ ipAddr,
+ peer.PrivateKey,
+ slug,
+ iface,
+ iface,
+ ipAddr, iface,
+ peer.PrivateKey,
+ iface,
+ iface, publicKey, network, hostname, wireguardPort,
+ iface,
+ network, iface,
+ port, slug, hostname,
+ )
+ }
+}
diff --git a/api/handlers/reverseProxy.go b/api/handlers/reverseProxy.go
new file mode 100644
index 0000000..4ac4c87
--- /dev/null
+++ b/api/handlers/reverseProxy.go
@@ -0,0 +1,35 @@
+package handlers
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/LMBishop/gunnel/pkg/store"
+)
+
+func ReverseProxy(storeService store.Service) func(http.ResponseWriter, *http.Request) {
+ return func(w http.ResponseWriter, r *http.Request) {
+ hostParts := strings.Split(r.Host, ".")
+
+ slug := hostParts[0]
+ rule := storeService.GetRuleBySlug(slug)
+ if rule == nil {
+ http.Error(w, fmt.Sprintf("Unknown peer '%s'", slug), http.StatusNotFound)
+ return
+ }
+
+ targetURL, err := url.Parse("http://" + rule.Peer.IPAddr.String() + ":" + rule.Port)
+ rule.LastUsed = time.Now()
+ if err != nil {
+ http.Error(w, "Invalid target URL", http.StatusInternalServerError)
+ return
+ }
+
+ proxy := httputil.NewSingleHostReverseProxy(targetURL)
+ proxy.ServeHTTP(w, r)
+ }
+}
diff --git a/example.config.yaml b/example.config.yaml
new file mode 100644
index 0000000..eaf2be7
--- /dev/null
+++ b/example.config.yaml
@@ -0,0 +1,6 @@
+host: "tun.example.com"
+wireGuard:
+ network: "10.69.0.0/16"
+ port: "4444"
+ interfaceName: "tunnel"
+expireAfter: 86400 \ No newline at end of file
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..4a935cb
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,23 @@
+module github.com/LMBishop/gunnel
+
+go 1.23.5
+
+require (
+ github.com/gabriel-vasile/mimetype v1.4.8 // indirect
+ github.com/go-co-op/gocron/v2 v2.15.0 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.24.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/gorilla/mux v1.8.1 // indirect
+ github.com/jonboulle/clockwork v0.4.0 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/robfig/cron/v3 v3.0.1 // indirect
+ github.com/tjarratt/babble v0.0.0-20210505082055-cbca2a4833c1 // indirect
+ golang.org/x/crypto v0.32.0 // indirect
+ golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
+ golang.org/x/net v0.34.0 // indirect
+ golang.org/x/sys v0.30.0 // indirect
+ golang.org/x/text v0.21.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..7604e55
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,39 @@
+github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
+github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
+github.com/go-co-op/gocron/v2 v2.15.0 h1:Kpvo71VSihE+RImmpA+3ta5CcMhoRzMGw4dJawrj4zo=
+github.com/go-co-op/gocron/v2 v2.15.0/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLcattWEFsqpKig=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg=
+github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
+github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
+github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
+github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
+github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
+github.com/tjarratt/babble v0.0.0-20210505082055-cbca2a4833c1 h1:j8whCiEmvLCXI3scVn+YnklCU8mwJ9ZJ4/DGAKqQbRE=
+github.com/tjarratt/babble v0.0.0-20210505082055-cbca2a4833c1/go.mod h1:O5hBrCGqzfb+8WyY8ico2AyQau7XQwAfEQeEQ5/5V9E=
+github.com/wordgen/wordgen v0.4.0 h1:auxV46r9QSW2rvidzW1iBjA+ftl3owEHZrP/1GTRAus=
+github.com/wordgen/wordgen v0.4.0/go.mod h1:x90ETdoxrm7I7qhVxT8QJHoOgEv66YDen1fUadBNHiI=
+golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
+golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
+golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
+golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
+golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
+golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..c31f56d
--- /dev/null
+++ b/main.go
@@ -0,0 +1,176 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/signal"
+ "os/user"
+ "time"
+
+ "github.com/LMBishop/gunnel/api/handlers"
+ "github.com/LMBishop/gunnel/pkg/config"
+ "github.com/LMBishop/gunnel/pkg/store"
+ "github.com/LMBishop/gunnel/pkg/wireguard"
+ "github.com/go-co-op/gocron/v2"
+ "github.com/gorilla/mux"
+)
+
+func main() {
+ u, err := user.Current()
+ if err != nil {
+ slog.Warn("cannot verify user is root", "error", err)
+ } else if u.Uid != "0" {
+ slog.Error("this program must be run as root to manage WireGuard")
+ os.Exit(1)
+ }
+
+ _, err = os.Stat("/usr/share/dict/words")
+ if err != nil {
+ slog.Error("could not find dictionary file at /usr/share/dict/words (you need to install a wordlist first)", "error", err)
+ os.Exit(1)
+ }
+
+ if err := run(); err != nil {
+ slog.Error("Unhandled error", "error", err)
+ os.Exit(1)
+ }
+}
+
+func run() error {
+ configService := config.NewService()
+ err := configService.InitialiseConfig("/etc/gunnel/config.yaml", "config.yaml")
+ if err != nil {
+ return fmt.Errorf("failed to load config: %w", err)
+ }
+
+ wireguardService := wireguard.NewService()
+ storeService := store.NewService()
+
+ c := configService.Config()
+
+ public, err := wireguardService.Up(c.WireGuard.InterfaceName, c.WireGuard.Network, c.WireGuard.Port)
+ if err != nil {
+ return fmt.Errorf("could not bring WireGuard interface up: %w", err)
+ }
+ slog.Info("interface up", "interface", c.WireGuard.InterfaceName, "publickey", public)
+
+ r := mux.NewRouter()
+ r.Host(c.Hostname).PathPrefix("/{port:[0-9]+}").HandlerFunc(handlers.NewPeer(storeService, wireguardService, configService))
+ r.Host(c.Hostname).Path("/").HandlerFunc(handlers.Index(configService))
+ r.Host(fmt.Sprintf("{subdomain}.%s", c.Hostname)).HandlerFunc(handlers.ReverseProxy(storeService))
+
+ srv := make([]*http.Server, 1)
+ if c.TLS.Enabled {
+ srv[0] = startHttpsServer(r, c.TLS.Cert, c.TLS.Key)
+ srv = append(srv, startHttpRedirect())
+ } else {
+ srv[0] = startHttpServer(r)
+ }
+
+ slog.Info("server started", "hostname", c.Hostname, "tls", c.TLS.Enabled)
+
+ s, err := gocron.NewScheduler()
+ if err != nil {
+ return fmt.Errorf("could not create scheduler: %w", err)
+ }
+
+ // todo fix (and move to service)
+ _, err = s.NewJob(gocron.CronJob("0 * * * *", false), gocron.NewTask(func() {
+ unusedRules := storeService.GetUnusedRulesSince(time.Now().Add(-time.Duration(c.ExpireAfter)))
+
+ if len(unusedRules) == 0 {
+ return
+ }
+
+ slog.Info("removing unused tunnels", "count", len(unusedRules))
+
+ for _, rule := range unusedRules {
+ wireguardService.RemovePeer(rule.Peer)
+ storeService.RemoveForwardingRule(rule.Slug)
+ }
+ }),
+ )
+
+ channel := make(chan os.Signal, 1)
+ signal.Notify(channel, os.Interrupt)
+
+ <-channel
+
+ err = s.Shutdown()
+ if err != nil {
+ slog.Error("scheduler shutdown", "error", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second*15)
+ defer cancel()
+ for _, s := range srv {
+ if err := s.Shutdown(ctx); err != nil {
+ slog.Error("server shutdown", "error", err)
+ }
+ }
+
+ err = wireguardService.Down()
+ if err != nil {
+ return fmt.Errorf("could not bring WireGuard interface down %w", err)
+ }
+
+ slog.Info("interface down", "interface", c.WireGuard.InterfaceName)
+
+ return nil
+}
+
+func startHttpServer(router *mux.Router) *http.Server {
+ srv := &http.Server{
+ Handler: router,
+ Addr: ":80",
+ WriteTimeout: 15 * time.Second,
+ ReadTimeout: 15 * time.Second,
+ }
+
+ go func() {
+ if err := srv.ListenAndServe(); err != nil {
+ slog.Error("http server", "error", err)
+ }
+ }()
+
+ return srv
+}
+
+func startHttpsServer(router *mux.Router, cert string, key string) *http.Server {
+ srv := &http.Server{
+ Handler: router,
+ Addr: ":443",
+ WriteTimeout: 15 * time.Second,
+ ReadTimeout: 15 * time.Second,
+ }
+
+ go func() {
+ if err := srv.ListenAndServeTLS(cert, key); err != nil {
+ slog.Error("https server", "error", err)
+ }
+ }()
+
+ return srv
+}
+
+func startHttpRedirect() *http.Server {
+ srv := &http.Server{
+ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "https://"+r.Host+r.URL.String(), http.StatusMovedPermanently)
+ }),
+ Addr: ":80",
+ WriteTimeout: 15 * time.Second,
+ ReadTimeout: 15 * time.Second,
+ }
+
+ go func() {
+ if err := srv.ListenAndServe(); err != nil {
+ slog.Error("http redirect server", "error", err)
+ }
+ }()
+
+ return srv
+}
diff --git a/pkg/config/service.go b/pkg/config/service.go
new file mode 100644
index 0000000..3c9a27e
--- /dev/null
+++ b/pkg/config/service.go
@@ -0,0 +1,99 @@
+package config
+
+import (
+ "fmt"
+ "net"
+ "os"
+ "regexp"
+
+ validate "github.com/go-playground/validator/v10"
+ "gopkg.in/yaml.v3"
+)
+
+type Config struct {
+ Hostname string `yaml:"host" validate:"required"`
+ TLS struct {
+ Enabled bool `yaml:"enabled"`
+ Cert string `yaml:"cert"`
+ Key string `yaml:"key"`
+ } `yaml:"tls"`
+ WireGuard struct {
+ Network string `yaml:"network" validate:"cidr,required"`
+ Port string `yaml:"port" validate:"required"`
+ InterfaceName string `yaml:"interfaceName" validate:"required"`
+ } `yaml:"wireGuard"`
+ ExpireAfter int `yaml:"expireAfter"`
+}
+
+type Service interface {
+ InitialiseConfig(paths ...string) error
+ Config() *Config
+}
+
+type service struct {
+ config *Config
+ validator *validate.Validate
+}
+
+const InterfaceRegex = "^[a-zA-Z0-9_=+.-]{1,15}$"
+
+func NewService() Service {
+ return &service{
+ validator: validate.New(validate.WithRequiredStructEnabled()),
+ }
+}
+
+func (s *service) InitialiseConfig(paths ...string) error {
+ for _, p := range paths {
+ if _, err := os.Stat(p); err != nil {
+ continue
+ }
+ c := &Config{}
+ err := readConfig(p, c)
+ if err != nil {
+ return err
+ }
+ s.config = c
+ break
+ }
+ return nil
+}
+
+func (s *service) Config() *Config {
+ return s.config
+}
+
+func readConfig(configPath string, dst *Config) error {
+ config, err := os.ReadFile(configPath)
+ if err != nil {
+ return err
+ }
+
+ if err := yaml.Unmarshal(config, dst); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (s *service) validateConfig(c *Config) error {
+ if err := s.validator.Struct(c); err != nil {
+ return err
+ }
+
+ match, _ := regexp.MatchString(InterfaceRegex, c.WireGuard.InterfaceName)
+ if !match {
+ return fmt.Errorf("invalid interface name: %s", c.WireGuard.InterfaceName)
+ }
+
+ ifaces, err := net.Interfaces()
+ if err != nil {
+ return fmt.Errorf("could not list network interfaces: %w", err)
+ }
+ for _, i := range ifaces {
+ if i.Name == c.WireGuard.InterfaceName {
+ return fmt.Errorf("an interface already exists with the name '%s'", i.Name)
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/store/service.go b/pkg/store/service.go
new file mode 100644
index 0000000..3a042fb
--- /dev/null
+++ b/pkg/store/service.go
@@ -0,0 +1,81 @@
+package store
+
+import (
+ "strings"
+ "time"
+
+ "github.com/LMBishop/gunnel/pkg/wireguard"
+ "github.com/tjarratt/babble"
+)
+
+type ForwardingRule struct {
+ Slug string
+ Peer *wireguard.Peer
+ Port string
+ LastUsed time.Time
+}
+
+type Service interface {
+ GetRuleBySlug(slug string) *ForwardingRule
+ NewForwardingRule(slug string, peer *wireguard.Peer, port string) *ForwardingRule
+ RemoveForwardingRule(slug string)
+ GetUnusedSlug() string
+ GetUnusedRulesSince(since time.Time) []*ForwardingRule
+}
+
+type service struct {
+ forwardingRules map[string]*ForwardingRule
+}
+
+func NewService() Service {
+ return &service{
+ forwardingRules: make(map[string]*ForwardingRule),
+ }
+}
+
+func (s *service) GetRuleBySlug(slug string) *ForwardingRule {
+ return s.forwardingRules[slug]
+}
+
+func (s *service) NewForwardingRule(slug string, peer *wireguard.Peer, port string) *ForwardingRule {
+ if s.forwardingRules[slug] != nil {
+ return nil
+ }
+
+ rule := &ForwardingRule{
+ Slug: slug,
+ Peer: peer,
+ Port: port,
+ }
+ s.forwardingRules[slug] = rule
+ return rule
+}
+
+func (s *service) GetUnusedSlug() string {
+ b := babble.NewBabbler()
+ b.Count = 3
+ b.Separator = "-"
+
+ for i := 0; i < 10; i++ {
+ slug := strings.Replace(strings.ToLower(b.Babble()), "'", "", -1)
+ if s.forwardingRules[slug] == nil {
+ return slug
+ }
+ }
+
+ return ""
+}
+
+func (s *service) GetUnusedRulesSince(since time.Time) []*ForwardingRule {
+ var rules []*ForwardingRule
+ for _, rule := range s.forwardingRules {
+ if rule.LastUsed.Before(since) {
+ rules = append(rules, rule)
+ }
+ }
+ return rules
+}
+
+func (s *service) RemoveForwardingRule(slug string) {
+ delete(s.forwardingRules, slug)
+}
diff --git a/pkg/wireguard/service.go b/pkg/wireguard/service.go
new file mode 100644
index 0000000..a0b67b7
--- /dev/null
+++ b/pkg/wireguard/service.go
@@ -0,0 +1,196 @@
+package wireguard
+
+import (
+ "bytes"
+ "encoding/binary"
+ "fmt"
+ "net"
+ "os/exec"
+ "strings"
+
+ "golang.org/x/sys/unix"
+)
+
+type Peer struct {
+ IPAddr net.IP
+ PublicKey string
+ PrivateKey string
+}
+
+type Service interface {
+ // GenerateKey() (string, error)
+ Up(iface string, network string, listenPort string) (string, error)
+ Down() error
+ NewPeer() (*Peer, error)
+ RemovePeer(peer *Peer) error
+ PublicKey() string
+}
+
+type service struct {
+ ipNet *net.IPNet
+ startIP uint32
+ endIP uint32
+ nextIP uint32
+
+ iface string
+ privateKey string
+ publicKey string
+}
+
+func NewService() Service {
+ return &service{}
+}
+
+func (s *service) Up(iface string, network string, listenPort string) (string, error) {
+ _, ipNet, err := net.ParseCIDR(network)
+ if err != nil {
+ return "", fmt.Errorf("cannot parse CIDR: %w", err)
+ }
+
+ s.ipNet = ipNet
+ mask := binary.BigEndian.Uint32(ipNet.Mask)
+ s.startIP = binary.BigEndian.Uint32(ipNet.IP)
+ s.endIP = (s.startIP & mask) | (mask ^ 0xffffffff)
+ s.nextIP = s.startIP
+
+ private, err := s.generateKey()
+ if err != nil {
+ return "", fmt.Errorf("cannot generate private key: %w", err)
+ }
+ public, err := s.getPublicKey(private)
+ if err != nil {
+ return "", fmt.Errorf("cannot get public key: %w", err)
+ }
+
+ fd, err := memfile("wg", []byte(private))
+
+ addInterface := fmt.Sprintf("ip link add dev %s type wireguard", iface)
+ addAddress := fmt.Sprintf("ip addr add %s dev %s", network, iface)
+ setPrivateKey := fmt.Sprintf("wg set %s private-key /dev/fd/%d listen-port %s", iface, fd, listenPort)
+ ifaceUp := fmt.Sprintf("ip link set %s up", iface)
+
+ cmd := exec.Command("bash", "-c", fmt.Sprintf("%s; %s; %s; %s", addInterface, addAddress, setPrivateKey, ifaceUp))
+ _, err = cmd.Output()
+ if err != nil {
+ return "", fmt.Errorf("cannot bring WireGuard interface up: %w", err)
+ }
+
+ s.iface = iface
+ s.privateKey = private
+ s.publicKey = public
+
+ return public, nil
+}
+
+func (s *service) Down() error {
+ cmd := exec.Command("bash", "-c", fmt.Sprintf("ip link delete dev %s", s.iface))
+ _, err := cmd.Output()
+ if err != nil {
+ return fmt.Errorf("cannot bring WireGuard interface down: %w", err)
+ }
+ return nil
+}
+
+func (s *service) NewPeer() (*Peer, error) {
+ private, err := s.generateKey()
+ if err != nil {
+ return nil, fmt.Errorf("cannot generate private key: %w", err)
+ }
+ public, err := s.getPublicKey(private)
+ if err != nil {
+ return nil, fmt.Errorf("cannot get public key: %w", err)
+ }
+
+ ipAddress, err := s.getNextIP()
+ if err != nil {
+ return nil, fmt.Errorf("could not assign new IP: %w", err)
+ }
+
+ cmd := exec.Command("bash", "-c", fmt.Sprintf("wg set %s peer %s allowed-ips %s/32", s.iface, public, ipAddress.String()))
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return nil, fmt.Errorf("cannot add peer: %s: %w", string(output), err)
+ }
+
+ return &Peer{
+ IPAddr: ipAddress,
+ PrivateKey: private,
+ PublicKey: public,
+ }, nil
+}
+
+func (s *service) RemovePeer(peer *Peer) error {
+ cmd := exec.Command("bash", "-c", fmt.Sprintf("wg set %s peer %s remove", s.iface, peer.PublicKey))
+ _, err := cmd.Output()
+ if err != nil {
+ return fmt.Errorf("cannot remove peer: %w", err)
+ }
+ return nil
+}
+
+func (s *service) PublicKey() string {
+ return s.publicKey
+}
+
+func (s *service) getNextIP() (net.IP, error) {
+ for {
+ if s.nextIP == s.endIP {
+ return net.IP{}, fmt.Errorf("no more IP addresses remaining")
+ }
+
+ ip := make(net.IP, 4)
+ binary.BigEndian.PutUint32(ip, s.nextIP)
+
+ if ip[3] != 0 && ip[3] != 255 {
+ s.nextIP++
+ return ip, nil
+ }
+
+ s.nextIP++
+ }
+}
+
+func (s *service) generateKey() (string, error) {
+ cmd := exec.Command("wg", "genkey")
+ stdout, err := cmd.Output()
+ if err != nil {
+ return "", err
+ }
+ return strings.Replace(string(stdout[:]), "\n", "", -1), nil
+}
+
+func (s *service) getPublicKey(private string) (string, error) {
+ cmd := exec.Command("wg", "pubkey")
+ cmd.Stdin = bytes.NewBufferString(private)
+ stdout, err := cmd.Output()
+ if err != nil {
+ return "", err
+ }
+ return strings.Replace(string(stdout[:]), "\n", "", -1), nil
+}
+
+func memfile(name string, b []byte) (int, error) {
+ fd, err := unix.MemfdCreate(name, 0)
+ if err != nil {
+ return 0, fmt.Errorf("MemfdCreate: %v", err)
+ }
+
+ err = unix.Ftruncate(fd, int64(len(b)))
+ if err != nil {
+ return 0, fmt.Errorf("Ftruncate: %v", err)
+ }
+
+ data, err := unix.Mmap(fd, 0, len(b), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
+ if err != nil {
+ return 0, fmt.Errorf("Mmap: %v", err)
+ }
+
+ copy(data, b)
+
+ err = unix.Munmap(data)
+ if err != nil {
+ return 0, fmt.Errorf("Munmap: %v", err)
+ }
+
+ return fd, nil
+}
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..cdb46ed
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,59 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <title>{{.Host}} tunneling service</title>
+
+ <style>
+ dt {
+ font-weight: bold;
+ }
+
+ dd {
+ margin-bottom: 1em;
+ }
+ </style>
+</head>
+
+<body>
+ <h1>{{.Host}} tunneling service</h1>
+ <p>
+ This service uses WireGuard to expose your local server to the Internet.
+ (No custom client needed!)
+ </p>
+
+ <h2>Usage</h2>
+
+ <p>
+ In your terminal:
+ </p>
+
+ <pre>
+curl -sSL http://{{.Host}}/[PORT YOU WANT TO FORWARD] | sh
+ </pre>
+
+ <p>
+ For example, to forward port 8080:
+ </p>
+
+ <pre>
+curl -sSL http://{{.Host}}/8080 | sh
+ </pre>
+
+ <p>
+ You will receive a URL that you can use to access your server from anywhere.
+ </p>
+
+ <h2>Policy</h2>
+
+ <dl>
+ <dt>Interface name: {{.Iface}}</dt>
+ <dd>The automated set up script will overwrite any existing interface with this name with its own.</dd>
+
+ <dt>Inactivity time: {{.ExpireAfter}} seconds</dt>
+ <dd>Tunnels will expire after this amount of time. (You will need to re-create a new one.)</dd>
+ </dl>
+
+</body>
+
+</html> \ No newline at end of file
diff --git a/web/web.go b/web/web.go
new file mode 100644
index 0000000..eb955fe
--- /dev/null
+++ b/web/web.go
@@ -0,0 +1,13 @@
+package web
+
+import (
+ "embed"
+ "html/template"
+)
+
+//go:embed *
+var files embed.FS
+
+func Index() *template.Template {
+ return template.Must(template.ParseFS(files, "index.html"))
+}