From 2475f5a8b92ef0dd28e7af5f36d01b25243ed778 Mon Sep 17 00:00:00 2001 From: Leonardo Bishop Date: Thu, 6 Feb 2025 15:22:34 +0000 Subject: Initial commit --- .gitignore | 25 ++++++ api/handlers/index.go | 22 +++++ api/handlers/peer.go | 75 +++++++++++++++++ api/handlers/reverseProxy.go | 35 ++++++++ example.config.yaml | 6 ++ go.mod | 23 +++++ go.sum | 39 +++++++++ main.go | 176 ++++++++++++++++++++++++++++++++++++++ pkg/config/service.go | 99 ++++++++++++++++++++++ pkg/store/service.go | 81 ++++++++++++++++++ pkg/wireguard/service.go | 196 +++++++++++++++++++++++++++++++++++++++++++ web/index.html | 59 +++++++++++++ web/web.go | 13 +++ 13 files changed, 849 insertions(+) create mode 100644 .gitignore create mode 100644 api/handlers/index.go create mode 100644 api/handlers/peer.go create mode 100644 api/handlers/reverseProxy.go create mode 100644 example.config.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 pkg/config/service.go create mode 100644 pkg/store/service.go create mode 100644 pkg/wireguard/service.go create mode 100644 web/index.html create mode 100644 web/web.go 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 @@ + + + + + {{.Host}} tunneling service + + + + + +

{{.Host}} tunneling service

+

+ This service uses WireGuard to expose your local server to the Internet. + (No custom client needed!) +

+ +

Usage

+ +

+ In your terminal: +

+ +
+curl -sSL http://{{.Host}}/[PORT YOU WANT TO FORWARD] | sh
+    
+ +

+ For example, to forward port 8080: +

+ +
+curl -sSL http://{{.Host}}/8080 | sh
+    
+ +

+ You will receive a URL that you can use to access your server from anywhere. +

+ +

Policy

+ +
+
Interface name: {{.Iface}}
+
The automated set up script will overwrite any existing interface with this name with its own.
+ +
Inactivity time: {{.ExpireAfter}} seconds
+
Tunnels will expire after this amount of time. (You will need to re-create a new one.)
+
+ + + + \ 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")) +} -- cgit v1.2.3-70-g09d2