Replace from-scratch rewrite with real ngoduykhanh/wireguard-ui fork

The from-scratch Go rewrite had unresolved bugs (missing go.sum, UI
404s, path issues) from being built without a working local Go
toolchain to verify against. Switching strategy: use the actual
upstream wireguard-ui codebase (proven, battle-tested single-server
manager) as the base, and extend it for multi-server support instead
of re-deriving everything from zero.

Kept our own installers (bootstrap.sh, update.sh, scripts/install.sh,
scripts/proxmox-install.sh) - these still apply, just need updating
to build/install the upstream module layout instead of the old
cmd/wireguard-ui-multi structure.

Module path intentionally left as upstream's own
(github.com/ngoduykhanh/wireguard-ui) for now to avoid touching every
internal import; revisit if this needs to be fully rebranded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-07-10 18:34:44 +02:00
co-authored by Claude Sonnet 5
parent 6eeea65ede
commit 867dc7740a
79 changed files with 11951 additions and 2548 deletions
+20
View File
@@ -0,0 +1,20 @@
package handler
import (
"net/http"
"github.com/labstack/echo/v4"
)
// ContentTypeJson checks that the requests have the Content-Type header set to "application/json".
// This helps against CSRF attacks.
func ContentTypeJson(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
contentType := c.Request().Header.Get("Content-Type")
if contentType != "application/json" {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Only JSON allowed"})
}
return next(c)
}
}
+6
View File
@@ -0,0 +1,6 @@
package handler
type jsonHTTPResponse struct {
Status bool `json:"status"`
Message string `json:"message"`
}
+1196
View File
File diff suppressed because it is too large Load Diff
+172
View File
@@ -0,0 +1,172 @@
package handler
import (
"fmt"
"net"
"net/http"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/gommon/log"
"github.com/ngoduykhanh/wireguard-ui/model"
"github.com/ngoduykhanh/wireguard-ui/store"
"github.com/sabhiram/go-wol/wol"
)
type WakeOnLanHostSavePayload struct {
Name string `json:"name"`
MacAddress string `json:"mac_address"`
OldMacAddress string `json:"old_mac_address"`
}
func createError(c echo.Context, err error, msg string) error {
log.Error(msg, err)
return c.JSON(
http.StatusInternalServerError,
jsonHTTPResponse{
false,
msg})
}
func GetWakeOnLanHosts(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
var err error
hosts, err := db.GetWakeOnLanHosts()
if err != nil {
return createError(c, err, fmt.Sprintf("wake_on_lan_hosts database error: %s", err))
}
err = c.Render(http.StatusOK, "wake_on_lan_hosts.html", map[string]interface{}{
"baseData": model.BaseData{Active: "wake_on_lan_hosts", CurrentUser: currentUser(c), Admin: isAdmin(c)},
"hosts": hosts,
"error": "",
})
if err != nil {
return createError(c, err, fmt.Sprintf("wake_on_lan_hosts.html render error: %s", err))
}
return nil
}
}
func SaveWakeOnLanHost(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
var payload WakeOnLanHostSavePayload
err := c.Bind(&payload)
if err != nil {
log.Error("Wake On Host Save Payload Bind Error: ", err)
return c.JSON(http.StatusInternalServerError, payload)
}
var host = model.WakeOnLanHost{
MacAddress: payload.MacAddress,
Name: payload.Name,
}
if len(payload.OldMacAddress) != 0 { // Edit
if payload.OldMacAddress != payload.MacAddress { // modified mac address
oldHost, err := db.GetWakeOnLanHost(payload.OldMacAddress)
if err != nil {
return createError(c, err, fmt.Sprintf("Wake On Host Update Err: %s", err))
}
if payload.OldMacAddress != payload.MacAddress {
existHost, _ := db.GetWakeOnLanHost(payload.MacAddress)
if existHost != nil {
return createError(c, nil, "Mac Address already exists.")
}
}
err = db.DeleteWakeOnHostLanHost(payload.OldMacAddress)
if err != nil {
return createError(c, err, fmt.Sprintf("Wake On Host Update Err: %s", err))
}
host.LatestUsed = oldHost.LatestUsed
}
err = db.SaveWakeOnLanHost(host)
} else { // new
existHost, _ := db.GetWakeOnLanHost(payload.MacAddress)
if existHost != nil {
return createError(c, nil, "Mac Address already exists.")
}
err = db.SaveWakeOnLanHost(host)
}
if err != nil {
return createError(c, err, fmt.Sprintf("Wake On Host Save Error: %s", err))
}
return c.JSON(http.StatusOK, host)
}
}
func DeleteWakeOnHost(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
var macAddress = c.Param("mac_address")
var host, err = db.GetWakeOnLanHost(macAddress)
if err != nil {
log.Error("Wake On Host Delete Error: ", err)
return createError(c, err, fmt.Sprintf("Wake On Host Delete Error: %s", macAddress))
}
err = db.DeleteWakeOnHost(*host)
if err != nil {
return createError(c, err, fmt.Sprintf("Wake On Host Delete Error: %s", macAddress))
}
return c.JSON(http.StatusOK, nil)
}
}
func WakeOnHost(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
macAddress := c.Param("mac_address")
host, err := db.GetWakeOnLanHost(macAddress)
now := time.Now().UTC()
host.LatestUsed = &now
err = db.SaveWakeOnLanHost(*host)
if err != nil {
return createError(c, err, fmt.Sprintf("Latest Used Update Error: %s", macAddress))
}
magicPacket, err := wol.New(macAddress)
if err != nil {
return createError(c, err, fmt.Sprintf("Magic Packet Create Error: %s", macAddress))
}
bytes, err := magicPacket.Marshal()
if err != nil {
return createError(c, err, fmt.Sprintf("Magic Packet Bytestream Error: %s", macAddress))
}
udpAddr, err := net.ResolveUDPAddr("udp", "255.255.255.255:0")
if err != nil {
return createError(c, err, fmt.Sprintf("ResolveUDPAddr Error: %s", macAddress))
}
// Grab a UDP connection to send our packet of bytes.
conn, err := net.DialUDP("udp", nil, udpAddr)
if err != nil {
return err
}
defer func(conn *net.UDPConn) {
err := conn.Close()
if err != nil {
log.Error(err)
}
}(conn)
n, err := conn.Write(bytes)
if err == nil && n != 102 {
return createError(c, nil, fmt.Sprintf("magic packet sent was %d bytes (expected 102 bytes sent)", n))
}
if err != nil {
return createError(c, err, fmt.Sprintf("Network Send Error: %s", macAddress))
}
return c.JSON(http.StatusOK, host.LatestUsed)
}
}
+249
View File
@@ -0,0 +1,249 @@
package handler
import (
"fmt"
"net/http"
"time"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/ngoduykhanh/wireguard-ui/util"
)
func ValidSession(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if !isValidSession(c) {
nextURL := c.Request().URL
if nextURL != nil && c.Request().Method == http.MethodGet {
return c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(util.BasePath+"/login?next=%s", c.Request().URL))
} else {
return c.Redirect(http.StatusTemporaryRedirect, util.BasePath+"/login")
}
}
return next(c)
}
}
// RefreshSession must only be used after ValidSession middleware
// RefreshSession checks if the session is eligible for the refresh, but doesn't check if it's fully valid
func RefreshSession(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
doRefreshSession(c)
return next(c)
}
}
func NeedsAdmin(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if !isAdmin(c) {
return c.Redirect(http.StatusTemporaryRedirect, util.BasePath+"/")
}
return next(c)
}
}
func isValidSession(c echo.Context) bool {
if util.DisableLogin {
return true
}
sess, _ := session.Get("session", c)
cookie, err := c.Cookie("session_token")
if err != nil || sess.Values["session_token"] != cookie.Value {
return false
}
// Check time bounds
createdAt := getCreatedAt(sess)
updatedAt := getUpdatedAt(sess)
maxAge := getMaxAge(sess)
// Temporary session is considered valid within 24h if browser is not closed before
// This value is not saved and is used as virtual expiration
if maxAge == 0 {
maxAge = 86400
}
expiration := updatedAt + int64(maxAge)
now := time.Now().UTC().Unix()
if updatedAt > now || expiration < now || createdAt+util.SessionMaxDuration < now {
return false
}
// Check if user still exists and unchanged
username := fmt.Sprintf("%s", sess.Values["username"])
userHash := getUserHash(sess)
if uHash, ok := util.DBUsersToCRC32[username]; !ok || userHash != uHash {
return false
}
return true
}
// Refreshes a "remember me" session when the user visits web pages (not API)
// Session must be valid before calling this function
// Refresh is performed at most once per 24h
func doRefreshSession(c echo.Context) {
if util.DisableLogin {
return
}
sess, _ := session.Get("session", c)
maxAge := getMaxAge(sess)
if maxAge <= 0 {
return
}
oldCookie, err := c.Cookie("session_token")
if err != nil || sess.Values["session_token"] != oldCookie.Value {
return
}
// Refresh no sooner than 24h
createdAt := getCreatedAt(sess)
updatedAt := getUpdatedAt(sess)
expiration := updatedAt + int64(getMaxAge(sess))
now := time.Now().UTC().Unix()
if updatedAt > now || expiration < now || now-updatedAt < 86_400 || createdAt+util.SessionMaxDuration < now {
return
}
cookiePath := util.GetCookiePath()
sess.Values["updated_at"] = now
sess.Options = &sessions.Options{
Path: cookiePath,
MaxAge: maxAge,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
sess.Save(c.Request(), c.Response())
cookie := new(http.Cookie)
cookie.Name = "session_token"
cookie.Path = cookiePath
cookie.Value = oldCookie.Value
cookie.MaxAge = maxAge
cookie.HttpOnly = true
cookie.SameSite = http.SameSiteLaxMode
c.SetCookie(cookie)
}
// Get time in seconds this session is valid without updating
func getMaxAge(sess *sessions.Session) int {
if util.DisableLogin {
return 0
}
maxAge := sess.Values["max_age"]
switch typedMaxAge := maxAge.(type) {
case int:
return typedMaxAge
default:
return 0
}
}
// Get a timestamp in seconds of the time the session was created
func getCreatedAt(sess *sessions.Session) int64 {
if util.DisableLogin {
return 0
}
createdAt := sess.Values["created_at"]
switch typedCreatedAt := createdAt.(type) {
case int64:
return typedCreatedAt
default:
return 0
}
}
// Get a timestamp in seconds of the last session update
func getUpdatedAt(sess *sessions.Session) int64 {
if util.DisableLogin {
return 0
}
lastUpdate := sess.Values["updated_at"]
switch typedLastUpdate := lastUpdate.(type) {
case int64:
return typedLastUpdate
default:
return 0
}
}
// Get CRC32 of a user at the moment of log in
// Any changes to user will result in logout of other (not updated) sessions
func getUserHash(sess *sessions.Session) uint32 {
if util.DisableLogin {
return 0
}
userHash := sess.Values["user_hash"]
switch typedUserHash := userHash.(type) {
case uint32:
return typedUserHash
default:
return 0
}
}
// currentUser to get username of logged in user
func currentUser(c echo.Context) string {
if util.DisableLogin {
return ""
}
sess, _ := session.Get("session", c)
username := fmt.Sprintf("%s", sess.Values["username"])
return username
}
// isAdmin to get user type: admin or manager
func isAdmin(c echo.Context) bool {
if util.DisableLogin {
return true
}
sess, _ := session.Get("session", c)
admin := fmt.Sprintf("%t", sess.Values["admin"])
return admin == "true"
}
func setUser(c echo.Context, username string, admin bool, userCRC32 uint32) {
sess, _ := session.Get("session", c)
sess.Values["username"] = username
sess.Values["user_hash"] = userCRC32
sess.Values["admin"] = admin
sess.Save(c.Request(), c.Response())
}
// clearSession to remove current session
func clearSession(c echo.Context) {
sess, _ := session.Get("session", c)
sess.Values["username"] = ""
sess.Values["user_hash"] = 0
sess.Values["admin"] = false
sess.Values["session_token"] = ""
sess.Values["max_age"] = -1
sess.Options.MaxAge = -1
sess.Save(c.Request(), c.Response())
cookiePath := util.GetCookiePath()
cookie, err := c.Cookie("session_token")
if err != nil {
cookie = new(http.Cookie)
}
cookie.Name = "session_token"
cookie.Path = cookiePath
cookie.MaxAge = -1
cookie.HttpOnly = true
cookie.SameSite = http.SameSiteLaxMode
c.SetCookie(cookie)
}