浏览代码

first commit

滔哥 4 月之前
当前提交
27dc83b245

+ 18 - 0
Dockerfile

@@ -0,0 +1,18 @@
+FROM alpine:latest
+
+LABEL org.opencontainers.image.title="LTWG" \
+      org.opencontainers.image.version="v2.0" \
+      org.opencontainers.image.description="Mini WireGuard server" \
+      org.opencontainers.image.licenses="MIT" \
+      org.opencontainers.image.source="https://git.lvtao.net/memory/ltwg.git"
+
+COPY LTWG-linux-amd64 /LTWG/ltwg
+
+# Install Linux packages
+RUN apk add --no-cache --purge --clean-protected dumb-init iptables tzdata && rm -rf /var/cache/apk/*
+
+EXPOSE 52017/udp
+EXPOSE 52016/tcp
+
+WORKDIR /LTWG
+CMD ["/usr/bin/dumb-init", "./ltwg"]

+ 76 - 0
README.md

@@ -0,0 +1,76 @@
+## :dart: 特色
+
+ :green_circle: 提供简单、易用的web管理后台
+
+ :purple_circle: 支持所有 WireGuard 客户端接入
+
+ :yellow_circle: 无需系统安装 WireGuard 组件
+
+ :orange_circle: 单文件、无额外库依赖
+
+ :red_circle: 自动申请免费SSL证书
+
+
+
+
+
+- ### 服务端安装
+
+选择对应的服务端,如x86环境请下载LTWG-linux-amd64
+
+添加可执行权限:
+
+```bash
+chmod +x ./LTWG-linux-amd64
+```
+
+前台运行:
+
+```bash
+./LTWG-linux-amd64
+```
+
+后台运行:
+
+```bash
+nohup ./LTWG-linux-amd64 >/dev/null 2>&1 &
+```
+
+容器中运行:下载docker-compose.yml文件然后执行
+
+```bash
+docker compose up -d
+```
+
+访问 http://ip:52016 登录管理后台,默认密码LTWG
+
+> :biohazard: ***如果服务器使用的是各种云服务,记得在云服务管理后台上开放所需的udp端口52017、tcp端口52016***
+
+
+
+- ### 服务端配置
+
+
+首次运行LTWG会在软件目录生成conf/config.json配置文件,配置说明如下:
+
+```json
+{
+ "version": "1",              // 当前版本
+ "host": "7.7.7.7",           // web管理后台ip或域名
+ "port": 52016,               // web管理后台端口
+ "auto_ssl": false,           // web管理后台是否启用自动获取Let's Encrypt签发证书,若启用请将端口改为443
+ "password": "LTWG",       // web管理后台登录认证密码
+ "lang": "en",                // web管理后台多语言支持,中文请将en改为cn
+ "ui_traffic_stats": true,    // web管理后台是否开启流量图特效
+ "ui_chart_type": 2,          // web管理后台流量特效图类型
+ "log_level": "error",        // 服务端日志记录等级
+ "wg_private_key": "YBw5KAo1vM2mz35GLhZB01ZNYWJYWdGZNQT1MebuCHk=",  // 服务端 WireGuard 私钥
+ "wg_device": "eth0",                   // 服务端 WireGuard 出入流量网卡名称
+ "wg_port": 52017,                      // 服务端 WireGuard UDP端口
+ "wg_mtu": 1280,                        // 服务端 WireGuard MTU值
+ "wg_persistent_keepalive": 25,         // 客户端存活包发送间隔时间
+ "wg_address": "198.18.0.1/16",         // 服务端ip和网段范围
+ "wg_dns": "1.1.1.1",                   // 客户端dns配置
+ "wg_allowed_ips": "0.0.0.0/0, ::/0"    // 客户端流量要转发到服务端的ip地址范围
+}
+```

+ 22 - 0
docker-compose.yml

@@ -0,0 +1,22 @@
+version: '3'
+services:
+  LTWG:
+    image: "ccr.ccs.tencentyun.com/eedba/ltwg:latest"
+    container_name: LTWG-server
+    devices:
+      - /dev/net/tun
+    network_mode: host
+    volumes:
+      - ./lib/modules:/lib/modules
+      - ./etc/LTWG:/LTWG/conf
+    cap_add:
+      - NET_ADMIN
+      - SYS_MODULE
+    restart: unless-stopped
+    environment:
+      #- TZ=Asia/Shanghai
+      #- LTWG_DEVICE=eth0
+      - LTWG_PASSWORD=ltwg0106
+      #- LTWG_AUTO_SSL=false
+      #- LTWG_PORT=52016
+  

+ 43 - 0
src/go.mod

@@ -0,0 +1,43 @@
+module fahi
+
+go 1.21
+
+require (
+	github.com/boombuler/barcode v1.0.1
+	github.com/coreos/go-iptables v0.7.0
+	github.com/google/uuid v1.6.0
+	github.com/gorilla/sessions v1.2.2
+	github.com/json-iterator/go v1.1.12
+	github.com/labstack/echo-contrib v0.15.0
+	github.com/labstack/echo/v4 v4.11.4
+	github.com/sirupsen/logrus v1.9.3
+	github.com/vishvananda/netlink v1.1.0
+	golang.org/x/crypto v0.17.0
+	golang.org/x/sys v0.15.0
+	golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
+	golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
+)
+
+require (
+	github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
+	github.com/google/go-cmp v0.5.9 // indirect
+	github.com/gorilla/context v1.1.1 // indirect
+	github.com/gorilla/securecookie v1.1.2 // indirect
+	github.com/josharian/native v1.1.0 // indirect
+	github.com/labstack/gommon v0.4.2 // indirect
+	github.com/mattn/go-colorable v0.1.13 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/mdlayher/genetlink v1.3.2 // indirect
+	github.com/mdlayher/netlink v1.7.2 // indirect
+	github.com/mdlayher/socket v0.4.1 // indirect
+	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+	github.com/modern-go/reflect2 v1.0.2 // indirect
+	github.com/valyala/bytebufferpool v1.0.0 // indirect
+	github.com/valyala/fasttemplate v1.2.2 // indirect
+	github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df // indirect
+	golang.org/x/net v0.19.0 // indirect
+	golang.org/x/sync v0.1.0 // indirect
+	golang.org/x/text v0.14.0 // indirect
+	golang.org/x/time v0.5.0 // indirect
+	golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
+)

+ 56 - 0
src/main.go

@@ -0,0 +1,56 @@
+package main
+
+import (
+	"fahi/pkg/config"
+	"fahi/pkg/web"
+	"fahi/pkg/wg"
+	"os"
+	"os/signal"
+	"syscall"
+
+	log "github.com/sirupsen/logrus"
+)
+
+func main() {
+	if os.Geteuid() != 0 {
+		log.Fatal("please run LTWG as root")
+	}
+	cfg, err := config.LoadOrCreate()
+	if err != nil {
+		log.Fatalf("failed to load or create config: %v", err)
+	}
+
+	logLevel, err := log.ParseLevel(cfg.LogLevel)
+	if err != nil {
+		log.Fatalf("failed to parse log level: %v", err)
+	}
+	log.SetLevel(logLevel)
+
+	wgIface, err := wg.New(cfg)
+	if err != nil {
+		log.Fatalf("failed to init wireguard: %v", err)
+	}
+	defer wgIface.Close()
+
+	err = wgIface.Create()
+	if err != nil {
+		log.Errorf("failed to create wireguard: %v", err)
+		return
+	}
+
+	termCh := make(chan os.Signal, 1)
+	signal.Notify(termCh, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGTSTP)
+	go func() {
+		select {
+		case <-termCh:
+		}
+		log.Info("shutdown signal received")
+		wgIface.Close()
+		os.Exit(1)
+	}()
+
+	err = web.Serve(cfg, wgIface)
+	if err != nil {
+		log.Errorf("failed to web server: %v", err)
+	}
+}

+ 117 - 0
src/pkg/config/config.go

@@ -0,0 +1,117 @@
+package config
+
+import (
+	"fahi/pkg/util"
+	"net"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+)
+
+type Config struct {
+	Version               string `json:"version"`
+	Host                  string `json:"host"`
+	Port                  int    `json:"port"`
+	AutoSSL               bool   `json:"auto_ssl"`
+	Password              string `json:"password"`
+	Lang                  string `json:"lang"`
+	LogLevel              string `json:"log_level"`
+	WgPrivateKey          string `json:"wg_private_key"`
+	WgDevice              string `json:"wg_device"`
+	WgPort                int    `json:"wg_port"`
+	WgMTU                 int    `json:"wg_mtu"`
+	WgPersistentKeepalive int    `json:"wg_persistent_keepalive"`
+	WgAddress             string `json:"wg_address"`
+	WgDNS                 string `json:"wg_dns"`
+	WgAllowedIPs          string `json:"wg_allowed_ips"`
+}
+
+func LoadOrCreate() (*Config, error) {
+	var cfg Config
+
+	cfgPath := util.RootDir + "conf/config.json"
+	data, err := os.ReadFile(cfgPath)
+	if err != nil {
+		wgDevice := "eth0"
+		r, err := util.NewRouter()
+		if err == nil {
+			iface, _, _, err := r.Route(net.IPv4(0, 0, 0, 0))
+			if err == nil {
+				wgDevice = iface.Name
+			}
+		}
+
+		host := ""
+		ip, err := util.GetExternalIP(7 * time.Second)
+		if err == nil {
+			host = ip.String()
+		}
+
+		wgDeviceEnv := os.Getenv("LTWG_DEVICE")
+		if wgDeviceEnv != "" {
+			wgDevice = wgDeviceEnv
+		}
+
+		password := os.Getenv("LTWG_PASSWORD")
+		if password == "" {
+			password = "ltwg0106"
+		}
+
+		port, err := strconv.Atoi(os.Getenv("LTWG_PORT"))
+		if err != nil {
+			port = 52016
+		}
+
+		autoSSL := false
+		autoSslEnv := strings.ToLower(os.Getenv("LTWG_AUTO_SSL"))
+		if autoSslEnv == "true" {
+			autoSSL = true
+		}
+
+		cfg = Config{
+			Version:               "2",
+			Host:                  host,
+			Port:                  port,
+			AutoSSL:               autoSSL,
+			Lang:                  "cn",
+			LogLevel:              "error",
+			Password:              password,
+			WgPrivateKey:          util.GeneratePrivateKey(),
+			WgDevice:              wgDevice,
+			WgPort:                52017,
+			WgMTU:                 1280,
+			WgPersistentKeepalive: 25,
+			WgAddress:             "198.18.0.1/16",
+			WgDNS:                 "1.1.1.1",
+			WgAllowedIPs:          "0.0.0.0/0, ::/0",
+		}
+
+		err = Save(&cfg)
+		if err != nil {
+			return nil, err
+		}
+
+		return &cfg, nil
+	}
+
+	err = util.Json.Unmarshal(data, &cfg)
+	if err != nil {
+		return nil, err
+	}
+	return &cfg, nil
+}
+
+func Save(cfg *Config) error {
+	data, err := util.Json.MarshalIndent(cfg, "", " ")
+	if err != nil {
+		return err
+	}
+
+	path := util.RootDir + "conf"
+	if _, err = os.Stat(path); os.IsNotExist(err) {
+		os.Mkdir(path, 0751)
+	}
+
+	return os.WriteFile(path+"/config.json", data, 0600)
+}

+ 234 - 0
src/pkg/util/route.go

@@ -0,0 +1,234 @@
+package util
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"net"
+	"sort"
+	"strings"
+	"syscall"
+	"unsafe"
+)
+
+type routeInfoInMemory struct {
+	Family byte
+	DstLen byte
+	SrcLen byte
+	TOS    byte
+
+	Table    byte
+	Protocol byte
+	Scope    byte
+	Type     byte
+
+	Flags uint32
+}
+
+// rtInfo contains information on a single route.
+type rtInfo struct {
+	Src, Dst         *net.IPNet
+	Gateway, PrefSrc net.IP
+	// We currently ignore the InputIface.
+	InputIface, OutputIface uint32
+	Priority                uint32
+}
+
+// routeSlice implements sort.Interface to sort routes by Priority.
+type routeSlice []*rtInfo
+
+func (r routeSlice) Len() int {
+	return len(r)
+}
+func (r routeSlice) Less(i, j int) bool {
+	return r[i].Priority < r[j].Priority
+}
+func (r routeSlice) Swap(i, j int) {
+	r[i], r[j] = r[j], r[i]
+}
+
+type ipAddrs struct {
+	v4, v6 net.IP
+}
+
+type Router struct {
+	ifaces map[int]net.Interface
+	addrs  map[int]ipAddrs
+	v4, v6 routeSlice
+}
+
+func NewRouter() (*Router, error) {
+	rtr := &Router{}
+	rtr.ifaces = make(map[int]net.Interface)
+	rtr.addrs = make(map[int]ipAddrs)
+	tab, err := syscall.NetlinkRIB(syscall.RTM_GETROUTE, syscall.AF_UNSPEC)
+	if err != nil {
+		return nil, err
+	}
+	msgs, err := syscall.ParseNetlinkMessage(tab)
+	if err != nil {
+		return nil, err
+	}
+loop:
+	for _, m := range msgs {
+		switch m.Header.Type {
+		case syscall.NLMSG_DONE:
+			break loop
+		case syscall.RTM_NEWROUTE:
+			rt := (*routeInfoInMemory)(unsafe.Pointer(&m.Data[0]))
+			routeInfo := rtInfo{}
+			attrs, err := syscall.ParseNetlinkRouteAttr(&m)
+			if err != nil {
+				return nil, err
+			}
+			if rt.Family != syscall.AF_INET && rt.Family != syscall.AF_INET6 {
+				continue loop
+			}
+			for _, attr := range attrs {
+				switch attr.Attr.Type {
+				case syscall.RTA_DST:
+					routeInfo.Dst = &net.IPNet{
+						IP:   net.IP(attr.Value),
+						Mask: net.CIDRMask(int(rt.DstLen), len(attr.Value)*8),
+					}
+				case syscall.RTA_SRC:
+					routeInfo.Src = &net.IPNet{
+						IP:   net.IP(attr.Value),
+						Mask: net.CIDRMask(int(rt.SrcLen), len(attr.Value)*8),
+					}
+				case syscall.RTA_GATEWAY:
+					routeInfo.Gateway = net.IP(attr.Value)
+				case syscall.RTA_PREFSRC:
+					routeInfo.PrefSrc = net.IP(attr.Value)
+				case syscall.RTA_IIF:
+					routeInfo.InputIface = *(*uint32)(unsafe.Pointer(&attr.Value[0]))
+				case syscall.RTA_OIF:
+					routeInfo.OutputIface = *(*uint32)(unsafe.Pointer(&attr.Value[0]))
+				case syscall.RTA_PRIORITY:
+					routeInfo.Priority = *(*uint32)(unsafe.Pointer(&attr.Value[0]))
+				}
+			}
+			if routeInfo.Dst == nil && routeInfo.Src == nil && routeInfo.Gateway == nil {
+				continue loop
+			}
+			switch rt.Family {
+			case syscall.AF_INET:
+				rtr.v4 = append(rtr.v4, &routeInfo)
+			case syscall.AF_INET6:
+				rtr.v6 = append(rtr.v6, &routeInfo)
+			default:
+				// should not happen.
+				continue loop
+			}
+		}
+	}
+	sort.Sort(rtr.v4)
+	sort.Sort(rtr.v6)
+	ifaces, err := net.Interfaces()
+	if err != nil {
+		return nil, err
+	}
+	for _, iface := range ifaces {
+		rtr.ifaces[iface.Index] = iface
+		var addrs ipAddrs
+		ifaceAddrs, err := iface.Addrs()
+		if err != nil {
+			return nil, err
+		}
+		for _, addr := range ifaceAddrs {
+			if inet, ok := addr.(*net.IPNet); ok {
+				// Go has a nasty habit of giving you IPv4s as ::ffff:1.2.3.4 instead of 1.2.3.4.
+				// We want to use mapped v4 addresses as v4 preferred addresses, never as v6
+				// preferred addresses.
+				if v4 := inet.IP.To4(); v4 != nil {
+					if addrs.v4 == nil {
+						addrs.v4 = v4
+					}
+				} else if addrs.v6 == nil {
+					addrs.v6 = inet.IP
+				}
+			}
+		}
+		rtr.addrs[iface.Index] = addrs
+	}
+	return rtr, nil
+}
+
+func (r *Router) String() string {
+	strs := []string{"ROUTER", "--- V4 ---"}
+	for _, route := range r.v4 {
+		strs = append(strs, fmt.Sprintf("%+v", *route))
+	}
+	strs = append(strs, "--- V6 ---")
+	for _, route := range r.v6 {
+		strs = append(strs, fmt.Sprintf("%+v", *route))
+	}
+	return strings.Join(strs, "\n")
+}
+
+func (r *Router) Route(dst net.IP) (iface net.Interface, gateway, preferredSrc net.IP, err error) {
+	return r.RouteWithSrc(nil, nil, dst)
+}
+
+func (r *Router) RouteWithSrc(input net.HardwareAddr, src, dst net.IP) (iface net.Interface, gateway, preferredSrc net.IP, err error) {
+	var ifaceIndex int
+	switch {
+	case dst.To4() != nil:
+		ifaceIndex, gateway, preferredSrc, err = r.route(r.v4, input, src, dst)
+	case dst.To16() != nil:
+		ifaceIndex, gateway, preferredSrc, err = r.route(r.v6, input, src, dst)
+	default:
+		err = errors.New("IP is not valid as IPv4 or IPv6")
+	}
+
+	if err != nil {
+		return
+	}
+
+	iface = r.ifaces[ifaceIndex]
+
+	if preferredSrc == nil {
+		switch {
+		case dst.To4() != nil:
+			preferredSrc = r.addrs[ifaceIndex].v4
+		case dst.To16() != nil:
+			preferredSrc = r.addrs[ifaceIndex].v6
+		}
+	}
+	return
+}
+
+func (r *Router) route(routes routeSlice, input net.HardwareAddr, src, dst net.IP) (iface int, gateway, preferredSrc net.IP, err error) {
+	var inputIndex uint32
+	if input != nil {
+		for i, iface := range r.ifaces {
+			if bytes.Equal(input, iface.HardwareAddr) {
+				inputIndex = uint32(i)
+				break
+			}
+		}
+	}
+	var defaultGateway *rtInfo = nil
+	for _, rt := range routes {
+		if rt.InputIface != 0 && rt.InputIface != inputIndex {
+			continue
+		}
+		if rt.Src == nil && rt.Dst == nil {
+			defaultGateway = rt
+			continue
+		}
+		if rt.Src != nil && !rt.Src.Contains(src) {
+			continue
+		}
+		if rt.Dst != nil && !rt.Dst.Contains(dst) {
+			continue
+		}
+		return int(rt.OutputIface), rt.Gateway, rt.PrefSrc, nil
+	}
+
+	if defaultGateway != nil {
+		return int(defaultGateway.OutputIface), defaultGateway.Gateway, defaultGateway.PrefSrc, nil
+	}
+	err = fmt.Errorf("no route found for %v", dst)
+	return
+}

+ 95 - 0
src/pkg/util/util.go

@@ -0,0 +1,95 @@
+package util
+
+import (
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+	"syscall"
+	"time"
+
+	jsoniter "github.com/json-iterator/go"
+	log "github.com/sirupsen/logrus"
+	"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+)
+
+var (
+	Json    = jsoniter.ConfigCompatibleWithStandardLibrary
+	RootDir = ""
+)
+
+func init() {
+	ex, err := os.Executable()
+	if err != nil {
+		log.Fatalf("error: %v", err)
+	}
+	RootDir = filepath.Dir(ex) + "/"
+}
+
+func GenerateKey() string {
+	key, err := wgtypes.GenerateKey()
+	if err == nil {
+		return key.String()
+	}
+	return ""
+}
+
+func GeneratePrivateKey() string {
+	key, err := wgtypes.GeneratePrivateKey()
+	if err == nil {
+		return key.String()
+	}
+	return ""
+}
+
+func GetExternalIP(timeout time.Duration) (net.IP, error) {
+	// Define the GET method with the correct url,
+	// setting the User-Agent to our library
+	req, err := http.NewRequest("GET", "https://checkip.amazonaws.com/", nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("User-Agent", "LTWG")
+
+	// transport to avoid goroutine leak
+	tr := &http.Transport{
+		MaxIdleConns:      1,
+		IdleConnTimeout:   3 * time.Second,
+		DisableKeepAlives: true,
+		DialContext: (&net.Dialer{
+			Timeout:   30 * time.Second,
+			KeepAlive: 30 * time.Second,
+			DualStack: false,
+			Control: func(network, address string, c syscall.RawConn) error {
+				return nil
+			},
+		}).DialContext,
+	}
+
+	client := &http.Client{Timeout: timeout, Transport: tr}
+
+	// Do the request and read the body for non-error results.
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	bytes, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	// validate the IP
+	raw := string(bytes)
+	externalIP := net.ParseIP(strings.TrimSpace(raw))
+	if externalIP == nil {
+		return nil, fmt.Errorf("[ERROR] returned an invalid IP: %s\n", raw)
+	}
+
+	// returned the parsed IP
+	return externalIP, nil
+}

+ 327 - 0
src/pkg/web/web.go

@@ -0,0 +1,327 @@
+package web
+
+import (
+	"embed"
+	"fahi/pkg/config"
+	"fahi/pkg/util"
+	"fahi/pkg/wg"
+	"fmt"
+	"image/png"
+	"io/fs"
+	"net/http"
+	"strings"
+
+	"github.com/boombuler/barcode"
+	"github.com/boombuler/barcode/qr"
+	"github.com/gorilla/sessions"
+	"github.com/labstack/echo-contrib/session"
+	"github.com/labstack/echo/v4"
+	"github.com/labstack/echo/v4/middleware"
+	"golang.org/x/crypto/acme/autocert"
+)
+
+//go:embed www
+var embededFiles embed.FS
+
+func assetHandler() http.Handler {
+	fsys, err := fs.Sub(embededFiles, "www")
+	if err != nil {
+		panic(err)
+	}
+	return http.FileServer(http.FS(fsys))
+}
+
+func Serve(cfg *config.Config, wgIface *wg.WgIface) error {
+	e := echo.New()
+	e.HideBanner = true
+	e.Use(middleware.Recover())
+	e.Use(session.Middleware(sessions.NewCookieStore([]byte(util.GenerateKey()))))
+
+	e.GET("/*", echo.WrapHandler(assetHandler()))
+
+	e.GET("/api/version", func(c echo.Context) error {
+		return c.JSON(http.StatusOK, &cfg.Version)
+	})
+
+	e.GET("/api/lang", func(c echo.Context) error {
+		return c.JSON(http.StatusOK, &cfg.Lang)
+	})
+
+	e.GET("/api/session", func(c echo.Context) error {
+		var result struct {
+			RequiresPassword bool `json:"requiresPassword"`
+			Authenticated    bool `json:"authenticated"`
+		}
+
+		if cfg.Password != "" {
+			result.RequiresPassword = true
+			if verify(c) {
+				result.Authenticated = true
+			}
+		}
+
+		return c.JSON(http.StatusOK, &result)
+	})
+
+	e.POST("/api/session", func(c echo.Context) error {
+		var req struct {
+			Password string `json:"password"`
+		}
+
+		var result struct {
+			Error string `json:"error"`
+		}
+
+		if err := c.Bind(&req); err != nil {
+			result.Error = "Missing password"
+			return c.JSON(http.StatusUnauthorized, &result)
+		} else if req.Password != cfg.Password {
+			result.Error = "Incorrect password"
+			return c.JSON(http.StatusUnauthorized, &result)
+		}
+
+		sess, _ := session.Get("connect.sid", c)
+		sess.Options = &sessions.Options{
+			Path:     "/",
+			MaxAge:   86400,
+			HttpOnly: true,
+		}
+		sess.Values["authenticated"] = true
+		sess.Save(c.Request(), c.Response())
+		return c.NoContent(http.StatusNoContent)
+	})
+
+	e.DELETE("/api/session", func(c echo.Context) error {
+		sess, err := session.Get("connect.sid", c)
+		if err == nil {
+			sess.Options.MaxAge = -1
+			sess.Save(c.Request(), c.Response())
+		}
+		return c.NoContent(http.StatusNoContent)
+	})
+
+	e.GET("/api/wireguard/client", func(c echo.Context) error {
+		var result struct {
+			Error string `json:"error"`
+		}
+
+		if !verify(c) {
+			result.Error = "Not logged in"
+			return c.JSON(http.StatusUnauthorized, &result)
+		}
+
+		peers, err := wgIface.GetPeers()
+		if err != nil {
+			result.Error = err.Error()
+			return c.JSON(http.StatusInternalServerError, &result)
+		}
+
+		return c.JSON(http.StatusOK, peers)
+	})
+
+	e.GET("/api/wireguard/client/:clientId/qrcode.svg", func(c echo.Context) error {
+		var result struct {
+			Error string `json:"error"`
+		}
+
+		if !verify(c) {
+			result.Error = "Not logged in"
+			return c.JSON(http.StatusUnauthorized, &result)
+		}
+
+		peerConfig, err := wgIface.GetPeerConfig(c.Param("clientId"))
+		if err != nil {
+			result.Error = err.Error()
+			return c.JSON(http.StatusInternalServerError, &result)
+		}
+
+		c.Response().Header().Set("Content-Type", "image/png")
+		qrCode, err := qr.Encode(peerConfig, qr.M, qr.Auto)
+		if err != nil {
+			result.Error = err.Error()
+			return c.JSON(http.StatusInternalServerError, &result)
+		}
+		qrCode, err = barcode.Scale(qrCode, 200, 200)
+		if err != nil {
+			result.Error = err.Error()
+			return c.JSON(http.StatusInternalServerError, &result)
+		}
+		png.Encode(c.Response().Writer, qrCode)
+
+		return nil
+	})
+
+	e.GET("/api/wireguard/client/:clientId/configuration", func(c echo.Context) error {
+		var result struct {
+			Error string `json:"error"`
+		}
+
+		if !verify(c) {
+			result.Error = "Not logged in"
+			return c.JSON(http.StatusUnauthorized, &result)
+		}
+
+		clientId := c.Param("clientId")
+		peerConfig, err := wgIface.GetPeerConfig(clientId)
+		if err != nil {
+			result.Error = err.Error()
+			return c.JSON(http.StatusInternalServerError, &result)
+		}
+
+		c.Response().Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.conf"`, strings.ReplaceAll(clientId, "-", "")))
+
+		return c.Blob(http.StatusOK, "text/plain", []byte(peerConfig))
+	})
+
+	e.POST("/api/wireguard/client", func(c echo.Context) error {
+		var result struct {
+			Error string `json:"error"`
+		}
+
+		if !verify(c) {
+			result.Error = "Not logged in"
+			return c.JSON(http.StatusUnauthorized, &result)
+		}
+
+		var req struct {
+			Name string `json:"name"`
+		}
+
+		if err := c.Bind(&req); err != nil {
+			result.Error = "Missing name"
+			return c.JSON(http.StatusForbidden, &result)
+		}
+
+		peer, err := wgIface.AddPeer(req.Name)
+		if err != nil {
+			result.Error = err.Error()
+			return c.JSON(http.StatusInternalServerError, &result)
+		}
+
+		return c.JSON(http.StatusOK, peer)
+	})
+
+	e.DELETE("/api/wireguard/client/:clientId", func(c echo.Context) error {
+		var result struct {
+			Error string `json:"error"`
+		}
+
+		if !verify(c) {
+			result.Error = "Not logged in"
+			return c.JSON(http.StatusUnauthorized, &result)
+		}
+
+		clientId := c.Param("clientId")
+
+		err := wgIface.DelPeer(clientId)
+		if err != nil {
+			result.Error = err.Error()
+			return c.JSON(http.StatusInternalServerError, &result)
+		}
+
+		return c.NoContent(http.StatusNoContent)
+	})
+
+	e.POST("/api/wireguard/client/:clientId/:enabled", func(c echo.Context) error {
+		var result struct {
+			Error string `json:"error"`
+		}
+
+		if !verify(c) {
+			result.Error = "Not logged in"
+			return c.JSON(http.StatusUnauthorized, &result)
+		}
+
+		err := wgIface.SetPeer(c.Param("clientId"), c.Param("enabled"), "", "")
+		if err != nil {
+			result.Error = err.Error()
+			return c.JSON(http.StatusInternalServerError, &result)
+		}
+
+		return c.NoContent(http.StatusNoContent)
+	})
+
+	e.PUT("/api/wireguard/client/:clientId/name", func(c echo.Context) error {
+		var result struct {
+			Error string `json:"error"`
+		}
+
+		if !verify(c) {
+			result.Error = "Not logged in"
+			return c.JSON(http.StatusUnauthorized, &result)
+		}
+
+		var req struct {
+			ClientId string `param:"clientId"`
+			Name     string `json:"name"`
+		}
+
+		if err := c.Bind(&req); err != nil {
+			result.Error = "Missing name"
+			return c.JSON(http.StatusForbidden, &result)
+		}
+
+		err := wgIface.SetPeer(req.ClientId, "", req.Name, "")
+		if err != nil {
+			result.Error = err.Error()
+			return c.JSON(http.StatusInternalServerError, &result)
+		}
+
+		return c.NoContent(http.StatusNoContent)
+	})
+
+	e.PUT("/api/wireguard/client/:clientId/address", func(c echo.Context) error {
+		var result struct {
+			Error string `json:"error"`
+		}
+
+		if !verify(c) {
+			result.Error = "Not logged in"
+			return c.JSON(http.StatusUnauthorized, &result)
+		}
+
+		var req struct {
+			ClientId string `param:"clientId"`
+			Address  string `json:"address"`
+		}
+
+		if err := c.Bind(&req); err != nil {
+			result.Error = "Missing address"
+			return c.JSON(http.StatusForbidden, &result)
+		}
+
+		err := wgIface.SetPeer(req.ClientId, "", "", req.Address)
+		if err != nil {
+			result.Error = err.Error()
+			return c.JSON(http.StatusInternalServerError, &result)
+		}
+
+		return c.NoContent(http.StatusNoContent)
+	})
+
+	address := fmt.Sprintf("0.0.0.0:%d", cfg.Port)
+	if cfg.AutoSSL {
+		e.AutoTLSManager.HostPolicy = autocert.HostWhitelist(cfg.Host)
+		e.AutoTLSManager.Cache = autocert.DirCache(util.RootDir + "cert")
+		return e.StartAutoTLS(address)
+	} else {
+		return e.Start(address)
+	}
+}
+
+func verify(c echo.Context) bool {
+	sess, err := session.Get("connect.sid", c)
+	if err != nil {
+		return false
+	}
+
+	authenticated := sess.Values["authenticated"]
+	if authenticated == nil {
+		return false
+	} else if value, ok := authenticated.(bool); ok {
+		if value {
+			return true
+		}
+	}
+	return false
+}

+ 1761 - 0
src/pkg/web/www/css/app.css

@@ -0,0 +1,1761 @@
+/*
+! tailwindcss v3.4.1 | MIT License | https://tailwindcss.com
+*/
+
+/*
+1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
+2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
+*/
+
+*,
+::before,
+::after {
+  box-sizing: border-box;
+  /* 1 */
+  border-width: 0;
+  /* 2 */
+  border-style: solid;
+  /* 2 */
+  border-color: #e5e7eb;
+  /* 2 */
+}
+
+::before,
+::after {
+  --tw-content: '';
+}
+
+/*
+1. Use a consistent sensible line-height in all browsers.
+2. Prevent adjustments of font size after orientation changes in iOS.
+3. Use a more readable tab size.
+4. Use the user's configured `sans` font-family by default.
+5. Use the user's configured `sans` font-feature-settings by default.
+6. Use the user's configured `sans` font-variation-settings by default.
+7. Disable tap highlights on iOS
+*/
+
+html,
+:host {
+  line-height: 1.5;
+  /* 1 */
+  -webkit-text-size-adjust: 100%;
+  /* 2 */
+  -moz-tab-size: 4;
+  /* 3 */
+  -o-tab-size: 4;
+     tab-size: 4;
+  /* 3 */
+  font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+  /* 4 */
+  font-feature-settings: normal;
+  /* 5 */
+  font-variation-settings: normal;
+  /* 6 */
+  -webkit-tap-highlight-color: transparent;
+  /* 7 */
+}
+
+/*
+1. Remove the margin in all browsers.
+2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
+*/
+
+body {
+  margin: 0;
+  /* 1 */
+  line-height: inherit;
+  /* 2 */
+}
+
+/*
+1. Add the correct height in Firefox.
+2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
+3. Ensure horizontal rules are visible by default.
+*/
+
+hr {
+  height: 0;
+  /* 1 */
+  color: inherit;
+  /* 2 */
+  border-top-width: 1px;
+  /* 3 */
+}
+
+/*
+Add the correct text decoration in Chrome, Edge, and Safari.
+*/
+
+abbr:where([title]) {
+  -webkit-text-decoration: underline dotted;
+          text-decoration: underline dotted;
+}
+
+/*
+Remove the default font size and weight for headings.
+*/
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+  font-size: inherit;
+  font-weight: inherit;
+}
+
+/*
+Reset links to optimize for opt-in styling instead of opt-out.
+*/
+
+a {
+  color: inherit;
+  text-decoration: inherit;
+}
+
+/*
+Add the correct font weight in Edge and Safari.
+*/
+
+b,
+strong {
+  font-weight: bolder;
+}
+
+/*
+1. Use the user's configured `mono` font-family by default.
+2. Use the user's configured `mono` font-feature-settings by default.
+3. Use the user's configured `mono` font-variation-settings by default.
+4. Correct the odd `em` font sizing in all browsers.
+*/
+
+code,
+kbd,
+samp,
+pre {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+  /* 1 */
+  font-feature-settings: normal;
+  /* 2 */
+  font-variation-settings: normal;
+  /* 3 */
+  font-size: 1em;
+  /* 4 */
+}
+
+/*
+Add the correct font size in all browsers.
+*/
+
+small {
+  font-size: 80%;
+}
+
+/*
+Prevent `sub` and `sup` elements from affecting the line height in all browsers.
+*/
+
+sub,
+sup {
+  font-size: 75%;
+  line-height: 0;
+  position: relative;
+  vertical-align: baseline;
+}
+
+sub {
+  bottom: -0.25em;
+}
+
+sup {
+  top: -0.5em;
+}
+
+/*
+1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
+2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
+3. Remove gaps between table borders by default.
+*/
+
+table {
+  text-indent: 0;
+  /* 1 */
+  border-color: inherit;
+  /* 2 */
+  border-collapse: collapse;
+  /* 3 */
+}
+
+/*
+1. Change the font styles in all browsers.
+2. Remove the margin in Firefox and Safari.
+3. Remove default padding in all browsers.
+*/
+
+button,
+input,
+optgroup,
+select,
+textarea {
+  font-family: inherit;
+  /* 1 */
+  font-feature-settings: inherit;
+  /* 1 */
+  font-variation-settings: inherit;
+  /* 1 */
+  font-size: 100%;
+  /* 1 */
+  font-weight: inherit;
+  /* 1 */
+  line-height: inherit;
+  /* 1 */
+  color: inherit;
+  /* 1 */
+  margin: 0;
+  /* 2 */
+  padding: 0;
+  /* 3 */
+}
+
+/*
+Remove the inheritance of text transform in Edge and Firefox.
+*/
+
+button,
+select {
+  text-transform: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Remove default button styles.
+*/
+
+button,
+[type='button'],
+[type='reset'],
+[type='submit'] {
+  -webkit-appearance: button;
+  /* 1 */
+  background-color: transparent;
+  /* 2 */
+  background-image: none;
+  /* 2 */
+}
+
+/*
+Use the modern Firefox focus style for all focusable elements.
+*/
+
+:-moz-focusring {
+  outline: auto;
+}
+
+/*
+Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
+*/
+
+:-moz-ui-invalid {
+  box-shadow: none;
+}
+
+/*
+Add the correct vertical alignment in Chrome and Firefox.
+*/
+
+progress {
+  vertical-align: baseline;
+}
+
+/*
+Correct the cursor style of increment and decrement buttons in Safari.
+*/
+
+::-webkit-inner-spin-button,
+::-webkit-outer-spin-button {
+  height: auto;
+}
+
+/*
+1. Correct the odd appearance in Chrome and Safari.
+2. Correct the outline style in Safari.
+*/
+
+[type='search'] {
+  -webkit-appearance: textfield;
+  /* 1 */
+  outline-offset: -2px;
+  /* 2 */
+}
+
+/*
+Remove the inner padding in Chrome and Safari on macOS.
+*/
+
+::-webkit-search-decoration {
+  -webkit-appearance: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Change font properties to `inherit` in Safari.
+*/
+
+::-webkit-file-upload-button {
+  -webkit-appearance: button;
+  /* 1 */
+  font: inherit;
+  /* 2 */
+}
+
+/*
+Add the correct display in Chrome and Safari.
+*/
+
+summary {
+  display: list-item;
+}
+
+/*
+Removes the default spacing and border for appropriate elements.
+*/
+
+blockquote,
+dl,
+dd,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+hr,
+figure,
+p,
+pre {
+  margin: 0;
+}
+
+fieldset {
+  margin: 0;
+  padding: 0;
+}
+
+legend {
+  padding: 0;
+}
+
+ol,
+ul,
+menu {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+}
+
+/*
+Reset default styling for dialogs.
+*/
+
+dialog {
+  padding: 0;
+}
+
+/*
+Prevent resizing textareas horizontally by default.
+*/
+
+textarea {
+  resize: vertical;
+}
+
+/*
+1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
+2. Set the default placeholder color to the user's configured gray 400 color.
+*/
+
+input::-moz-placeholder, textarea::-moz-placeholder {
+  opacity: 1;
+  /* 1 */
+  color: #9ca3af;
+  /* 2 */
+}
+
+input::placeholder,
+textarea::placeholder {
+  opacity: 1;
+  /* 1 */
+  color: #9ca3af;
+  /* 2 */
+}
+
+/*
+Set the default cursor for buttons.
+*/
+
+button,
+[role="button"] {
+  cursor: pointer;
+}
+
+/*
+Make sure disabled buttons don't get the pointer cursor.
+*/
+
+:disabled {
+  cursor: default;
+}
+
+/*
+1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
+2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
+   This can trigger a poorly considered lint error in some tools but is included by design.
+*/
+
+img,
+svg,
+video,
+canvas,
+audio,
+iframe,
+embed,
+object {
+  display: block;
+  /* 1 */
+  vertical-align: middle;
+  /* 2 */
+}
+
+/*
+Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
+*/
+
+img,
+video {
+  max-width: 100%;
+  height: auto;
+}
+
+/* Make elements with the HTML hidden attribute stay hidden by default */
+
+[hidden] {
+  display: none;
+}
+
+*, ::before, ::after {
+  --tw-border-spacing-x: 0;
+  --tw-border-spacing-y: 0;
+  --tw-translate-x: 0;
+  --tw-translate-y: 0;
+  --tw-rotate: 0;
+  --tw-skew-x: 0;
+  --tw-skew-y: 0;
+  --tw-scale-x: 1;
+  --tw-scale-y: 1;
+  --tw-pan-x:  ;
+  --tw-pan-y:  ;
+  --tw-pinch-zoom:  ;
+  --tw-scroll-snap-strictness: proximity;
+  --tw-gradient-from-position:  ;
+  --tw-gradient-via-position:  ;
+  --tw-gradient-to-position:  ;
+  --tw-ordinal:  ;
+  --tw-slashed-zero:  ;
+  --tw-numeric-figure:  ;
+  --tw-numeric-spacing:  ;
+  --tw-numeric-fraction:  ;
+  --tw-ring-inset:  ;
+  --tw-ring-offset-width: 0px;
+  --tw-ring-offset-color: #fff;
+  --tw-ring-color: rgb(59 130 246 / 0.5);
+  --tw-ring-offset-shadow: 0 0 #0000;
+  --tw-ring-shadow: 0 0 #0000;
+  --tw-shadow: 0 0 #0000;
+  --tw-shadow-colored: 0 0 #0000;
+  --tw-blur:  ;
+  --tw-brightness:  ;
+  --tw-contrast:  ;
+  --tw-grayscale:  ;
+  --tw-hue-rotate:  ;
+  --tw-invert:  ;
+  --tw-saturate:  ;
+  --tw-sepia:  ;
+  --tw-drop-shadow:  ;
+  --tw-backdrop-blur:  ;
+  --tw-backdrop-brightness:  ;
+  --tw-backdrop-contrast:  ;
+  --tw-backdrop-grayscale:  ;
+  --tw-backdrop-hue-rotate:  ;
+  --tw-backdrop-invert:  ;
+  --tw-backdrop-opacity:  ;
+  --tw-backdrop-saturate:  ;
+  --tw-backdrop-sepia:  ;
+}
+
+::backdrop {
+  --tw-border-spacing-x: 0;
+  --tw-border-spacing-y: 0;
+  --tw-translate-x: 0;
+  --tw-translate-y: 0;
+  --tw-rotate: 0;
+  --tw-skew-x: 0;
+  --tw-skew-y: 0;
+  --tw-scale-x: 1;
+  --tw-scale-y: 1;
+  --tw-pan-x:  ;
+  --tw-pan-y:  ;
+  --tw-pinch-zoom:  ;
+  --tw-scroll-snap-strictness: proximity;
+  --tw-gradient-from-position:  ;
+  --tw-gradient-via-position:  ;
+  --tw-gradient-to-position:  ;
+  --tw-ordinal:  ;
+  --tw-slashed-zero:  ;
+  --tw-numeric-figure:  ;
+  --tw-numeric-spacing:  ;
+  --tw-numeric-fraction:  ;
+  --tw-ring-inset:  ;
+  --tw-ring-offset-width: 0px;
+  --tw-ring-offset-color: #fff;
+  --tw-ring-color: rgb(59 130 246 / 0.5);
+  --tw-ring-offset-shadow: 0 0 #0000;
+  --tw-ring-shadow: 0 0 #0000;
+  --tw-shadow: 0 0 #0000;
+  --tw-shadow-colored: 0 0 #0000;
+  --tw-blur:  ;
+  --tw-brightness:  ;
+  --tw-contrast:  ;
+  --tw-grayscale:  ;
+  --tw-hue-rotate:  ;
+  --tw-invert:  ;
+  --tw-saturate:  ;
+  --tw-sepia:  ;
+  --tw-drop-shadow:  ;
+  --tw-backdrop-blur:  ;
+  --tw-backdrop-brightness:  ;
+  --tw-backdrop-contrast:  ;
+  --tw-backdrop-grayscale:  ;
+  --tw-backdrop-hue-rotate:  ;
+  --tw-backdrop-invert:  ;
+  --tw-backdrop-opacity:  ;
+  --tw-backdrop-saturate:  ;
+  --tw-backdrop-sepia:  ;
+}
+
+.container {
+  width: 100%;
+}
+
+@media (min-width: 640px) {
+  .container {
+    max-width: 640px;
+  }
+}
+
+@media (min-width: 768px) {
+  .container {
+    max-width: 768px;
+  }
+}
+
+@media (min-width: 1024px) {
+  .container {
+    max-width: 1024px;
+  }
+}
+
+@media (min-width: 1280px) {
+  .container {
+    max-width: 1280px;
+  }
+}
+
+@media (min-width: 1536px) {
+  .container {
+    max-width: 1536px;
+  }
+}
+
+.visible {
+  visibility: visible;
+}
+
+.static {
+  position: static;
+}
+
+.fixed {
+  position: fixed;
+}
+
+.absolute {
+  position: absolute;
+}
+
+.relative {
+  position: relative;
+}
+
+.inset-0 {
+  inset: 0px;
+}
+
+.-bottom-1 {
+  bottom: -0.25rem;
+}
+
+.-right-1 {
+  right: -0.25rem;
+}
+
+.bottom-0 {
+  bottom: 0px;
+}
+
+.left-0 {
+  left: 0px;
+}
+
+.right-0 {
+  right: 0px;
+}
+
+.right-2 {
+  right: 0.5rem;
+}
+
+.right-4 {
+  right: 1rem;
+}
+
+.top-0 {
+  top: 0px;
+}
+
+.top-2 {
+  top: 0.5rem;
+}
+
+.top-4 {
+  top: 1rem;
+}
+
+.z-0 {
+  z-index: 0;
+}
+
+.z-10 {
+  z-index: 10;
+}
+
+.z-20 {
+  z-index: 20;
+}
+
+.float-right {
+  float: right;
+}
+
+.m-1 {
+  margin: 0.25rem;
+}
+
+.m-10 {
+  margin: 2.5rem;
+}
+
+.m-2 {
+  margin: 0.5rem;
+}
+
+.m-5 {
+  margin: 1.25rem;
+}
+
+.mx-auto {
+  margin-left: auto;
+  margin-right: auto;
+}
+
+.my-16 {
+  margin-top: 4rem;
+  margin-bottom: 4rem;
+}
+
+.mb-10 {
+  margin-bottom: 2.5rem;
+}
+
+.mb-2 {
+  margin-bottom: 0.5rem;
+}
+
+.mb-5 {
+  margin-bottom: 1.25rem;
+}
+
+.ml-5 {
+  margin-left: 1.25rem;
+}
+
+.mr-1 {
+  margin-right: 0.25rem;
+}
+
+.mr-2 {
+  margin-right: 0.5rem;
+}
+
+.mr-5 {
+  margin-right: 1.25rem;
+}
+
+.mt-10 {
+  margin-top: 2.5rem;
+}
+
+.mt-2 {
+  margin-top: 0.5rem;
+}
+
+.mt-3 {
+  margin-top: 0.75rem;
+}
+
+.mt-5 {
+  margin-top: 1.25rem;
+}
+
+.block {
+  display: block;
+}
+
+.inline-block {
+  display: inline-block;
+}
+
+.inline {
+  display: inline;
+}
+
+.flex {
+  display: flex;
+}
+
+.inline-flex {
+  display: inline-flex;
+}
+
+.table {
+  display: table;
+}
+
+.grid {
+  display: grid;
+}
+
+.contents {
+  display: contents;
+}
+
+.hidden {
+  display: none;
+}
+
+.h-1 {
+  height: 0.25rem;
+}
+
+.h-10 {
+  height: 2.5rem;
+}
+
+.h-12 {
+  height: 3rem;
+}
+
+.h-14 {
+  height: 3.5rem;
+}
+
+.h-2 {
+  height: 0.5rem;
+}
+
+.h-20 {
+  height: 5rem;
+}
+
+.h-3 {
+  height: 0.75rem;
+}
+
+.h-32 {
+  height: 8rem;
+}
+
+.h-4 {
+  height: 1rem;
+}
+
+.h-6 {
+  height: 1.5rem;
+}
+
+.min-h-screen {
+  min-height: 100vh;
+}
+
+.w-10 {
+  width: 2.5rem;
+}
+
+.w-12 {
+  width: 3rem;
+}
+
+.w-2 {
+  width: 0.5rem;
+}
+
+.w-20 {
+  width: 5rem;
+}
+
+.w-4 {
+  width: 1rem;
+}
+
+.w-5 {
+  width: 1.25rem;
+}
+
+.w-6 {
+  width: 1.5rem;
+}
+
+.w-64 {
+  width: 16rem;
+}
+
+.w-8 {
+  width: 2rem;
+}
+
+.w-full {
+  width: 100%;
+}
+
+.max-w-3xl {
+  max-width: 48rem;
+}
+
+.flex-auto {
+  flex: 1 1 auto;
+}
+
+.flex-shrink-0 {
+  flex-shrink: 0;
+}
+
+.flex-grow {
+  flex-grow: 1;
+}
+
+.transform {
+  transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
+}
+
+@keyframes ping {
+  75%, 100% {
+    transform: scale(2);
+    opacity: 0;
+  }
+}
+
+.animate-ping {
+  animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+.animate-spin {
+  animation: spin 1s linear infinite;
+}
+
+.cursor-not-allowed {
+  cursor: not-allowed;
+}
+
+.cursor-pointer {
+  cursor: pointer;
+}
+
+.resize {
+  resize: both;
+}
+
+.flex-row {
+  flex-direction: row;
+}
+
+.flex-col {
+  flex-direction: column;
+}
+
+.flex-wrap {
+  flex-wrap: wrap;
+}
+
+.items-center {
+  align-items: center;
+}
+
+.justify-end {
+  justify-content: flex-end;
+}
+
+.justify-center {
+  justify-content: center;
+}
+
+.justify-between {
+  justify-content: space-between;
+}
+
+.gap-1 {
+  gap: 0.25rem;
+}
+
+.overflow-hidden {
+  overflow: hidden;
+}
+
+.overflow-y-auto {
+  overflow-y: auto;
+}
+
+.truncate {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.rounded {
+  border-radius: 0.25rem;
+}
+
+.rounded-full {
+  border-radius: 9999px;
+}
+
+.rounded-lg {
+  border-radius: 0.5rem;
+}
+
+.rounded-md {
+  border-radius: 0.375rem;
+}
+
+.border {
+  border-width: 1px;
+}
+
+.border-2 {
+  border-width: 2px;
+}
+
+.border-b {
+  border-bottom-width: 1px;
+}
+
+.border-b-2 {
+  border-bottom-width: 2px;
+}
+
+.border-t-2 {
+  border-top-width: 2px;
+}
+
+.border-solid {
+  border-style: solid;
+}
+
+.border-none {
+  border-style: none;
+}
+
+.border-gray-100 {
+  --tw-border-opacity: 1;
+  border-color: rgb(243 244 246 / var(--tw-border-opacity));
+}
+
+.border-gray-300 {
+  --tw-border-opacity: 1;
+  border-color: rgb(209 213 219 / var(--tw-border-opacity));
+}
+
+.border-red-800 {
+  --tw-border-opacity: 1;
+  border-color: rgb(82 196 26 / var(--tw-border-opacity));
+}
+
+.border-transparent {
+  border-color: transparent;
+}
+
+.bg-black {
+  --tw-bg-opacity: 1;
+  background-color: rgb(0 0 0 / var(--tw-bg-opacity));
+}
+
+.bg-gray-100 {
+  --tw-bg-opacity: 1;
+  background-color: rgb(243 244 246 / var(--tw-bg-opacity));
+}
+
+.bg-gray-200 {
+  --tw-bg-opacity: 1;
+  background-color: rgb(229 231 235 / var(--tw-bg-opacity));
+}
+
+.bg-gray-50 {
+  --tw-bg-opacity: 1;
+  background-color: rgb(249 250 251 / var(--tw-bg-opacity));
+}
+
+.bg-gray-500 {
+  --tw-bg-opacity: 1;
+  background-color: rgb(107 114 128 / var(--tw-bg-opacity));
+}
+
+.bg-red-100 {
+  --tw-bg-opacity: 1;
+  background-color: rgb(226 254 226 / var(--tw-bg-opacity));
+}
+
+.bg-red-600 {
+  --tw-bg-opacity: 1;
+  background-color: rgb(102 236 36 / var(--tw-bg-opacity));
+}
+
+.bg-red-800 {
+  --tw-bg-opacity: 1;
+  background-color: rgb(82 196 26 / var(--tw-bg-opacity));
+}
+
+.bg-white {
+  --tw-bg-opacity: 1;
+  background-color: rgb(255 255 255 / var(--tw-bg-opacity));
+}
+
+.bg-opacity-50 {
+  --tw-bg-opacity: 0.5;
+}
+
+.p-1 {
+  padding: 0.25rem;
+}
+
+.p-2 {
+  padding: 0.5rem;
+}
+
+.p-3 {
+  padding: 0.75rem;
+}
+
+.p-4 {
+  padding: 1rem;
+}
+
+.p-5 {
+  padding: 1.25rem;
+}
+
+.p-8 {
+  padding: 2rem;
+}
+
+.px-1 {
+  padding-left: 0.25rem;
+  padding-right: 0.25rem;
+}
+
+.px-3 {
+  padding-left: 0.75rem;
+  padding-right: 0.75rem;
+}
+
+.px-4 {
+  padding-left: 1rem;
+  padding-right: 1rem;
+}
+
+.px-5 {
+  padding-left: 1.25rem;
+  padding-right: 1.25rem;
+}
+
+.py-2 {
+  padding-top: 0.5rem;
+  padding-bottom: 0.5rem;
+}
+
+.py-3 {
+  padding-top: 0.75rem;
+  padding-bottom: 0.75rem;
+}
+
+.pb-1 {
+  padding-bottom: 0.25rem;
+}
+
+.pb-12 {
+  padding-bottom: 3rem;
+}
+
+.pb-2 {
+  padding-bottom: 0.5rem;
+}
+
+.pb-20 {
+  padding-bottom: 5rem;
+}
+
+.pb-4 {
+  padding-bottom: 1rem;
+}
+
+.pt-24 {
+  padding-top: 6rem;
+}
+
+.pt-4 {
+  padding-top: 1rem;
+}
+
+.pt-5 {
+  padding-top: 1.25rem;
+}
+
+.text-left {
+  text-align: left;
+}
+
+.text-center {
+  text-align: center;
+}
+
+.align-middle {
+  vertical-align: middle;
+}
+
+.align-bottom {
+  vertical-align: bottom;
+}
+
+.text-2xl {
+  font-size: 1.5rem;
+  line-height: 2rem;
+}
+
+.text-4xl {
+  font-size: 2.25rem;
+  line-height: 2.5rem;
+}
+
+.text-base {
+  font-size: 1rem;
+  line-height: 1.5rem;
+}
+
+.text-lg {
+  font-size: 1.125rem;
+  line-height: 1.75rem;
+}
+
+.text-sm {
+  font-size: 0.875rem;
+  line-height: 1.25rem;
+}
+
+.text-xs {
+  font-size: 0.75rem;
+  line-height: 1rem;
+}
+
+.font-bold {
+  font-weight: 700;
+}
+
+.font-medium {
+  font-weight: 500;
+}
+
+.font-semibold {
+  font-weight: 600;
+}
+
+.leading-6 {
+  line-height: 1.5rem;
+}
+
+.text-black {
+  --tw-text-opacity: 1;
+  color: rgb(0 0 0 / var(--tw-text-opacity));
+}
+
+.text-gray-200 {
+  --tw-text-opacity: 1;
+  color: rgb(229 231 235 / var(--tw-text-opacity));
+}
+
+.text-gray-300 {
+  --tw-text-opacity: 1;
+  color: rgb(209 213 219 / var(--tw-text-opacity));
+}
+
+.text-gray-400 {
+  --tw-text-opacity: 1;
+  color: rgb(156 163 175 / var(--tw-text-opacity));
+}
+
+.text-gray-500 {
+  --tw-text-opacity: 1;
+  color: rgb(107 114 128 / var(--tw-text-opacity));
+}
+
+.text-gray-600 {
+  --tw-text-opacity: 1;
+  color: rgb(75 85 99 / var(--tw-text-opacity));
+}
+
+.text-gray-700 {
+  --tw-text-opacity: 1;
+  color: rgb(55 65 81 / var(--tw-text-opacity));
+}
+
+.text-gray-900 {
+  --tw-text-opacity: 1;
+  color: rgb(17 24 39 / var(--tw-text-opacity));
+}
+
+.text-red-600 {
+  --tw-text-opacity: 1;
+  color: rgb(102 236 36 / var(--tw-text-opacity));
+}
+
+.text-red-800 {
+  --tw-text-opacity: 1;
+  color: rgb(82 196 26 / var(--tw-text-opacity));
+}
+
+.text-white {
+  --tw-text-opacity: 1;
+  color: rgb(255 255 255 / var(--tw-text-opacity));
+}
+
+.opacity-0 {
+  opacity: 0;
+}
+
+.opacity-100 {
+  opacity: 1;
+}
+
+.opacity-25 {
+  opacity: 0.25;
+}
+
+.opacity-75 {
+  opacity: 0.75;
+}
+
+.shadow {
+  --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
+  --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
+  box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
+}
+
+.shadow-lg {
+  --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+  --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
+  box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
+}
+
+.shadow-md {
+  --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
+  --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
+  box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
+}
+
+.shadow-sm {
+  --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+  --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);
+  box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
+}
+
+.shadow-xl {
+  --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
+  --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);
+  box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
+}
+
+.outline-none {
+  outline: 2px solid transparent;
+  outline-offset: 2px;
+}
+
+.blur {
+  --tw-blur: blur(8px);
+  filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
+}
+
+.filter {
+  filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
+}
+
+.transition {
+  transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
+  transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
+  transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter;
+  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+  transition-duration: 150ms;
+}
+
+.transition-all {
+  transition-property: all;
+  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+  transition-duration: 150ms;
+}
+
+.transition-opacity {
+  transition-property: opacity;
+  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+  transition-duration: 150ms;
+}
+
+.duration-200 {
+  transition-duration: 200ms;
+}
+
+.duration-300 {
+  transition-duration: 300ms;
+}
+
+.ease-in {
+  transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
+}
+
+.ease-out {
+  transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
+}
+
+.last\:border-b-0:last-child {
+  border-bottom-width: 0px;
+}
+
+.hover\:border-red-800:hover {
+  --tw-border-opacity: 1;
+  border-color: rgb(82 196 26 / var(--tw-border-opacity));
+}
+
+.hover\:border-white:hover {
+  --tw-border-opacity: 1;
+  border-color: rgb(255 255 255 / var(--tw-border-opacity));
+}
+
+.hover\:bg-gray-300:hover {
+  --tw-bg-opacity: 1;
+  background-color: rgb(209 213 219 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-gray-50:hover {
+  --tw-bg-opacity: 1;
+  background-color: rgb(249 250 251 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-red-700:hover {
+  --tw-bg-opacity: 1;
+  background-color: rgb(102 216 36 / var(--tw-bg-opacity));
+}
+
+.hover\:bg-red-800:hover {
+  --tw-bg-opacity: 1;
+  background-color: rgb(82 196 26 / var(--tw-bg-opacity));
+}
+
+.hover\:text-gray-800:hover {
+  --tw-text-opacity: 1;
+  color: rgb(31 41 55 / var(--tw-text-opacity));
+}
+
+.hover\:text-white:hover {
+  --tw-text-opacity: 1;
+  color: rgb(255 255 255 / var(--tw-text-opacity));
+}
+
+.hover\:underline:hover {
+  text-decoration-line: underline;
+}
+
+.hover\:opacity-100:hover {
+  opacity: 1;
+}
+
+.focus\:border-gray-200:focus {
+  --tw-border-opacity: 1;
+  border-color: rgb(229 231 235 / var(--tw-border-opacity));
+}
+
+.focus\:border-red-800:focus {
+  --tw-border-opacity: 1;
+  border-color: rgb(82 196 26 / var(--tw-border-opacity));
+}
+
+.focus\:outline-none:focus {
+  outline: 2px solid transparent;
+  outline-offset: 2px;
+}
+
+.group:hover .group-hover\:opacity-100 {
+  opacity: 1;
+}
+
+@media (min-width: 640px) {
+  .sm\:mx-0 {
+    margin-left: 0px;
+    margin-right: 0px;
+  }
+
+  .sm\:my-8 {
+    margin-top: 2rem;
+    margin-bottom: 2rem;
+  }
+
+  .sm\:ml-3 {
+    margin-left: 0.75rem;
+  }
+
+  .sm\:ml-4 {
+    margin-left: 1rem;
+  }
+
+  .sm\:mt-0 {
+    margin-top: 0px;
+  }
+
+  .sm\:block {
+    display: block;
+  }
+
+  .sm\:inline-block {
+    display: inline-block;
+  }
+
+  .sm\:flex {
+    display: flex;
+  }
+
+  .sm\:h-10 {
+    height: 2.5rem;
+  }
+
+  .sm\:h-screen {
+    height: 100vh;
+  }
+
+  .sm\:w-10 {
+    width: 2.5rem;
+  }
+
+  .sm\:w-auto {
+    width: auto;
+  }
+
+  .sm\:max-w-lg {
+    max-width: 32rem;
+  }
+
+  .sm\:scale-100 {
+    --tw-scale-x: 1;
+    --tw-scale-y: 1;
+    transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
+  }
+
+  .sm\:scale-95 {
+    --tw-scale-x: .95;
+    --tw-scale-y: .95;
+    transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
+  }
+
+  .sm\:flex-row-reverse {
+    flex-direction: row-reverse;
+  }
+
+  .sm\:items-start {
+    align-items: flex-start;
+  }
+
+  .sm\:p-0 {
+    padding: 0px;
+  }
+
+  .sm\:p-6 {
+    padding: 1.5rem;
+  }
+
+  .sm\:px-6 {
+    padding-left: 1.5rem;
+    padding-right: 1.5rem;
+  }
+
+  .sm\:pb-4 {
+    padding-bottom: 1rem;
+  }
+
+  .sm\:text-left {
+    text-align: left;
+  }
+
+  .sm\:align-middle {
+    vertical-align: middle;
+  }
+
+  .sm\:text-sm {
+    font-size: 0.875rem;
+    line-height: 1.25rem;
+  }
+}
+
+@media (min-width: 768px) {
+  .md\:inline-block {
+    display: inline-block;
+  }
+
+  .md\:flex-row {
+    flex-direction: row;
+  }
+
+  .md\:px-0 {
+    padding-left: 0px;
+    padding-right: 0px;
+  }
+
+  .md\:pb-0 {
+    padding-bottom: 0px;
+  }
+}
+
+@media (prefers-color-scheme: dark) {
+  .dark\:border-neutral-500 {
+    --tw-border-opacity: 1;
+    border-color: rgb(115 115 115 / var(--tw-border-opacity));
+  }
+
+  .dark\:border-neutral-600 {
+    --tw-border-opacity: 1;
+    border-color: rgb(82 82 82 / var(--tw-border-opacity));
+  }
+
+  .dark\:border-neutral-800 {
+    --tw-border-opacity: 1;
+    border-color: rgb(38 38 38 / var(--tw-border-opacity));
+  }
+
+  .dark\:border-red-600 {
+    --tw-border-opacity: 1;
+    border-color: rgb(102 236 36 / var(--tw-border-opacity));
+  }
+
+  .dark\:bg-black {
+    --tw-bg-opacity: 1;
+    background-color: rgb(0 0 0 / var(--tw-bg-opacity));
+  }
+
+  .dark\:bg-neutral-400 {
+    --tw-bg-opacity: 1;
+    background-color: rgb(163 163 163 / var(--tw-bg-opacity));
+  }
+
+  .dark\:bg-neutral-500 {
+    --tw-bg-opacity: 1;
+    background-color: rgb(115 115 115 / var(--tw-bg-opacity));
+  }
+
+  .dark\:bg-neutral-600 {
+    --tw-bg-opacity: 1;
+    background-color: rgb(82 82 82 / var(--tw-bg-opacity));
+  }
+
+  .dark\:bg-neutral-700 {
+    --tw-bg-opacity: 1;
+    background-color: rgb(64 64 64 / var(--tw-bg-opacity));
+  }
+
+  .dark\:bg-neutral-800 {
+    --tw-bg-opacity: 1;
+    background-color: rgb(38 38 38 / var(--tw-bg-opacity));
+  }
+
+  .dark\:bg-red-100 {
+    --tw-bg-opacity: 1;
+    background-color: rgb(226 254 226 / var(--tw-bg-opacity));
+  }
+
+  .dark\:bg-red-600 {
+    --tw-bg-opacity: 1;
+    background-color: rgb(102 236 36 / var(--tw-bg-opacity));
+  }
+
+  .dark\:bg-red-800 {
+    --tw-bg-opacity: 1;
+    background-color: rgb(82 196 26 / var(--tw-bg-opacity));
+  }
+
+  .dark\:text-gray-500 {
+    --tw-text-opacity: 1;
+    color: rgb(107 114 128 / var(--tw-text-opacity));
+  }
+
+  .dark\:text-neutral-200 {
+    --tw-text-opacity: 1;
+    color: rgb(229 229 229 / var(--tw-text-opacity));
+  }
+
+  .dark\:text-neutral-300 {
+    --tw-text-opacity: 1;
+    color: rgb(212 212 212 / var(--tw-text-opacity));
+  }
+
+  .dark\:text-neutral-400 {
+    --tw-text-opacity: 1;
+    color: rgb(163 163 163 / var(--tw-text-opacity));
+  }
+
+  .dark\:text-neutral-50 {
+    --tw-text-opacity: 1;
+    color: rgb(250 250 250 / var(--tw-text-opacity));
+  }
+
+  .dark\:text-neutral-500 {
+    --tw-text-opacity: 1;
+    color: rgb(115 115 115 / var(--tw-text-opacity));
+  }
+
+  .dark\:text-neutral-600 {
+    --tw-text-opacity: 1;
+    color: rgb(82 82 82 / var(--tw-text-opacity));
+  }
+
+  .dark\:text-red-300 {
+    --tw-text-opacity: 1;
+    color: rgb(252 165 165 / var(--tw-text-opacity));
+  }
+
+  .dark\:text-red-600 {
+    --tw-text-opacity: 1;
+    color: rgb(102 236 36 / var(--tw-text-opacity));
+  }
+
+  .dark\:text-white {
+    --tw-text-opacity: 1;
+    color: rgb(255 255 255 / var(--tw-text-opacity));
+  }
+
+  .dark\:opacity-50 {
+    opacity: 0.5;
+  }
+
+  .dark\:placeholder\:text-neutral-400::-moz-placeholder {
+    --tw-text-opacity: 1;
+    color: rgb(163 163 163 / var(--tw-text-opacity));
+  }
+
+  .dark\:placeholder\:text-neutral-400::placeholder {
+    --tw-text-opacity: 1;
+    color: rgb(163 163 163 / var(--tw-text-opacity));
+  }
+
+  .dark\:placeholder\:text-neutral-500::-moz-placeholder {
+    --tw-text-opacity: 1;
+    color: rgb(115 115 115 / var(--tw-text-opacity));
+  }
+
+  .dark\:placeholder\:text-neutral-500::placeholder {
+    --tw-text-opacity: 1;
+    color: rgb(115 115 115 / var(--tw-text-opacity));
+  }
+
+  .dark\:hover\:border-neutral-600:hover {
+    --tw-border-opacity: 1;
+    border-color: rgb(82 82 82 / var(--tw-border-opacity));
+  }
+
+  .dark\:hover\:border-red-600:hover {
+    --tw-border-opacity: 1;
+    border-color: rgb(102 236 36 / var(--tw-border-opacity));
+  }
+
+  .dark\:hover\:bg-neutral-500:hover {
+    --tw-bg-opacity: 1;
+    background-color: rgb(115 115 115 / var(--tw-bg-opacity));
+  }
+
+  .dark\:hover\:bg-neutral-600:hover {
+    --tw-bg-opacity: 1;
+    background-color: rgb(82 82 82 / var(--tw-bg-opacity));
+  }
+
+  .dark\:hover\:bg-red-600:hover {
+    --tw-bg-opacity: 1;
+    background-color: rgb(102 236 36 / var(--tw-bg-opacity));
+  }
+
+  .dark\:hover\:bg-red-700:hover {
+    --tw-bg-opacity: 1;
+    background-color: rgb(102 216 36 / var(--tw-bg-opacity));
+  }
+
+  .dark\:hover\:bg-red-800:hover {
+    --tw-bg-opacity: 1;
+    background-color: rgb(82 196 26 / var(--tw-bg-opacity));
+  }
+
+  .dark\:hover\:text-neutral-700:hover {
+    --tw-text-opacity: 1;
+    color: rgb(64 64 64 / var(--tw-text-opacity));
+  }
+
+  .dark\:hover\:text-red-100:hover {
+    --tw-text-opacity: 1;
+    color: rgb(226 254 226 / var(--tw-text-opacity));
+  }
+
+  .dark\:hover\:text-white:hover {
+    --tw-text-opacity: 1;
+    color: rgb(255 255 255 / var(--tw-text-opacity));
+  }
+
+  .dark\:focus\:border-neutral-500:focus {
+    --tw-border-opacity: 1;
+    border-color: rgb(115 115 115 / var(--tw-border-opacity));
+  }
+
+  .dark\:focus\:border-red-800:focus {
+    --tw-border-opacity: 1;
+    border-color: rgb(82 196 26 / var(--tw-border-opacity));
+  }
+
+  .focus\:dark\:border-neutral-500:focus {
+    --tw-border-opacity: 1;
+    border-color: rgb(115 115 115 / var(--tw-border-opacity));
+  }
+}

二进制
src/pkg/web/www/img/favicon.ico


二进制
src/pkg/web/www/img/logo.png


+ 494 - 0
src/pkg/web/www/index.html

@@ -0,0 +1,494 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+  <title>LTWG</title>
+  <link href="./css/app.css" rel="stylesheet">
+  <link rel="manifest" href="./manifest.json">
+  <link rel="shortcut icon" href="./img/favicon.ico">
+  <link rel="apple-touch-icon" href="./img/logo.png">
+  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+  <meta name="apple-mobile-web-app-capable" content="yes">
+</head>
+<style>
+  [v-cloak] {
+    display: none;
+  }
+</style>
+
+<body class="bg-gray-50 dark:bg-neutral-800">
+
+  <div id="app">
+
+    <div v-cloak class="container mx-auto max-w-3xl px-5 md:px-0">
+
+      <div v-if="authenticated === true">
+        <span v-if="requiresPassword"
+          class="text-sm text-gray-400 dark:text-neutral-400 mb-10 mr-2 mt-3 cursor-pointer hover:underline float-right"
+          @click="logout">
+          {{$t("logout")}}
+
+          <svg class="h-3 inline" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
+            stroke="currentColor">
+            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
+              d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
+          </svg>
+        </span>
+        <h1 class="text-4xl dark:text-neutral-200 font-medium mt-2 mb-2">
+          <img src="./img/logo.png" width="32" class="inline align-middle dark:bg" />
+          <span class="align-middle">LTWG</span>
+        </h1>
+        <h2 class="text-sm text-gray-400 dark:text-neutral-400 mb-10"></h2>
+
+        <div class="shadow-md rounded-lg bg-white dark:bg-neutral-700 overflow-hidden">
+          <div class="flex flex-row flex-auto items-center p-3 px-5 border-b-2 border-gray-100 dark:border-neutral-600">
+            <div class="flex-grow">
+              <p class="text-2xl font-medium dark:text-neutral-200">{{$t("clients")}}</p>
+            </div>
+            <div class="flex-shrink-0">
+              <button @click="clientCreate = true; clientCreateName = '';"
+                class="hover:bg-red-800 hover:border-red-800 hover:text-white text-gray-700 dark:text-neutral-200 border-2 border-gray-100 dark:border-neutral-600 py-2 px-4 rounded inline-flex items-center transition">
+                <svg class="w-4 mr-2" inline xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
+                  stroke="currentColor">
+                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
+                    d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
+                </svg>
+                <span class="text-sm">{{$t("new")}}</span>
+              </button>
+            </div>
+          </div>
+
+          <div>
+            <!-- Client -->
+            <div v-if="clients && clients.length > 0" v-for="client in clients" :key="client.id"
+              class="relative overflow-hidden border-b last:border-b-0 border-gray-100 dark:border-neutral-600 border-solid">
+
+              <!-- Chart -->
+              <div class="absolute z-0 bottom-0 left-0 right-0" style="top: 60%;">
+                <apexchart width="100%" height="100%" :options="client.chartOptions" :series="client.transferTxSeries">
+                </apexchart>
+              </div>
+              <div class="absolute z-0 top-0 left-0 right-0" style="bottom: 60%;">
+                <apexchart width="100%" height="100%" :options="client.chartOptions" :series="client.transferRxSeries"
+                  style="transform: scaleY(-1);">
+                </apexchart>
+              </div>
+              <div class="relative p-5 z-10 flex flex-col md:flex-row justify-between">
+                <div class="flex items-center pb-2 md:pb-0">
+                  <div class="h-10 w-10 mr-5 rounded-full bg-gray-50 relative">
+                    <svg class="w-6 m-2 text-gray-300" xmlns="http://www.w3.org/2000/svg"
+                      viewBox="0 0 20 20" fill="currentColor">
+                      <path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
+                        clip-rule="evenodd" />
+                    </svg>
+                    <img v-if="client.avatar" :src="client.avatar" class="w-10 rounded-full absolute top-0 left-0" />
+
+                    <div
+                      v-if="client.latestHandshakeAt && ((new Date() - new Date(client.latestHandshakeAt) < 1000 * 60 * 10))">
+                      <div
+                        class="animate-ping w-4 h-4 p-1 bg-red-100 dark:bg-red-100 rounded-full absolute -bottom-1 -right-1">
+                      </div>
+                      <div class="w-2 h-2 bg-red-800 dark:bg-red-600 rounded-full absolute bottom-0 right-0"></div>
+                    </div>
+                  </div>
+
+                  <div class="flex-grow">
+
+                    <!-- Name -->
+                    <div class="text-gray-700 dark:text-neutral-200 group"
+                      :title="$t('createdOn') + dateTime(new Date(client.createdAt))">
+
+                      <!-- Show -->
+                      <input v-show="clientEditNameId === client.id" v-model="clientEditName"
+                        v-on:keyup.enter="updateClientName(client, clientEditName); clientEditName = null; clientEditNameId = null;"
+                        v-on:keyup.escape="clientEditName = null; clientEditNameId = null;"
+                        :ref="'client-' + client.id + '-name'"
+                        class="rounded px-1 border-2 dark:bg-neutral-700 border-gray-100 dark:border-neutral-600 focus:border-gray-200 dark:focus:border-neutral-500 dark:placeholder:text-neutral-500 outline-none w-30" />
+                      <span v-show="clientEditNameId !== client.id"
+                        class="inline-block border-t-2 border-b-2 border-transparent">{{client.name}}</span>
+
+                      <!-- Edit -->
+                      <span v-show="clientEditNameId !== client.id"
+                        @click="clientEditName = client.name; clientEditNameId = client.id; setTimeout(() => $refs['client-' + client.id + '-name'][0].select(), 1);"
+                        class="cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity">
+                        <svg xmlns="http://www.w3.org/2000/svg"
+                          class="h-4 w-4 inline align-middle opacity-25 hover:opacity-100" fill="none"
+                          viewBox="0 0 24 24" stroke="currentColor">
+                          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
+                            d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
+                        </svg>
+                      </span>
+                    </div>
+
+                    <!-- Info -->
+                    <div class="text-gray-400 dark:text-neutral-400 text-xs">
+
+                      <!-- Address -->
+                      <span class="group block md:inline-block pb-1 md:pb-0">
+
+                        <!-- Show -->
+                        <input v-show="clientEditAddressId === client.id" v-model="clientEditAddress"
+                          v-on:keyup.enter="updateClientAddress(client, clientEditAddress); clientEditAddress = null; clientEditAddressId = null;"
+                          v-on:keyup.escape="clientEditAddress = null; clientEditAddressId = null;"
+                          :ref="'client-' + client.id + '-address'"
+                          class="rounded border-2 dark:bg-neutral-700 border-gray-100 dark:border-neutral-600 focus:border-gray-200 dark:focus:border-neutral-500 outline-none w-20 text-black dark:text-neutral-300 dark:placeholder:text-neutral-500" />
+                        <span v-show="clientEditAddressId !== client.id"
+                          class="inline-block border-t-2 border-b-2 border-transparent">{{client.address}}</span>
+
+                        <!-- Edit -->
+                        <span v-show="clientEditAddressId !== client.id"
+                          @click="clientEditAddress = client.address; clientEditAddressId = client.id; setTimeout(() => $refs['client-' + client.id + '-address'][0].select(), 1);"
+                          class="cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity">
+                          <svg xmlns="http://www.w3.org/2000/svg"
+                            class="h-4 w-4 inline align-middle opacity-25 hover:opacity-100" fill="none"
+                            viewBox="0 0 24 24" stroke="currentColor">
+                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
+                              d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
+                          </svg>
+                        </span>
+                      </span>
+
+                      <!-- Transfer TX -->
+                      <span v-if="client.transferTx" :title="$t('totalDownload') + bytes(client.transferTx)">
+                        ·
+                        <svg class="align-middle h-3 inline" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
+                          fill="currentColor">
+                          <path fill-rule="evenodd"
+                            d="M16.707 10.293a1 1 0 010 1.414l-6 6a1 1 0 01-1.414 0l-6-6a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l4.293-4.293a1 1 0 011.414 0z"
+                            clip-rule="evenodd" />
+                        </svg>
+                        {{client.transferTxCurrent | bytes}}/s
+                      </span>
+
+                      <!-- Transfer RX -->
+                      <span v-if="client.transferRx" :title="$t('totalUpload') + bytes(client.transferRx)">
+                        ·
+                        <svg class="align-middle h-3 inline" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
+                          fill="currentColor">
+                          <path fill-rule="evenodd"
+                            d="M3.293 9.707a1 1 0 010-1.414l6-6a1 1 0 011.414 0l6 6a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L4.707 9.707a1 1 0 01-1.414 0z"
+                            clip-rule="evenodd" />
+                        </svg>
+                        {{client.transferRxCurrent | bytes}}/s
+                      </span>
+
+                      <!-- Last seen -->
+                      <span v-if="client.latestHandshakeAt"
+                        :title="$t('lastSeen') + dateTime(new Date(client.latestHandshakeAt))">
+                        · {{new Date(client.latestHandshakeAt) | timeago}}
+                      </span>
+                    </div>
+                  </div>
+                </div>
+
+                <div class="flex items-center justify-end">
+                  <div class="text-gray-400 dark:text-neutral-400 flex gap-1 items-center justify-between">
+
+                    <!-- Enable/Disable -->
+                    <div @click="disableClient(client)" v-if="client.enabled === true" :title="$t('disableClient')"
+                      class="inline-block align-middle rounded-full w-10 h-6 mr-1 bg-red-800 cursor-pointer hover:bg-red-700 transition-all">
+                      <div class="rounded-full w-4 h-4 m-1 ml-5 bg-white"></div>
+                    </div>
+
+                    <div @click="enableClient(client)" v-if="client.enabled === false" :title="$t('enableClient')"
+                      class="inline-block align-middle rounded-full w-10 h-6 mr-1 bg-gray-200 dark:bg-neutral-400 cursor-pointer hover:bg-gray-300 dark:hover:bg-neutral-500 transition-all">
+
+                      <div class="rounded-full w-4 h-4 m-1 bg-white"></div>
+                    </div>
+
+                    <!-- Show QR-->
+
+                    <button
+                      class="align-middle bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 hover:bg-red-800 dark:hover:bg-red-800 hover:text-white dark:hover:text-white p-2 rounded transition"
+                      :title="$t('showQR')" @click="qrcode = `./api/wireguard/client/${client.id}/qrcode.svg`">
+                      <svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
+                        stroke="currentColor">
+                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
+                          d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
+                      </svg>
+                    </button>
+
+                    <!-- Download Config -->
+                    <a :href="'./api/wireguard/client/' + client.id + '/configuration'" download
+                      class="align-middle inline-block bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 hover:bg-red-800 dark:hover:bg-red-800 hover:text-white dark:hover:text-white p-2 rounded transition"
+                      :title="$t('downloadConfig')">
+                      <svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
+                        stroke="currentColor">
+                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
+                          d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
+                      </svg>
+                    </a>
+
+                    <!-- Delete -->
+
+                    <button
+                      class="align-middle bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 hover:bg-red-800 dark:hover:bg-red-800 hover:text-white dark:hover:text-white p-2 rounded transition"
+                      :title="$t('deleteClient')" @click="clientDelete = client">
+                      <svg class="w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
+                        <path fill-rule="evenodd"
+                          d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
+                          clip-rule="evenodd" />
+                      </svg>
+                    </button>
+                  </div>
+                </div>
+
+              </div>
+
+            </div>
+            <div v-if="clients && clients.length === 0">
+              <p class="text-center m-10 text-gray-400 dark:text-neutral-400 text-sm">
+                {{$t("noClients")}}<br /><br />
+                <button @click="clientCreate = true; clientCreateName = '';"
+                  class="bg-red-800 hover:bg-red-700 text-white border-2 border-none py-2 px-4 rounded inline-flex items-center transition">
+                  <svg class="w-4 mr-2" inline xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
+                    stroke="currentColor">
+                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
+                      d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
+                  </svg>
+                  <span class="text-sm">{{$t("newClient")}}</span>
+                </button>
+              </p>
+            </div>
+            <div v-if="clients === null" class="text-gray-200 dark:text-red-300 p-5">
+              <svg class="w-5 animate-spin mx-auto" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
+                fill="currentColor">
+                <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
+                <path class="opacity-75" fill="currentColor"
+                  d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
+                </path>
+              </svg>
+            </div>
+          </div>
+        </div>
+
+        <!-- QR Code-->
+        <div v-if="qrcode">
+          <div class="bg-black bg-opacity-50 fixed top-0 right-0 left-0 bottom-0 flex items-center justify-center z-20">
+            <div class="bg-white rounded-md shadow-lg relative p-8">
+              <button @click="qrcode = null"
+                class="absolute right-2 top-2 text-gray-600 dark:text-neutral-500 hover:text-gray-800 dark:hover:text-neutral-700">
+                <svg class="w-8" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
+                  stroke="currentColor">
+                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
+                </svg>
+              </button>
+              <img :src="qrcode" />
+            </div>
+          </div>
+        </div>
+
+        <!-- Create Dialog -->
+        <div v-if="clientCreate" class="fixed z-10 inset-0 overflow-y-auto">
+          <div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
+            <!--
+        Background overlay, show/hide based on modal state.
+
+        Entering: "ease-out duration-300"
+          From: "opacity-0"
+          To: "opacity-100"
+        Leaving: "ease-in duration-200"
+          From: "opacity-100"
+          To: "opacity-0"
+      -->
+            <div class="fixed inset-0 transition-opacity" aria-hidden="true">
+              <div class="absolute inset-0 bg-gray-500 dark:bg-black opacity-75 dark:opacity-50"></div>
+            </div>
+
+            <!-- This element is to trick the browser into centering the modal contents. -->
+            <span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
+            <!--
+        Modal panel, show/hide based on modal state.
+
+        Entering: "ease-out duration-300"
+          From: "opacity-0 tranneutral-y-4 sm:tranneutral-y-0 sm:scale-95"
+          To: "opacity-100 tranneutral-y-0 sm:scale-100"
+        Leaving: "ease-in duration-200"
+          From: "opacity-100 tranneutral-y-0 sm:scale-100"
+          To: "opacity-0 tranneutral-y-4 sm:tranneutral-y-0 sm:scale-95"
+      -->
+            <div
+              class="inline-block align-bottom bg-white dark:bg-neutral-700 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full"
+              role="dialog" aria-modal="true" aria-labelledby="modal-headline">
+              <div class="bg-white dark:bg-neutral-700 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
+                <div class="sm:flex sm:items-start">
+                  <div
+                    class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-800 sm:mx-0 sm:h-10 sm:w-10">
+                    <svg class="h-6 w-6 text-white" inline xmlns="http://www.w3.org/2000/svg"
+                      fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
+                        d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
+                    </svg>
+                  </div>
+                  <div class="flex-grow mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
+                    <h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-neutral-200" id="modal-headline">
+                      {{$t("newClient")}}
+                    </h3>
+                    <div class="mt-2">
+                      <p class="text-sm text-gray-500">
+                        <input
+                          class="rounded p-2 border-2 dark:bg-neutral-700 dark:text-neutral-200 border-gray-100 dark:border-neutral-600 focus:border-gray-200 focus:dark:border-neutral-500 dark:placeholder:text-neutral-400 outline-none w-full"
+                          type="text" v-model.trim="clientCreateName" :placeholder="$t('name')" />
+                      </p>
+                    </div>
+                  </div>
+                </div>
+              </div>
+              <div class="bg-gray-50 dark:bg-neutral-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
+                <button v-if="clientCreateName.length" type="button" @click="createClient(); clientCreate = null"
+                  class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-800 text-base font-medium text-white hover:bg-red-700 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm">
+                  {{$t("create")}}
+                </button>
+                <button v-else type="button"
+                  class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-gray-200 dark:bg-neutral-400 text-base font-medium text-white dark:text-neutral-300 sm:ml-3 sm:w-auto sm:text-sm cursor-not-allowed">
+                  {{$t("create")}}
+                </button>
+                <button type="button" @click="clientCreate = null"
+                  class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-neutral-500 shadow-sm px-4 py-2 bg-white dark:bg-neutral-500 text-base font-medium text-gray-700 dark:text-neutral-50 hover:bg-gray-50 dark:hover:bg-neutral-600 dark:hover:border-neutral-600 focus:outline-none sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
+                  {{$t("cancel")}}
+                </button>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <!-- Delete Dialog -->
+        <div v-if="clientDelete" class="fixed z-10 inset-0 overflow-y-auto">
+          <div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
+            <!--
+        Background overlay, show/hide based on modal state.
+
+        Entering: "ease-out duration-300"
+          From: "opacity-0"
+          To: "opacity-100"
+        Leaving: "ease-in duration-200"
+          From: "opacity-100"
+          To: "opacity-0"
+      -->
+            <div class="fixed inset-0 transition-opacity" aria-hidden="true">
+              <div class="absolute inset-0 bg-gray-500 dark:bg-black opacity-75 dark:opacity-50"></div>
+            </div>
+
+            <!-- This element is to trick the browser into centering the modal contents. -->
+            <span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
+            <!--
+        Modal panel, show/hide based on modal state.
+
+        Entering: "ease-out duration-300"
+          From: "opacity-0 tranneutral-y-4 sm:tranneutral-y-0 sm:scale-95"
+          To: "opacity-100 tranneutral-y-0 sm:scale-100"
+        Leaving: "ease-in duration-200"
+          From: "opacity-100 tranneutral-y-0 sm:scale-100"
+          To: "opacity-0 tranneutral-y-4 sm:tranneutral-y-0 sm:scale-95"
+      -->
+            <div
+              class="inline-block align-bottom bg-white dark:bg-neutral-700 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full"
+              role="dialog" aria-modal="true" aria-labelledby="modal-headline">
+              <div class="bg-white dark:bg-neutral-700 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
+                <div class="sm:flex sm:items-start">
+                  <div
+                    class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
+                    <!-- Heroicon name: outline/exclamation -->
+                    <svg class="h-6 w-6 text-red-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
+                      stroke="currentColor" aria-hidden="true">
+                      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
+                        d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
+                    </svg>
+                  </div>
+                  <div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
+                    <h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-neutral-200" id="modal-headline">
+                      {{$t("deleteClient")}}
+                    </h3>
+                    <div class="mt-2">
+                      <p class="text-sm text-gray-500 dark:text-neutral-300">
+                        {{$t("deleteDialog1")}} <strong>{{clientDelete.name}}</strong>? {{$t("deleteDialog2")}}
+                      </p>
+                    </div>
+                  </div>
+                </div>
+              </div>
+              <div class="bg-gray-50 dark:bg-neutral-600 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
+                <button type="button" @click="deleteClient(clientDelete); clientDelete = null"
+                  class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 dark:bg-red-600 text-base font-medium text-white dark:text-white hover:bg-red-700 dark:hover:bg-red-700 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm">
+                  {{$t("deleteClient")}}
+                </button>
+                <button type="button" @click="clientDelete = null"
+                  class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-neutral-500 shadow-sm px-4 py-2 bg-white dark:bg-neutral-500 text-base font-medium text-gray-700 dark:text-neutral-50 hover:bg-gray-50 dark:hover:bg-neutral-600 dark:hover:border-neutral-600 focus:outline-none sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
+                  {{$t("cancel")}}
+                </button>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div v-if="authenticated === false">
+        <h1 class="text-4xl font-medium my-16 text-gray-700 dark:text-neutral-200 text-center">
+          <img src="./img/logo.png" width="32" class="inline align-middle dark:bg" />
+          <span class="align-middle">LTWG</span>
+        </h1>
+
+        <form @submit="login"
+          class="shadow rounded-md bg-white dark:bg-neutral-700 mx-auto w-64 p-5 overflow-hidden mt-10">
+          <!-- Avatar -->
+          <div class="h-20 w-20 mb-10 mt-5 mx-auto rounded-full bg-red-800 dark:bg-red-800 relative overflow-hidden">
+            <svg class="w-10 h-10 m-5 text-white dark:text-white" xmlns="http://www.w3.org/2000/svg"
+              viewBox="0 0 20 20" fill="currentColor">
+              <path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd" />
+            </svg>
+          </div>
+
+          <input type="password" name="password" :placeholder="$t('password')" v-model="password"
+            class="px-3 py-2 text-sm dark:bg-neutral-700 text-gray-500 dark:text-gray-500 mb-5 border-2 border-gray-100 dark:border-neutral-800 rounded-lg w-full focus:border-red-800 dark:focus:border-red-800 dark:placeholder:text-neutral-400 outline-none" />
+
+          <button v-if="authenticating"
+            class="bg-red-800 dark:bg-red-800 w-full rounded shadow py-2 text-sm text-white dark:text-white cursor-not-allowed">
+            <svg class="w-5 animate-spin mx-auto" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
+              fill="currentColor">
+              <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
+              <path class="opacity-75" fill="currentColor"
+                d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
+              </path>
+            </svg>
+          </button>
+          <input v-if="!authenticating && password" type="submit"
+            class="bg-red-800 dark:bg-red-800 w-full rounded shadow py-2 text-sm text-white dark:text-white hover:bg-red-700 dark:hover:bg-red-700 transition cursor-pointer"
+            :value="$t('signIn')">
+          <input v-if="!authenticating && !password" type="submit"
+            class="bg-gray-200 dark:bg-neutral-800 w-full rounded shadow py-2 text-sm text-white dark:text-white cursor-not-allowed"
+            :value="$t('signIn')">
+        </form>
+      </div>
+
+      <div v-if="authenticated === null" class="text-gray-300 dark:text-red-300 pt-24 pb-12">
+
+        <svg class="w-5 animate-spin mx-auto" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
+          fill="currentColor">
+          <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
+          <path class="opacity-75" fill="currentColor"
+            d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
+          </path>
+        </svg>
+
+      </div>
+
+    </div>
+
+    <p v-cloak class="text-center m-10 text-gray-300 dark:text-neutral-600 text-xs">
+      <a class="hover:underline" target="_blank" href="https://git.lvtao.net/memory/ltwg.git">LTWG WireGuard Server</a>
+      © 2024
+    </p>
+  </div>
+
+  <script src="./js/vendor/vue.min.js"></script>
+  <script src="./js/vendor/vue-i18n.min.js"></script>
+  <script src="./js/vendor/apexcharts.min.js"></script>
+  <script src="./js/vendor/vue-apexcharts.min.js"></script>
+  <script src="./js/vendor/sha512.min.js"></script>
+  <script src="./js/vendor/timeago.full.min.js"></script>
+  <script src="./js/api.js"></script>
+  <script src="./js/i18n.js"></script>
+  <script src="./js/app.js"></script>
+</body>
+
+</html>

+ 127 - 0
src/pkg/web/www/js/api.js

@@ -0,0 +1,127 @@
+/* eslint-disable no-unused-vars */
+/* eslint-disable no-undef */
+
+'use strict';
+
+class API {
+
+  async call({ method, path, body }) {
+    const res = await fetch(`./api${path}`, {
+      method,
+      headers: {
+        'Content-Type': 'application/json',
+      },
+      body: body
+        ? JSON.stringify(body)
+        : undefined,
+    });
+
+    if (res.status === 204) {
+      return undefined;
+    }
+
+    const json = await res.json();
+
+    if (!res.ok) {
+      throw new Error(json.error || res.statusText);
+    }
+
+    return json;
+  }
+
+  async getVersion() {
+    return this.call({
+      method: 'get',
+      path: '/version',
+    });
+  }
+
+  async getLang() {
+    return this.call({
+      method: 'get',
+      path: '/lang',
+    });
+  }
+
+  async getSession() {
+    return this.call({
+      method: 'get',
+      path: '/session',
+    });
+  }
+
+  async createSession({ password }) {
+    return this.call({
+      method: 'post',
+      path: '/session',
+      body: { password },
+    });
+  }
+
+  async deleteSession() {
+    return this.call({
+      method: 'delete',
+      path: '/session',
+    });
+  }
+
+  async getClients() {
+    return this.call({
+      method: 'get',
+      path: '/wireguard/client',
+    }).then((clients) => clients.map((client) => ({
+      ...client,
+      createdAt: new Date(client.createdAt),
+      updatedAt: new Date(client.updatedAt),
+      latestHandshakeAt: client.latestHandshakeAt !== null
+        ? new Date(client.latestHandshakeAt)
+        : null,
+    })));
+  }
+
+  async createClient({ name }) {
+    return this.call({
+      method: 'post',
+      path: '/wireguard/client',
+      body: { name },
+    });
+  }
+
+  async deleteClient({ clientId }) {
+    return this.call({
+      method: 'delete',
+      path: `/wireguard/client/${clientId}`,
+    });
+  }
+
+  async enableClient({ clientId }) {
+    return this.call({
+      method: 'post',
+      path: `/wireguard/client/${clientId}/enable`,
+    });
+  }
+
+  async disableClient({ clientId }) {
+    return this.call({
+      method: 'post',
+      path: `/wireguard/client/${clientId}/disable`,
+    });
+  }
+
+  async updateClientName({ clientId, name }) {
+    return this.call({
+      method: 'put',
+      path: `/wireguard/client/${clientId}/name`,
+      body: { name },
+    });
+  }
+
+  async updateClientAddress({ clientId, address }) {
+    return this.call({
+      method: 'put',
+      path: `/wireguard/client/${clientId}/address`,
+      body: { address },
+    });
+  }
+
+}

+ 294 - 0
src/pkg/web/www/js/app.js

@@ -0,0 +1,294 @@
+/* eslint-disable no-console */
+/* eslint-disable no-alert */
+/* eslint-disable no-undef */
+/* eslint-disable no-new */
+
+'use strict';
+
+function bytes(bytes, decimals, kib, maxunit) {
+  kib = kib || false;
+  if (bytes === 0) return '0 B';
+  if (Number.isNaN(parseFloat(bytes)) && !Number.isFinite(bytes)) return 'NaN';
+  const k = kib ? 1024 : 1000;
+  const dm = decimals != null && !Number.isNaN(decimals) && decimals >= 0 ? decimals : 2;
+  const sizes = kib
+    ? ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB', 'BiB']
+    : ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'BB'];
+  let i = Math.floor(Math.log(bytes) / Math.log(k));
+  if (maxunit !== undefined) {
+    const index = sizes.indexOf(maxunit);
+    if (index !== -1) i = index;
+  }
+  // eslint-disable-next-line no-restricted-properties
+  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
+}
+
+const i18n = new VueI18n({
+  locale: localStorage.getItem('lang') || 'en',
+  fallbackLocale: 'en',
+  messages,
+});
+
+new Vue({
+  el: '#app',
+  i18n,
+  data: {
+    authenticated: null,
+    authenticating: false,
+    password: null,
+    requiresPassword: null,
+
+    clients: null,
+    clientsPersist: {},
+    clientDelete: null,
+    clientCreate: null,
+    clientCreateName: '',
+    clientEditName: null,
+    clientEditNameId: null,
+    clientEditAddress: null,
+    clientEditAddressId: null,
+    qrcode: null,
+
+    currentRelease: null,
+
+    isDark: null,
+
+    chartOptions: {
+      chart: {
+        background: 'transparent',
+        type: 'bar',
+        stacked: false,
+        toolbar: {
+          show: false,
+        },
+        animations: {
+          enabled: false,
+        },
+      },
+      colors: [
+        '#DDDDDD', // rx
+        '#EEEEEE', // tx
+      ],
+      dataLabels: {
+        enabled: false,
+      },
+      plotOptions: {
+        bar: {
+          horizontal: false,
+        },
+      },
+      xaxis: {
+        labels: {
+          show: false,
+        },
+        axisTicks: {
+          show: true,
+        },
+        axisBorder: {
+          show: true,
+        },
+      },
+      yaxis: {
+        labels: {
+          show: false,
+        },
+        min: 0,
+      },
+      tooltip: {
+        enabled: false,
+      },
+      legend: {
+        show: false,
+      },
+      grid: {
+        show: false,
+        padding: {
+          left: -10,
+          right: 0,
+          bottom: -15,
+          top: -15,
+        },
+        column: {
+          opacity: 0,
+        },
+        xaxis: {
+          lines: {
+            show: false,
+          },
+        },
+      },
+    },
+  },
+  methods: {
+    dateTime: (value) => {
+      return new Intl.DateTimeFormat(undefined, {
+        year: 'numeric',
+        month: 'short',
+        day: 'numeric',
+        hour: 'numeric',
+        minute: 'numeric',
+      }).format(value);
+    },
+    async refresh({
+      updateCharts = false,
+    } = {}) {
+      if (!this.authenticated) return;
+
+      const clients = await this.api.getClients();
+      this.clients = clients.map((client) => {
+        if (client.name.includes('@') && client.name.includes('.')) {
+          client.avatar = `https://www.gravatar.com/avatar/${sha512(client.name)}?d=blank`;
+        }
+
+        if (!this.clientsPersist[client.id]) {
+          this.clientsPersist[client.id] = {};
+          this.clientsPersist[client.id].transferRxHistory = Array(50).fill(0);
+          this.clientsPersist[client.id].transferRxPrevious = client.transferRx;
+          this.clientsPersist[client.id].transferTxHistory = Array(50).fill(0);
+          this.clientsPersist[client.id].transferTxPrevious = client.transferTx;
+        }
+
+        // Debug
+        // client.transferRx = this.clientsPersist[client.id].transferRxPrevious + Math.random() * 1000;
+        // client.transferTx = this.clientsPersist[client.id].transferTxPrevious + Math.random() * 1000;
+
+        if (updateCharts) {
+          this.clientsPersist[client.id].transferRxCurrent = client.transferRx - this.clientsPersist[client.id].transferRxPrevious;
+          this.clientsPersist[client.id].transferRxPrevious = client.transferRx;
+          this.clientsPersist[client.id].transferTxCurrent = client.transferTx - this.clientsPersist[client.id].transferTxPrevious;
+          this.clientsPersist[client.id].transferTxPrevious = client.transferTx;
+
+          this.clientsPersist[client.id].transferRxHistory.push(this.clientsPersist[client.id].transferRxCurrent);
+          this.clientsPersist[client.id].transferRxHistory.shift();
+
+          this.clientsPersist[client.id].transferTxHistory.push(this.clientsPersist[client.id].transferTxCurrent);
+          this.clientsPersist[client.id].transferTxHistory.shift();
+        }
+
+        client.transferTxCurrent = this.clientsPersist[client.id].transferTxCurrent;
+        client.transferRxCurrent = this.clientsPersist[client.id].transferRxCurrent;
+
+        client.transferTxHistory = this.clientsPersist[client.id].transferTxHistory;
+        client.transferRxHistory = this.clientsPersist[client.id].transferRxHistory;
+        client.transferMax = Math.max(...client.transferTxHistory, ...client.transferRxHistory);
+
+        client.hoverTx = this.clientsPersist[client.id].hoverTx;
+        client.hoverRx = this.clientsPersist[client.id].hoverRx;
+
+        return client;
+      });
+    },
+    login(e) {
+      e.preventDefault();
+
+      if (!this.password) return;
+      if (this.authenticating) return;
+
+      this.authenticating = true;
+      this.api.createSession({
+        password: this.password,
+      })
+        .then(async () => {
+          const session = await this.api.getSession();
+          this.authenticated = session.authenticated;
+          this.requiresPassword = session.requiresPassword;
+          return this.refresh();
+        })
+        .catch((err) => {
+          alert(err.message || err.toString());
+        })
+        .finally(() => {
+          this.authenticating = false;
+          this.password = null;
+        });
+    },
+    logout(e) {
+      e.preventDefault();
+
+      this.api.deleteSession()
+        .then(() => {
+          this.authenticated = false;
+          this.clients = null;
+        })
+        .catch((err) => {
+          alert(err.message || err.toString());
+        });
+    },
+    createClient() {
+      const name = this.clientCreateName;
+      if (!name) return;
+
+      this.api.createClient({ name })
+        .catch((err) => alert(err.message || err.toString()))
+        .finally(() => this.refresh().catch(console.error));
+    },
+    deleteClient(client) {
+      this.api.deleteClient({ clientId: client.id })
+        .catch((err) => alert(err.message || err.toString()))
+        .finally(() => this.refresh().catch(console.error));
+    },
+    enableClient(client) {
+      this.api.enableClient({ clientId: client.id })
+        .catch((err) => alert(err.message || err.toString()))
+        .finally(() => this.refresh().catch(console.error));
+    },
+    disableClient(client) {
+      this.api.disableClient({ clientId: client.id })
+        .catch((err) => alert(err.message || err.toString()))
+        .finally(() => this.refresh().catch(console.error));
+    },
+    updateClientName(client, name) {
+      this.api.updateClientName({ clientId: client.id, name })
+        .catch((err) => alert(err.message || err.toString()))
+        .finally(() => this.refresh().catch(console.error));
+    },
+    updateClientAddress(client, address) {
+      this.api.updateClientAddress({ clientId: client.id, address })
+        .catch((err) => alert(err.message || err.toString()))
+        .finally(() => this.refresh().catch(console.error));
+    },
+    toggleTheme() {
+      if (this.isDark) {
+        localStorage.theme = 'light';
+        document.documentElement.classList.remove('dark');
+      } else {
+        localStorage.theme = 'dark';
+        document.documentElement.classList.add('dark');
+      }
+      this.isDark = !this.isDark;
+    },
+  },
+  filters: {
+    bytes,
+    timeago: (value) => {
+      return timeago.format(value, i18n.locale);
+    },
+  },
+  mounted() {
+    this.isDark = false;
+    if (localStorage.theme === 'dark') {
+      this.isDark = true;
+    }
+
+    this.api = new API();
+    this.api.getSession()
+      .then((session) => {
+        this.authenticated = session.authenticated;
+        this.requiresPassword = session.requiresPassword;
+        this.refresh({
+          updateCharts: true,
+        }).catch((err) => {
+          alert(err.message || err.toString());
+        });
+      })
+      .catch((err) => {
+        alert(err.message || err.toString());
+      });
+
+    setInterval(() => {
+      this.refresh({
+        updateCharts: true,
+      }).catch(console.error);
+    }, 1000);
+  },
+});

+ 56 - 0
src/pkg/web/www/js/i18n.js

@@ -0,0 +1,56 @@
+'use strict';
+
+const messages = { // eslint-disable-line no-unused-vars
+  en: {
+    name: 'Name',
+    password: 'Password',
+    signIn: 'Sign In',
+    logout: 'Logout',
+    updateAvailable: 'There is an update available!',
+    update: 'Update',
+    clients: 'Clients',
+    new: 'New',
+    deleteClient: 'Delete Client',
+    deleteDialog1: 'Are you sure you want to delete',
+    deleteDialog2: 'This action cannot be undone.',
+    cancel: 'Cancel',
+    create: 'Create',
+    createdOn: 'Created on ',
+    lastSeen: 'Last seen on ',
+    totalDownload: 'Total Download: ',
+    totalUpload: 'Total Upload: ',
+    newClient: 'New Client',
+    disableClient: 'Disable Client',
+    enableClient: 'Enable Client',
+    noClients: 'There are no clients yet.',
+    showQR: 'Show QR Code',
+    downloadConfig: 'Download Configuration',
+    madeBy: 'Made by',
+  },
+  cn: {
+    name: '名称',
+    password: '密码',
+    signIn: '登录',
+    logout: '退出',
+    updateAvailable: '有一个更新可用!',
+    update: '更新',
+    clients: '客户端',
+    new: '新建',
+    deleteClient: '删除',
+    deleteDialog1: '你确定要删除',
+    deleteDialog2: '操作不可恢复。',
+    cancel: '取消',
+    create: '创建',
+    createdOn: '创建于',
+    lastSeen: '最后见于',
+    totalDownload: '总下载: ',
+    totalUpload: '总上传: ',
+    newClient: '新客户端',
+    disableClient: '禁用客户端',
+    enableClient: '启用客户端',
+    noClients: '尚无客户端。',
+    showQR: '显示二维码',
+    downloadConfig: '下载配置',
+    madeBy: '提供自',
+  },
+};

文件差异内容过多而无法显示
+ 5 - 0
src/pkg/web/www/js/vendor/apexcharts.min.js


文件差异内容过多而无法显示
+ 0 - 0
src/pkg/web/www/js/vendor/sha512.min.js


文件差异内容过多而无法显示
+ 0 - 0
src/pkg/web/www/js/vendor/timeago.full.min.js


文件差异内容过多而无法显示
+ 6 - 0
src/pkg/web/www/js/vendor/vue-apexcharts.min.js


文件差异内容过多而无法显示
+ 5 - 0
src/pkg/web/www/js/vendor/vue-i18n.min.js


文件差异内容过多而无法显示
+ 5 - 0
src/pkg/web/www/js/vendor/vue.min.js


+ 11 - 0
src/pkg/web/www/manifest.json

@@ -0,0 +1,11 @@
+{
+  "name": "WireGuard",
+  "display": "standalone",
+  "background_color": "#fff",
+  "icons": [
+    {
+      "src": "img/favicon.png",
+      "type": "image/png"
+    }
+  ]
+}

+ 3 - 0
src/pkg/web/www/src/css/app.css

@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;

+ 431 - 0
src/pkg/wg/config.go

@@ -0,0 +1,431 @@
+package wg
+
+import (
+	cfg "fahi/pkg/config"
+	"fahi/pkg/util"
+	"fmt"
+	"net"
+	"os"
+	"strings"
+	"time"
+
+	"github.com/google/uuid"
+	log "github.com/sirupsen/logrus"
+	"golang.zx2c4.com/wireguard/wgctrl"
+	"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+)
+
+type Peer struct {
+	Id           string    `json:"id"`
+	Name         string    `json:"name"`
+	Address      string    `json:"address"`
+	PrivateKey   string    `json:"privateKey"`
+	PreSharedKey string    `json:"preSharedKey"`
+	CreatedAt    time.Time `json:"createdAt"`
+	UpdatedAt    time.Time `json:"updatedAt"`
+	Enabled      bool      `json:"enabled"`
+}
+
+type PeerStatus struct {
+	Id                  string    `json:"id"`
+	Name                string    `json:"name"`
+	Address             string    `json:"address"`
+	PublicKey           string    `json:"publicKey"`
+	CreatedAt           time.Time `json:"createdAt"`
+	UpdatedAt           time.Time `json:"updatedAt"`
+	Enabled             bool      `json:"enabled"`
+	PersistentKeepalive int64     `json:"persistentKeepalive"`
+	LatestHandshakeAt   time.Time `json:"latestHandshakeAt"`
+	TransferRx          int64     `json:"transferRx"`
+	TransferTx          int64     `json:"transferTx"`
+}
+
+// configureDevice configures the wireguard device
+func (w *WgIface) configureDevice(config wgtypes.Config) error {
+	wgc, err := wgctrl.New()
+	if err != nil {
+		return err
+	}
+	defer wgc.Close()
+
+	// validate if device with name exists
+	_, err = wgc.Device(w.Name)
+	if err != nil {
+		return err
+	}
+	log.Debugf("got Wireguard device %s", w.Name)
+
+	return wgc.ConfigureDevice(w.Name, config)
+}
+
+func loadPeers() ([]Peer, error) {
+	peers := []Peer{}
+
+	path := util.RootDir + "conf/peers.json"
+
+	data, err := os.ReadFile(path)
+	if err != nil {
+		data = []byte("[]")
+		err = os.WriteFile(path, data, 0600)
+		if err != nil {
+			return peers, err
+		}
+	}
+
+	err = util.Json.Unmarshal(data, &peers)
+	if err != nil {
+		return peers, err
+	}
+
+	return peers, nil
+}
+
+func savePeers(peers []Peer) error {
+	data, err := util.Json.MarshalIndent(&peers, "", " ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(util.RootDir+"conf/peers.json", data, 0600)
+}
+
+func (w *WgIface) configure() error {
+	key, err := wgtypes.ParseKey(w.privateKey)
+	if err != nil {
+		return err
+	}
+	fwmark := 0
+	config := wgtypes.Config{
+		PrivateKey:   &key,
+		ReplacePeers: true,
+		FirewallMark: &fwmark,
+		ListenPort:   &w.Port,
+	}
+
+	peers, err := loadPeers()
+	if err != nil {
+		return err
+	}
+
+	peersConfig := []wgtypes.PeerConfig{}
+	for _, peer := range peers {
+		if peer.Enabled {
+			_, ipNet, err := net.ParseCIDR(peer.Address + "/32")
+			if err != nil {
+				return err
+			}
+
+			privateKey, err := wgtypes.ParseKey(peer.PrivateKey)
+			if err != nil {
+				return err
+			}
+
+			peerConfig := wgtypes.PeerConfig{
+				PublicKey:  privateKey.PublicKey(),
+				AllowedIPs: []net.IPNet{*ipNet},
+			}
+
+			peersConfig = append(peersConfig, peerConfig)
+		}
+	}
+
+	config.Peers = peersConfig
+
+	err = w.configureDevice(config)
+	if err != nil {
+		return fmt.Errorf("received error \"%v\" while configuring interface %s with port %d", err, w.Name, w.Port)
+	}
+
+	return nil
+}
+
+func (w *WgIface) updatePeer(peerKey string, allowedIps string, keepAlive time.Duration) error {
+	log.Debugf("updating interface %s peer %s: endpoint %s ", w.Name, peerKey)
+
+	//parse allowed ips
+	AllowedIPs := []net.IPNet{}
+	ais := strings.Split(allowedIps, ",")
+
+	for _, ai := range ais {
+		_, ipNet, err := net.ParseCIDR(ai)
+		if err != nil {
+			return err
+		}
+		AllowedIPs = append(AllowedIPs, *ipNet)
+	}
+
+	peerKeyParsed, err := wgtypes.ParseKey(peerKey)
+	if err != nil {
+		return err
+	}
+	peer := wgtypes.PeerConfig{
+		PublicKey:                   peerKeyParsed.PublicKey(),
+		ReplaceAllowedIPs:           true,
+		AllowedIPs:                  AllowedIPs,
+		PersistentKeepaliveInterval: &keepAlive,
+	}
+
+	config := wgtypes.Config{
+		Peers: []wgtypes.PeerConfig{peer},
+	}
+	err = w.configureDevice(config)
+	if err != nil {
+		return fmt.Errorf("received error \"%v\" while updating peer on interface %s with settings: allowed ips %s", err, w.Name, allowedIps)
+	}
+	return nil
+}
+
+func (w *WgIface) GetPeers() ([]PeerStatus, error) {
+	w.mu.Lock()
+	defer w.mu.Unlock()
+
+	wgc, err := wgctrl.New()
+	if err != nil {
+		return nil, err
+	}
+	defer func() {
+		err = wgc.Close()
+		if err != nil {
+			log.Errorf("got error while closing wgctl: %v", err)
+		}
+	}()
+
+	wgDevice, err := wgc.Device(w.Name)
+	if err != nil {
+		return nil, err
+	}
+
+	peers, err := loadPeers()
+	if err != nil {
+		return nil, err
+	}
+
+	peersStatus := []PeerStatus{}
+
+	for _, peer := range peers {
+		privateKey, err := wgtypes.ParseKey(peer.PrivateKey)
+		if err != nil {
+			return nil, err
+		}
+		publicKey := privateKey.PublicKey().String()
+
+		peerStatus := PeerStatus{
+			Id:        peer.Id,
+			Name:      peer.Name,
+			Address:   peer.Address,
+			CreatedAt: peer.CreatedAt,
+			UpdatedAt: peer.UpdatedAt,
+			Enabled:   peer.Enabled,
+			PublicKey: publicKey,
+		}
+
+		for _, devPeer := range wgDevice.Peers {
+			if devPeer.PublicKey.String() == publicKey {
+				peerStatus.PersistentKeepalive = int64(devPeer.PersistentKeepaliveInterval)
+				peerStatus.LatestHandshakeAt = devPeer.LastHandshakeTime
+				peerStatus.TransferRx = devPeer.ReceiveBytes
+				peerStatus.TransferTx = devPeer.TransmitBytes
+				break
+			}
+		}
+		peersStatus = append(peersStatus, peerStatus)
+	}
+
+	return peersStatus, nil
+}
+
+// RemovePeer removes a Wireguard Peer from the interface iface
+func (w *WgIface) removePeer(peerKey string) error {
+	log.Debugf("Removing peer %s from interface %s ", peerKey, w.Name)
+
+	peerKeyParsed, err := wgtypes.ParseKey(peerKey)
+	if err != nil {
+		return err
+	}
+
+	peer := wgtypes.PeerConfig{
+		PublicKey: peerKeyParsed.PublicKey(),
+		Remove:    true,
+	}
+
+	config := wgtypes.Config{
+		Peers: []wgtypes.PeerConfig{peer},
+	}
+	err = w.configureDevice(config)
+	if err != nil {
+		return fmt.Errorf("received error \"%v\" while removing peer %s from interface %s", err, peerKey, w.Name)
+	}
+	return nil
+}
+
+func (w *WgIface) GetPeerConfig(id string) (string, error) {
+	w.mu.Lock()
+	defer w.mu.Unlock()
+
+	peers, err := loadPeers()
+	if err != nil {
+		return "", err
+	}
+
+	c, err := cfg.LoadOrCreate()
+	if err != nil {
+		return "", err
+	}
+
+	for _, peer := range peers {
+		if peer.Id == id {
+			privateKey, err := wgtypes.ParseKey(c.WgPrivateKey)
+			if err != nil {
+				return "", err
+			}
+			peerConfig := "[Interface]\nPrivateKey = " + peer.PrivateKey + "\nAddress = " + peer.Address + "/16\n" + "DNS = " + c.WgDNS + "\nMTU = " + fmt.Sprintf("%d", c.WgMTU) + "\n\n"
+			peerConfig = peerConfig + "[Peer]\nPublicKey = " + privateKey.PublicKey().String() + "\nAllowedIPs = " + c.WgAllowedIPs + "\nPersistentKeepalive = " + fmt.Sprintf("%d", c.WgPersistentKeepalive)
+			peerConfig = peerConfig + "\nEndpoint = " + c.Host + ":" + fmt.Sprintf("%d", c.WgPort)
+			return peerConfig, nil
+		}
+	}
+
+	return "", nil
+}
+
+func (w *WgIface) AddPeer(name string) (*Peer, error) {
+	w.mu.Lock()
+	defer w.mu.Unlock()
+
+	peers, err := loadPeers()
+	if err != nil {
+		return nil, err
+	}
+
+	c, err := cfg.LoadOrCreate()
+	if err != nil {
+		return nil, err
+	}
+
+	address := c.WgAddress[:strings.LastIndex(c.WgAddress, ".")+1]
+
+	if len(peers) == 0 {
+		address += "2"
+	} else {
+		for i := 2; i < 255; i++ {
+			found := false
+			newIp := address + fmt.Sprintf("%d", i)
+			for _, peer := range peers {
+				if peer.Address == newIp {
+					found = true
+					break
+				}
+			}
+			if !found {
+				address = newIp
+				break
+			}
+		}
+	}
+
+	if strings.HasSuffix(address, ".") {
+		return nil, fmt.Errorf("Maximum number of clients reached")
+	}
+
+	privateKey, err := wgtypes.GeneratePrivateKey()
+	if err != nil {
+		return nil, err
+	}
+
+	peer := Peer{
+		Id:         uuid.New().String(),
+		Name:       name,
+		Address:    address,
+		PrivateKey: privateKey.String(),
+		CreatedAt:  time.Now(),
+		UpdatedAt:  time.Now(),
+		Enabled:    true,
+	}
+
+	err = w.updatePeer(peer.PrivateKey, peer.Address+"/32", time.Duration(c.WgPersistentKeepalive)*time.Second)
+	if err != nil {
+		return nil, err
+	}
+
+	peers = append(peers, peer)
+	err = savePeers(peers)
+	if err != nil {
+		return nil, err
+	}
+
+	return &peer, nil
+}
+
+func (w *WgIface) DelPeer(id string) error {
+	w.mu.Lock()
+	defer w.mu.Unlock()
+
+	peers, err := loadPeers()
+	if err != nil {
+		return err
+	}
+
+	i := 0
+	for _, peer := range peers {
+		if peer.Id != id {
+			peers[i] = peer
+			i++
+		} else {
+			err = w.removePeer(peer.PrivateKey)
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	return savePeers(peers[:i])
+}
+
+func (w *WgIface) SetPeer(id, enabled, name, address string) error {
+	w.mu.Lock()
+	defer w.mu.Unlock()
+
+	peers, err := loadPeers()
+	if err != nil {
+		return err
+	}
+
+	for i, peer := range peers {
+		if peer.Id == id {
+			peers[i].UpdatedAt = time.Now()
+			if name != "" {
+				peers[i].Name = name
+			} else if address != "" {
+				peers[i].Address = address
+				c, err := cfg.LoadOrCreate()
+				if err != nil {
+					return err
+				}
+				err = w.updatePeer(peer.PrivateKey, address+"/32", time.Duration(c.WgPersistentKeepalive)*time.Second)
+				if err != nil {
+					return err
+				}
+			} else {
+				if enabled == "enable" {
+					peers[i].Enabled = true
+					c, err := cfg.LoadOrCreate()
+					if err != nil {
+						return err
+					}
+					err = w.updatePeer(peer.PrivateKey, peer.Address+"/32", time.Duration(c.WgPersistentKeepalive)*time.Second)
+					if err != nil {
+						return err
+					}
+				} else if enabled == "disable" {
+					peers[i].Enabled = false
+					err = w.removePeer(peer.PrivateKey)
+					if err != nil {
+						return err
+					}
+				}
+			}
+			break
+		}
+	}
+
+	return savePeers(peers)
+}

+ 349 - 0
src/pkg/wg/module.go

@@ -0,0 +1,349 @@
+package wg
+
+import (
+	"bufio"
+	"errors"
+	"fmt"
+	"io/fs"
+	"io/ioutil"
+	"math"
+	"os"
+	"path/filepath"
+	"strings"
+	"syscall"
+
+	log "github.com/sirupsen/logrus"
+	"github.com/vishvananda/netlink"
+	"golang.org/x/sys/unix"
+)
+
+// Holds logic to check existence of kernel modules used by wireguard interfaces
+// Copied from https://github.com/paultag/go-modprobe and
+// https://github.com/pmorjan/kmod
+
+type status int
+
+const (
+	defaultModuleDir        = "/lib/modules"
+	unknown          status = iota
+	unloaded
+	unloading
+	loading
+	live
+	inuse
+)
+
+type module struct {
+	name string
+	path string
+}
+
+var (
+	// ErrModuleNotFound is the error resulting if a module can't be found.
+	ErrModuleNotFound = errors.New("module not found")
+	moduleLibDir      = defaultModuleDir
+	// get the root directory for the kernel modules. If this line panics,
+	// it's because getModuleRoot has failed to get the uname of the running
+	// kernel (likely a non-POSIX system, but maybe a broken kernel?)
+	moduleRoot = getModuleRoot()
+)
+
+// Get the module root (/lib/modules/$(uname -r)/)
+func getModuleRoot() string {
+	uname := unix.Utsname{}
+	if err := unix.Uname(&uname); err != nil {
+		panic(err)
+	}
+
+	i := 0
+	for ; uname.Release[i] != 0; i++ {
+	}
+
+	return filepath.Join(moduleLibDir, string(uname.Release[:i]))
+}
+
+// tunModuleIsLoaded check if tun module exist, if is not attempt to load it
+func tunModuleIsLoaded() bool {
+	_, err := os.Stat("/dev/net/tun")
+	if err == nil {
+		return true
+	}
+
+	log.Infof("couldn't access device /dev/net/tun, go error %v, "+
+		"will attempt to load tun module, if running on container add flag --cap-add=NET_ADMIN", err)
+
+	tunLoaded, err := tryToLoadModule("tun")
+	if err != nil {
+		log.Errorf("unable to find or load tun module, got error: %v", err)
+	}
+	return tunLoaded
+}
+
+// WireguardModuleIsLoaded check if we can load wireguard mod (linux only)
+func WireguardModuleIsLoaded() bool {
+	if canCreateFakeWireguardInterface() {
+		return true
+	}
+
+	loaded, err := tryToLoadModule("wireguard")
+	if err != nil {
+		log.Info(err)
+		return false
+	}
+
+	return loaded
+}
+
+func canCreateFakeWireguardInterface() bool {
+	link := newWGLink("mustnotexist")
+
+	// We willingly try to create a device with an invalid
+	// MTU here as the validation of the MTU will be performed after
+	// the validation of the link kind and hence allows us to check
+	// for the existance of the wireguard module without actually
+	// creating a link.
+	//
+	// As a side-effect, this will also let the kernel lazy-load
+	// the wireguard module.
+	link.attrs.MTU = math.MaxInt
+
+	err := netlink.LinkAdd(link)
+
+	return errors.Is(err, syscall.EINVAL)
+}
+
+func tryToLoadModule(moduleName string) (bool, error) {
+	if isModuleEnabled(moduleName) {
+		return true, nil
+	}
+	modulePath, err := getModulePath(moduleName)
+	if err != nil {
+		return false, fmt.Errorf("couldn't find module path for %s, error: %v", moduleName, err)
+	}
+	if modulePath == "" {
+		return false, nil
+	}
+
+	log.Infof("trying to load %s module", moduleName)
+
+	err = loadModuleWithDependencies(moduleName, modulePath)
+	if err != nil {
+		return false, fmt.Errorf("couldn't load %s module, error: %v", moduleName, err)
+	}
+	return true, nil
+}
+
+func isModuleEnabled(name string) bool {
+	builtin, builtinErr := isBuiltinModule(name)
+	state, statusErr := moduleStatus(name)
+	return (builtinErr == nil && builtin) || (statusErr == nil && state >= loading)
+}
+
+func getModulePath(name string) (string, error) {
+	var foundPath string
+	skipRemainingDirs := false
+
+	err := filepath.WalkDir(
+		moduleRoot,
+		func(path string, info fs.DirEntry, err error) error {
+			if skipRemainingDirs {
+				return fs.SkipDir
+			}
+			if err != nil {
+				// skip broken files
+				return nil
+			}
+
+			if !info.Type().IsRegular() {
+				return nil
+			}
+
+			nameFromPath := pathToName(path)
+			if nameFromPath == name {
+				foundPath = path
+				skipRemainingDirs = true
+			}
+
+			return nil
+		})
+
+	if err != nil {
+		return "", err
+	}
+
+	return foundPath, nil
+}
+
+func pathToName(s string) string {
+	s = filepath.Base(s)
+	for ext := filepath.Ext(s); ext != ""; ext = filepath.Ext(s) {
+		s = strings.TrimSuffix(s, ext)
+	}
+	return cleanName(s)
+}
+
+func cleanName(s string) string {
+	return strings.ReplaceAll(strings.TrimSpace(s), "-", "_")
+}
+
+func isBuiltinModule(name string) (bool, error) {
+	f, err := os.Open(filepath.Join(moduleRoot, "/modules.builtin"))
+	if err != nil {
+		return false, err
+	}
+	defer func() {
+		err := f.Close()
+		if err != nil {
+			log.Errorf("failed closing modules.builtin file, %v", err)
+		}
+	}()
+
+	var found bool
+	scanner := bufio.NewScanner(f)
+	for scanner.Scan() {
+		line := scanner.Text()
+		if pathToName(line) == name {
+			found = true
+			break
+		}
+	}
+	if err := scanner.Err(); err != nil {
+		return false, err
+	}
+	return found, nil
+}
+
+// /proc/modules
+//      name | memory size | reference count | references | state: <Live|Loading|Unloading>
+// 		macvlan 28672 1 macvtap, Live 0x0000000000000000
+func moduleStatus(name string) (status, error) {
+	state := unknown
+	f, err := os.Open("/proc/modules")
+	if err != nil {
+		return state, err
+	}
+	defer func() {
+		err := f.Close()
+		if err != nil {
+			log.Errorf("failed closing /proc/modules file, %v", err)
+		}
+	}()
+
+	state = unloaded
+
+	scanner := bufio.NewScanner(f)
+	for scanner.Scan() {
+		fields := strings.Fields(scanner.Text())
+		if fields[0] == name {
+			if fields[2] != "0" {
+				state = inuse
+				break
+			}
+			switch fields[4] {
+			case "Live":
+				state = live
+			case "Loading":
+				state = loading
+			case "Unloading":
+				state = unloading
+			}
+			break
+		}
+	}
+	if err := scanner.Err(); err != nil {
+		return state, err
+	}
+
+	return state, nil
+}
+
+func loadModuleWithDependencies(name, path string) error {
+	deps, err := getModuleDependencies(name)
+	if err != nil {
+		return fmt.Errorf("couldn't load list of module %s dependecies", name)
+	}
+	for _, dep := range deps {
+		err = loadModule(dep.name, dep.path)
+		if err != nil {
+			return fmt.Errorf("couldn't load dependecy module %s for %s", dep.name, name)
+		}
+	}
+	return loadModule(name, path)
+}
+
+func loadModule(name, path string) error {
+	state, err := moduleStatus(name)
+	if err != nil {
+		return err
+	}
+	if state >= loading {
+		return nil
+	}
+
+	f, err := os.Open(path)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		err := f.Close()
+		if err != nil {
+			log.Errorf("failed closing %s file, %v", path, err)
+		}
+	}()
+
+	// first try finit_module(2), then init_module(2)
+	err = unix.FinitModule(int(f.Fd()), "", 0)
+	if errors.Is(err, unix.ENOSYS) {
+		buf, err := ioutil.ReadAll(f)
+		if err != nil {
+			return err
+		}
+		return unix.InitModule(buf, "")
+	}
+	return err
+}
+
+// getModuleDependencies returns a module dependencies
+func getModuleDependencies(name string) ([]module, error) {
+	f, err := os.Open(filepath.Join(moduleRoot, "/modules.dep"))
+	if err != nil {
+		return nil, err
+	}
+	defer func() {
+		err := f.Close()
+		if err != nil {
+			log.Errorf("failed closing modules.dep file, %v", err)
+		}
+	}()
+
+	var deps []string
+	scanner := bufio.NewScanner(f)
+	for scanner.Scan() {
+		line := scanner.Text()
+		fields := strings.Fields(line)
+		if pathToName(strings.TrimSuffix(fields[0], ":")) == name {
+			deps = fields
+			break
+		}
+	}
+	if err := scanner.Err(); err != nil {
+		return nil, err
+	}
+
+	if len(deps) == 0 {
+		return nil, ErrModuleNotFound
+	}
+	deps[0] = strings.TrimSuffix(deps[0], ":")
+
+	var modules []module
+	for _, v := range deps {
+		if pathToName(v) != name {
+			modules = append(modules, module{
+				name: pathToName(v),
+				path: filepath.Join(moduleRoot, v),
+			})
+		}
+	}
+
+	return modules, nil
+}

+ 129 - 0
src/pkg/wg/tun_kernel.go

@@ -0,0 +1,129 @@
+package wg
+
+import (
+	"os"
+
+	log "github.com/sirupsen/logrus"
+	"github.com/vishvananda/netlink"
+)
+
+type NativeLink struct {
+	Link *netlink.Link
+}
+
+func (w *WgIface) createWithKernel() error {
+
+	link := newWGLink(w.Name)
+
+	// check if interface exists
+	l, err := netlink.LinkByName(w.Name)
+	if err != nil {
+		switch err.(type) {
+		case netlink.LinkNotFoundError:
+			break
+		default:
+			return err
+		}
+	}
+
+	// remove if interface exists
+	if l != nil {
+		err = netlink.LinkDel(link)
+		if err != nil {
+			return err
+		}
+	}
+
+	log.Debugf("adding device: %s", w.Name)
+	err = netlink.LinkAdd(link)
+	if os.IsExist(err) {
+		log.Infof("interface %s already exists. Will reuse.", w.Name)
+	} else if err != nil {
+		return err
+	}
+
+	w.Interface = link
+
+	err = w.assignAddr()
+	if err != nil {
+		return err
+	}
+
+	err = netlink.LinkSetMTU(link, w.MTU)
+	if err != nil {
+		log.Errorf("error setting MTU on interface: %s", w.Name)
+		return err
+	}
+
+	err = w.configure()
+	if err != nil {
+		return err
+	}
+
+	err = netlink.LinkSetUp(link)
+	if err != nil {
+		log.Errorf("error bringing up interface: %s", w.Name)
+		return err
+	}
+
+	return nil
+}
+
+// assignAddr Adds IP address to the tunnel interface
+func (w *WgIface) assignAddr() error {
+	link := newWGLink(w.Name)
+
+	//delete existing addresses
+	list, err := netlink.AddrList(link, 0)
+	if err != nil {
+		return err
+	}
+	if len(list) > 0 {
+		for _, a := range list {
+			err = netlink.AddrDel(link, &a)
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	log.Debugf("adding address %s to interface: %s", w.Address.String(), w.Name)
+	addr, _ := netlink.ParseAddr(w.Address.String())
+	err = netlink.AddrAdd(link, addr)
+	if os.IsExist(err) {
+		log.Infof("interface %s already has the address: %s", w.Name, w.Address.String())
+	} else if err != nil {
+		return err
+	}
+	// On linux, the link must be brought up
+	err = netlink.LinkSetUp(link)
+	return err
+}
+
+type wgLink struct {
+	attrs *netlink.LinkAttrs
+}
+
+func newWGLink(name string) *wgLink {
+	attrs := netlink.NewLinkAttrs()
+	attrs.Name = name
+
+	return &wgLink{
+		attrs: &attrs,
+	}
+}
+
+// Attrs returns the Wireguard's default attributes
+func (l *wgLink) Attrs() *netlink.LinkAttrs {
+	return l.attrs
+}
+
+// Type returns the interface type
+func (l *wgLink) Type() string {
+	return "wireguard"
+}
+
+// Close deletes the link interface
+func (l *wgLink) Close() error {
+	return netlink.LinkDel(l)
+}

+ 67 - 0
src/pkg/wg/tun_userspace.go

@@ -0,0 +1,67 @@
+package wg
+
+import (
+	"net"
+
+	log "github.com/sirupsen/logrus"
+	"golang.zx2c4.com/wireguard/conn"
+	"golang.zx2c4.com/wireguard/device"
+	"golang.zx2c4.com/wireguard/ipc"
+	"golang.zx2c4.com/wireguard/tun"
+)
+
+func (w *WgIface) createWithUserspace() error {
+	tunIface, err := tun.CreateTUN(w.Name, w.MTU)
+	if err != nil {
+		return err
+	}
+
+	w.Interface = tunIface
+
+	// We need to create a wireguard-go device and listen to configuration requests
+	w.tunDevice = device.NewDevice(tunIface, conn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, "[fahi] "))
+
+	err = w.assignAddr()
+	if err != nil {
+		return err
+	}
+
+	w.uapiListener, err = getUAPI(w.Name)
+	if err != nil {
+		return err
+	}
+
+	go func(uapi net.Listener) {
+		for {
+			uapiConn, uapiErr := uapi.Accept()
+			if uapiErr != nil {
+				log.Traceln("uapi Accept failed with error: ", uapiErr)
+				return
+			}
+			go w.tunDevice.IpcHandle(uapiConn)
+		}
+	}(w.uapiListener)
+
+	log.Debugln("UAPI listener started")
+
+	err = w.configure()
+	if err != nil {
+		return err
+	}
+
+	err = w.tunDevice.Up()
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// getUAPI returns a Listener
+func getUAPI(iface string) (net.Listener, error) {
+	tunSock, err := ipc.UAPIOpen(iface)
+	if err != nil {
+		return nil, err
+	}
+	return ipc.UAPIListen(iface, tunSock)
+}

+ 189 - 0
src/pkg/wg/wg.go

@@ -0,0 +1,189 @@
+package wg
+
+import (
+	"fahi/pkg/config"
+	"fmt"
+	"net"
+	"os"
+	"sync"
+
+	"github.com/coreos/go-iptables/iptables"
+	log "github.com/sirupsen/logrus"
+	"golang.zx2c4.com/wireguard/device"
+)
+
+type WgIface struct {
+	Name         string
+	Device       string
+	AdminPort    int
+	Port         int
+	MTU          int
+	Address      *WgAddress
+	Interface    NetInterface
+	privateKey   string
+	mu           sync.Mutex
+	tunDevice    *device.Device
+	uapiListener net.Listener
+	iptables     *iptables.IPTables
+}
+
+// WGAddress Wireguard parsed address
+type WgAddress struct {
+	IP      net.IP
+	Network *net.IPNet
+}
+
+func (addr *WgAddress) String() string {
+	maskSize, _ := addr.Network.Mask.Size()
+	return fmt.Sprintf("%s/%d", addr.IP.String(), maskSize)
+}
+
+func parseAddress(address string) (*WgAddress, error) {
+	ip, network, err := net.ParseCIDR(address)
+	if err != nil {
+		return nil, err
+	}
+
+	return &WgAddress{
+		IP:      ip,
+		Network: network,
+	}, nil
+}
+
+// NetInterface represents a generic network tunnel interface
+type NetInterface interface {
+	Close() error
+}
+
+func New(cfg *config.Config) (*WgIface, error) {
+	wgIface := &WgIface{
+		Name:       "LTWG",
+		Device:     cfg.WgDevice,
+		AdminPort:  cfg.Port,
+		Port:       cfg.WgPort,
+		MTU:        cfg.WgMTU,
+		mu:         sync.Mutex{},
+		privateKey: cfg.WgPrivateKey,
+	}
+
+	wgAddress, err := parseAddress(cfg.WgAddress)
+	if err != nil {
+		return wgIface, err
+	}
+	wgIface.Address = wgAddress
+
+	return wgIface, nil
+}
+
+func (w *WgIface) Create() (err error) {
+	w.mu.Lock()
+	defer w.mu.Unlock()
+
+	if WireguardModuleIsLoaded() {
+		log.Info("using kernel WireGuard")
+		err = w.createWithKernel()
+	} else {
+		if !tunModuleIsLoaded() {
+			return fmt.Errorf("couldn't check or load tun module")
+		}
+		log.Info("using userspace WireGuard")
+		err = w.createWithUserspace()
+	}
+	if err != nil {
+		return
+	}
+
+	err = setIPForwarding(true)
+	if err != nil {
+		return
+	}
+
+	w.iptables, err = iptables.NewWithProtocol(iptables.ProtocolIPv4)
+	if err != nil {
+		return
+	}
+	err = w.iptables.Insert("filter", "FORWARD", 1, "-i", w.Name, "-j", "ACCEPT")
+	if err != nil {
+		return
+	}
+	err = w.iptables.Insert("filter", "FORWARD", 1, "-o", w.Name, "-j", "ACCEPT")
+	if err != nil {
+		return
+	}
+	err = w.iptables.Insert("nat", "POSTROUTING", 1, "-s", w.Address.Network.String(), "-o", w.Device, "-j", "MASQUERADE")
+	if err != nil {
+		return
+	}
+	err = w.iptables.Insert("filter", "INPUT", 1, "-p", "udp", "--dport", fmt.Sprintf("%d", w.Port), "-j", "ACCEPT")
+	if err != nil {
+		return
+	}
+	err = w.iptables.Insert("filter", "INPUT", 1, "-p", "tcp", "--dport", fmt.Sprintf("%d", w.AdminPort), "-j", "ACCEPT")
+	return
+}
+
+func (w *WgIface) Close() error {
+	w.mu.Lock()
+	defer w.mu.Unlock()
+
+	var err error
+
+	if w.tunDevice != nil {
+		w.tunDevice.Close()
+		w.tunDevice = nil
+	} else if w.Interface != nil {
+		err = w.Interface.Close()
+		w.Interface = nil
+		if err != nil {
+			log.Debugf("failed to close interface: %s", err)
+		}
+	}
+
+	sockPath := "/var/run/wireguard/" + w.Name + ".sock"
+	if _, statErr := os.Stat(sockPath); statErr == nil {
+		_ = os.Remove(sockPath)
+	}
+
+	if w.uapiListener != nil {
+		err = w.uapiListener.Close()
+		w.uapiListener = nil
+		if err != nil {
+			log.Errorf("failed to close uapi listener: %v", err)
+		}
+	}
+
+	setIPForwarding(false)
+
+	if w.iptables != nil {
+		w.iptables.Delete("filter", "FORWARD", "-i", w.Name, "-j", "ACCEPT")
+		w.iptables.Delete("filter", "FORWARD", "-o", w.Name, "-j", "ACCEPT")
+		w.iptables.Delete("nat", "POSTROUTING", "-s", w.Address.Network.String(), "-o", w.Device, "-j", "MASQUERADE")
+		w.iptables.Delete("filter", "INPUT", "-p", "udp", "--dport", fmt.Sprintf("%d", w.Port), "-j", "ACCEPT")
+		w.iptables.Delete("filter", "INPUT", "-p", "tcp", "--dport", fmt.Sprintf("%d", w.AdminPort), "-j", "ACCEPT")
+		w.iptables = nil
+	}
+
+	return err
+}
+
+func setIPForwarding(enabled bool) error {
+	ipv4ForwardingPath := "/proc/sys/net/ipv4/ip_forward"
+	bytes, err := os.ReadFile(ipv4ForwardingPath)
+	if err != nil {
+		return err
+	}
+
+	if len(bytes) > 0 {
+		if enabled && bytes[0] == 49 {
+			return nil
+		} else if !enabled && bytes[0] == 48 {
+			return nil
+		}
+	}
+
+	if enabled {
+		return os.WriteFile(ipv4ForwardingPath, []byte("1"), 0644)
+	}
+
+	return os.WriteFile(ipv4ForwardingPath, []byte("0"), 0644)
+}

部分文件因为文件数量过多而无法显示