wg-quick refused to start when /etc/wireguard/<iface>.conf didn't exist yet - the case for any newly created or imported server before someone manually hits "Apply Config". ServerServiceStart now regenerates the config from current DB state first, reusing the same logic ApplyServerConfig already had (extracted into writeServerConfigToDisk).
2414 lines
83 KiB
Go
2414 lines
83 KiB
Go
package handler
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gorilla/sessions"
|
|
"github.com/labstack/echo-contrib/session"
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/labstack/gommon/log"
|
|
"github.com/rs/xid"
|
|
"github.com/skip2/go-qrcode"
|
|
"golang.zx2c4.com/wireguard/wgctrl"
|
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
|
|
|
"github.com/ngoduykhanh/wireguard-ui/auth"
|
|
"github.com/ngoduykhanh/wireguard-ui/backup"
|
|
"github.com/ngoduykhanh/wireguard-ui/emailer"
|
|
"github.com/ngoduykhanh/wireguard-ui/firewall"
|
|
"github.com/ngoduykhanh/wireguard-ui/model"
|
|
"github.com/ngoduykhanh/wireguard-ui/store"
|
|
"github.com/ngoduykhanh/wireguard-ui/system"
|
|
"github.com/ngoduykhanh/wireguard-ui/telegram"
|
|
"github.com/ngoduykhanh/wireguard-ui/util"
|
|
"github.com/ngoduykhanh/wireguard-ui/wireguard"
|
|
)
|
|
|
|
var usernameRegexp = regexp.MustCompile("^\\w[\\w\\-.]*$")
|
|
|
|
// resolveServerID returns the server ID a request targets: the :id route
|
|
// param for /servers/:id/... routes, or util.DefaultServerID for the
|
|
// legacy bare routes (/, /new-client, ...) which always operate on the
|
|
// migrated default server.
|
|
func resolveServerID(c echo.Context) string {
|
|
if id := c.Param("id"); id != "" {
|
|
return id
|
|
}
|
|
return util.DefaultServerID
|
|
}
|
|
|
|
// buildEffectiveSettings merges the app-wide GlobalSetting (DNS/MTU/
|
|
// PersistentKeepalive) with a server's own EndpointAddress/DNSServers/MTU
|
|
// overrides (from ServerSetting) into a single model.GlobalSetting, so
|
|
// util.BuildClientConfig can keep its existing single-struct signature
|
|
// unchanged. Empty/zero overrides fall back to the global default, so
|
|
// existing single-server installs (no override ever set) are unaffected.
|
|
func buildEffectiveSettings(db store.IStore, serverID string) (model.GlobalSetting, error) {
|
|
globalSettings, err := db.GetGlobalSettings()
|
|
if err != nil {
|
|
return globalSettings, err
|
|
}
|
|
serverSettings, err := db.GetServerSettings(serverID)
|
|
if err == nil {
|
|
if serverSettings.EndpointAddress != "" {
|
|
globalSettings.EndpointAddress = serverSettings.EndpointAddress
|
|
}
|
|
if len(serverSettings.DNSServers) > 0 {
|
|
globalSettings.DNSServers = serverSettings.DNSServers
|
|
}
|
|
if serverSettings.MTU > 0 {
|
|
globalSettings.MTU = serverSettings.MTU
|
|
}
|
|
}
|
|
return globalSettings, nil
|
|
}
|
|
|
|
// Health check handler
|
|
func Health() echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
return c.String(http.StatusOK, "ok")
|
|
}
|
|
}
|
|
|
|
func Favicon() echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
if favicon, ok := os.LookupEnv(util.FaviconFilePathEnvVar); ok {
|
|
return c.File(favicon)
|
|
}
|
|
return c.Redirect(http.StatusFound, util.BasePath+"/static/custom/img/favicon.ico")
|
|
}
|
|
}
|
|
|
|
// LoginPage handler
|
|
func LoginPage() echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
return c.Render(http.StatusOK, "login.html", map[string]interface{}{})
|
|
}
|
|
}
|
|
|
|
// Login for signing in handler
|
|
func Login(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
data := make(map[string]interface{})
|
|
err := json.NewDecoder(c.Request().Body).Decode(&data)
|
|
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
|
}
|
|
|
|
username := data["username"].(string)
|
|
password := data["password"].(string)
|
|
rememberMe := data["rememberMe"].(bool)
|
|
|
|
if !usernameRegexp.MatchString(username) {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
|
|
}
|
|
|
|
dbuser, err := db.GetUserByName(username)
|
|
if err != nil {
|
|
log.Warnf("Invalid credentials. Cannot query user %s from DB (%s)", username, c.Request().RemoteAddr)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Invalid credentials"})
|
|
}
|
|
|
|
userCorrect := subtle.ConstantTimeCompare([]byte(username), []byte(dbuser.Username)) == 1
|
|
|
|
var passwordCorrect bool
|
|
if dbuser.PasswordHash != "" {
|
|
match, err := util.VerifyHash(dbuser.PasswordHash, password)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify password"})
|
|
}
|
|
passwordCorrect = match
|
|
} else {
|
|
passwordCorrect = subtle.ConstantTimeCompare([]byte(password), []byte(dbuser.Password)) == 1
|
|
}
|
|
|
|
if userCorrect && passwordCorrect {
|
|
if dbuser.TOTPEnabled {
|
|
// Password check passed, but this account requires a second
|
|
// factor. Stash a short-lived, NOT-yet-authenticated pending
|
|
// state instead of finalizing the session.
|
|
cookiePath := util.GetCookiePath()
|
|
|
|
sess, _ := session.Get("session", c)
|
|
sess.Options = &sessions.Options{
|
|
Path: cookiePath,
|
|
MaxAge: 300, // 5 minutes to enter the TOTP code
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
}
|
|
sess.Values["pending_totp_user"] = dbuser.Username
|
|
sess.Values["pending_remember_me"] = rememberMe
|
|
sess.Save(c.Request(), c.Response())
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"success": true, "totp_required": true})
|
|
}
|
|
|
|
if err := finalizeLoginSession(c, dbuser, rememberMe); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
|
|
log.Infof("Logged in successfully user %s (%s)", username, c.Request().RemoteAddr)
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Logged in successfully"})
|
|
}
|
|
|
|
log.Warnf("Invalid credentials user %s (%s)", username, c.Request().RemoteAddr)
|
|
return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "Invalid credentials"})
|
|
}
|
|
}
|
|
|
|
// finalizeLoginSession fully authenticates dbuser by writing the real
|
|
// session values and the session_token cookie. It is shared between the
|
|
// no-TOTP path of Login and the second-factor confirmation in
|
|
// VerifyTOTPLogin so the two code paths cannot drift apart.
|
|
func finalizeLoginSession(c echo.Context, dbuser model.User, rememberMe bool) error {
|
|
ageMax := 0
|
|
if rememberMe {
|
|
ageMax = 86400 * 7
|
|
}
|
|
|
|
cookiePath := util.GetCookiePath()
|
|
|
|
sess, _ := session.Get("session", c)
|
|
sess.Options = &sessions.Options{
|
|
Path: cookiePath,
|
|
MaxAge: ageMax,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
}
|
|
|
|
// set session_token
|
|
tokenUID := xid.New().String()
|
|
now := time.Now().UTC().Unix()
|
|
sess.Values["username"] = dbuser.Username
|
|
sess.Values["user_hash"] = util.GetDBUserCRC32(dbuser)
|
|
sess.Values["admin"] = dbuser.Admin
|
|
sess.Values["session_token"] = tokenUID
|
|
sess.Values["max_age"] = ageMax
|
|
sess.Values["created_at"] = now
|
|
sess.Values["updated_at"] = now
|
|
if err := sess.Save(c.Request(), c.Response()); err != nil {
|
|
return err
|
|
}
|
|
|
|
// set session_token in cookie
|
|
cookie := new(http.Cookie)
|
|
cookie.Name = "session_token"
|
|
cookie.Path = cookiePath
|
|
cookie.Value = tokenUID
|
|
cookie.MaxAge = ageMax
|
|
cookie.HttpOnly = true
|
|
cookie.SameSite = http.SameSiteLaxMode
|
|
c.SetCookie(cookie)
|
|
|
|
return nil
|
|
}
|
|
|
|
// VerifyTOTPLogin completes a login that was put into the "pending TOTP"
|
|
// state by Login, after the user supplies a valid 6-digit code.
|
|
func VerifyTOTPLogin(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
sess, _ := session.Get("session", c)
|
|
|
|
pendingUsername, ok := sess.Values["pending_totp_user"].(string)
|
|
if !ok || pendingUsername == "" {
|
|
return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "No pending login"})
|
|
}
|
|
|
|
rememberMe, _ := sess.Values["pending_remember_me"].(bool)
|
|
|
|
data := make(map[string]interface{})
|
|
if err := json.NewDecoder(c.Request().Body).Decode(&data); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
|
}
|
|
|
|
code, _ := data["code"].(string)
|
|
|
|
dbuser, err := db.GetUserByName(pendingUsername)
|
|
if err != nil {
|
|
log.Warnf("Invalid pending TOTP login. Cannot query user %s from DB (%s)", pendingUsername, c.Request().RemoteAddr)
|
|
return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "No pending login"})
|
|
}
|
|
|
|
if !dbuser.TOTPEnabled || !auth.Validate(dbuser.TOTPSecret, code, time.Now()) {
|
|
log.Warnf("Invalid TOTP code for user %s (%s)", pendingUsername, c.Request().RemoteAddr)
|
|
return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "Invalid code"})
|
|
}
|
|
|
|
// clear the pending state before finalizing the real session
|
|
delete(sess.Values, "pending_totp_user")
|
|
delete(sess.Values, "pending_remember_me")
|
|
sess.Save(c.Request(), c.Response())
|
|
|
|
if err := finalizeLoginSession(c, dbuser, rememberMe); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
|
|
log.Infof("Logged in successfully user %s (%s)", pendingUsername, c.Request().RemoteAddr)
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Logged in successfully"})
|
|
}
|
|
}
|
|
|
|
// GetUsers handler return a JSON list of all users
|
|
func GetUsers(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
usersList, err := db.GetUsers()
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
|
|
false, fmt.Sprintf("Cannot get user list: %v", err),
|
|
})
|
|
}
|
|
|
|
// never expose live TOTP secrets to API callers
|
|
for i := range usersList {
|
|
usersList[i].TOTPSecret = ""
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, usersList)
|
|
}
|
|
}
|
|
|
|
// GetUser handler returns a JSON object of single user
|
|
func GetUser(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
username := c.Param("username")
|
|
|
|
if !usernameRegexp.MatchString(username) {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
|
|
}
|
|
|
|
if !isAdmin(c) && (username != currentUser(c)) {
|
|
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Manager cannot access other user data"})
|
|
}
|
|
|
|
userData, err := db.GetUserByName(username)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "User not found"})
|
|
}
|
|
|
|
// Never expose the raw TOTP secret through this endpoint. Enrollment
|
|
// has its own dedicated, self-only route (/profile/totp/enroll).
|
|
userData.TOTPSecret = ""
|
|
|
|
return c.JSON(http.StatusOK, userData)
|
|
}
|
|
}
|
|
|
|
// Logout to log a user out
|
|
func Logout() echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
clearSession(c)
|
|
return c.Redirect(http.StatusTemporaryRedirect, util.BasePath+"/login")
|
|
}
|
|
}
|
|
|
|
// LoadProfile to load user information
|
|
func LoadProfile() echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
return c.Render(http.StatusOK, "profile.html", map[string]interface{}{
|
|
"baseData": model.BaseData{Active: "profile", CurrentUser: currentUser(c), Admin: isAdmin(c)},
|
|
})
|
|
}
|
|
}
|
|
|
|
// UsersSettings handler
|
|
func UsersSettings() echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
return c.Render(http.StatusOK, "users_settings.html", map[string]interface{}{
|
|
"baseData": model.BaseData{Active: "users-settings", CurrentUser: currentUser(c), Admin: isAdmin(c)},
|
|
})
|
|
}
|
|
}
|
|
|
|
// ServersPage renders the server list/create page
|
|
func ServersPage() echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
return c.Render(http.StatusOK, "servers.html", map[string]interface{}{
|
|
"baseData": model.BaseData{Active: "servers", CurrentUser: currentUser(c), Admin: isAdmin(c)},
|
|
})
|
|
}
|
|
}
|
|
|
|
// UpdateUser to update user information
|
|
func UpdateUser(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
data := make(map[string]interface{})
|
|
err := json.NewDecoder(c.Request().Body).Decode(&data)
|
|
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
|
}
|
|
|
|
username := data["username"].(string)
|
|
password := data["password"].(string)
|
|
previousUsername := data["previous_username"].(string)
|
|
admin := data["admin"].(bool)
|
|
|
|
if !isAdmin(c) && (previousUsername != currentUser(c)) {
|
|
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Manager cannot access other user data"})
|
|
}
|
|
|
|
if !isAdmin(c) {
|
|
admin = false
|
|
}
|
|
|
|
if !usernameRegexp.MatchString(previousUsername) {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
|
|
}
|
|
|
|
user, err := db.GetUserByName(previousUsername)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
|
|
if username == "" || !usernameRegexp.MatchString(username) {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
|
|
} else {
|
|
user.Username = username
|
|
}
|
|
|
|
if username != previousUsername {
|
|
_, err := db.GetUserByName(username)
|
|
if err == nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "This username is taken"})
|
|
}
|
|
}
|
|
|
|
if password != "" {
|
|
hash, err := util.HashPassword(password)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
user.PasswordHash = hash
|
|
}
|
|
|
|
if previousUsername != currentUser(c) {
|
|
user.Admin = admin
|
|
}
|
|
|
|
// only an admin may change which servers/clients a user can access
|
|
if isAdmin(c) {
|
|
if rawIDs, ok := data["server_ids"].([]interface{}); ok {
|
|
serverIDs := make([]string, 0, len(rawIDs))
|
|
for _, v := range rawIDs {
|
|
if id, ok := v.(string); ok && util.ValidateRecordID(id) {
|
|
serverIDs = append(serverIDs, id)
|
|
}
|
|
}
|
|
user.ServerIDs = serverIDs
|
|
}
|
|
|
|
if rawIDs, ok := data["client_ids"].([]interface{}); ok {
|
|
clientIDs := make([]string, 0, len(rawIDs))
|
|
for _, v := range rawIDs {
|
|
if id, ok := v.(string); ok && util.ValidateRecordID(id) {
|
|
clientIDs = append(clientIDs, id)
|
|
}
|
|
}
|
|
user.ClientIDs = clientIDs
|
|
}
|
|
}
|
|
|
|
if err := db.DeleteUser(previousUsername); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
if err := db.SaveUser(user); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
log.Infof("Updated user information successfully")
|
|
|
|
if previousUsername == currentUser(c) {
|
|
setUser(c, user.Username, user.Admin, util.GetDBUserCRC32(user))
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated user information successfully"})
|
|
}
|
|
}
|
|
|
|
// totpIssuer is the "issuer" name embedded in the otpauth:// provisioning
|
|
// URI, shown by authenticator apps next to the account name.
|
|
const totpIssuer = "WireGuard-UI-Multi"
|
|
|
|
// EnrollTOTP starts (or resumes) self-service TOTP enrollment for the
|
|
// currently logged-in user. It generates a secret on first call (leaving
|
|
// TOTPEnabled false until confirmed via ConfirmTOTP) and always returns a
|
|
// QR code / provisioning secret for the currently stored (unconfirmed or
|
|
// confirmed) secret.
|
|
func EnrollTOTP(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
username := currentUser(c)
|
|
|
|
user, err := db.GetUserByName(username)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "User not found"})
|
|
}
|
|
|
|
if user.TOTPSecret == "" {
|
|
secret, err := auth.GenerateSecret()
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate TOTP secret: " + err.Error()})
|
|
}
|
|
user.TOTPSecret = secret
|
|
user.TOTPEnabled = false
|
|
if err := db.SaveUser(user); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
}
|
|
|
|
uri := auth.ProvisioningURI(user.TOTPSecret, user.Username, totpIssuer)
|
|
|
|
png, err := qrcode.Encode(uri, qrcode.Medium, 256)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "qr gen: " + err.Error()})
|
|
}
|
|
qrDataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"secret": user.TOTPSecret,
|
|
"qrcode": qrDataURI,
|
|
"enabled": user.TOTPEnabled,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ConfirmTOTP verifies a code against the currently pending (unconfirmed)
|
|
// TOTP secret for the logged-in user, and enables TOTP on success.
|
|
func ConfirmTOTP(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
username := currentUser(c)
|
|
|
|
data := make(map[string]interface{})
|
|
if err := json.NewDecoder(c.Request().Body).Decode(&data); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
|
}
|
|
code, _ := data["code"].(string)
|
|
|
|
user, err := db.GetUserByName(username)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "User not found"})
|
|
}
|
|
|
|
if user.TOTPSecret == "" {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "TOTP enrollment has not been started"})
|
|
}
|
|
|
|
if !auth.Validate(user.TOTPSecret, code, time.Now()) {
|
|
log.Warnf("Invalid TOTP confirmation code for user %s (%s)", username, c.Request().RemoteAddr)
|
|
return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "Invalid code"})
|
|
}
|
|
|
|
user.TOTPEnabled = true
|
|
if err := db.SaveUser(user); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
|
|
log.Infof("User %s enabled two-factor authentication", username)
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Two-factor authentication enabled"})
|
|
}
|
|
}
|
|
|
|
// DisableTOTP disables TOTP for the current user, or - if the caller is an
|
|
// admin and explicitly names a different user - resets that other user's
|
|
// TOTP as an emergency lockout recovery mechanism.
|
|
func DisableTOTP(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
username := currentUser(c)
|
|
|
|
data := make(map[string]interface{})
|
|
// Body is optional for the self-service case.
|
|
_ = json.NewDecoder(c.Request().Body).Decode(&data)
|
|
|
|
targetUsername := username
|
|
if requested, ok := data["username"].(string); ok && requested != "" {
|
|
if isAdmin(c) && requested != currentUser(c) {
|
|
targetUsername = requested
|
|
} else if requested != currentUser(c) {
|
|
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Manager cannot access other user data"})
|
|
}
|
|
}
|
|
|
|
if !usernameRegexp.MatchString(targetUsername) {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
|
|
}
|
|
|
|
user, err := db.GetUserByName(targetUsername)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "User not found"})
|
|
}
|
|
|
|
user.TOTPSecret = ""
|
|
user.TOTPEnabled = false
|
|
if err := db.SaveUser(user); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
|
|
log.Infof("Two-factor authentication disabled for user %s (by %s)", targetUsername, username)
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Two-factor authentication disabled"})
|
|
}
|
|
}
|
|
|
|
// CreateUser to create new user
|
|
func CreateUser(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
data := make(map[string]interface{})
|
|
err := json.NewDecoder(c.Request().Body).Decode(&data)
|
|
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
|
}
|
|
|
|
var user model.User
|
|
username := data["username"].(string)
|
|
password := data["password"].(string)
|
|
admin := data["admin"].(bool)
|
|
|
|
if username == "" || !usernameRegexp.MatchString(username) {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
|
|
} else {
|
|
user.Username = username
|
|
}
|
|
|
|
{
|
|
_, err := db.GetUserByName(username)
|
|
if err == nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "This username is taken"})
|
|
}
|
|
}
|
|
|
|
hash, err := util.HashPassword(password)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
user.PasswordHash = hash
|
|
|
|
user.Admin = admin
|
|
|
|
if rawIDs, ok := data["server_ids"].([]interface{}); ok {
|
|
for _, v := range rawIDs {
|
|
if id, ok := v.(string); ok && util.ValidateRecordID(id) {
|
|
user.ServerIDs = append(user.ServerIDs, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
if rawIDs, ok := data["client_ids"].([]interface{}); ok {
|
|
for _, v := range rawIDs {
|
|
if id, ok := v.(string); ok && util.ValidateRecordID(id) {
|
|
user.ClientIDs = append(user.ClientIDs, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := db.SaveUser(user); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
log.Infof("Created user successfully")
|
|
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Created user successfully"})
|
|
}
|
|
}
|
|
|
|
// RemoveUser handler
|
|
func RemoveUser(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
data := make(map[string]interface{})
|
|
err := json.NewDecoder(c.Request().Body).Decode(&data)
|
|
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
|
}
|
|
|
|
username := data["username"].(string)
|
|
|
|
if !usernameRegexp.MatchString(username) {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
|
|
}
|
|
|
|
if username == currentUser(c) {
|
|
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "User cannot delete itself"})
|
|
}
|
|
// delete user from database
|
|
|
|
if err := db.DeleteUser(username); err != nil {
|
|
log.Error("Cannot delete user: ", err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot delete user from database"})
|
|
}
|
|
|
|
log.Infof("Removed user: %s", username)
|
|
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "User removed"})
|
|
}
|
|
}
|
|
|
|
// WireGuardClients handler
|
|
func WireGuardClients(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
clientDataList, err := db.GetClients(true)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
|
|
false, fmt.Sprintf("Cannot get client list: %v", err),
|
|
})
|
|
}
|
|
|
|
return c.Render(http.StatusOK, "clients.html", map[string]interface{}{
|
|
"baseData": model.BaseData{Active: "", CurrentUser: currentUser(c), Admin: isAdmin(c)},
|
|
"clientDataList": clientDataList,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ServerClientsPage renders the client management page scoped to one
|
|
// server (step 4 of the multi-server extension). Access is gated by the
|
|
// RequireServerAccess middleware at the route level.
|
|
func ServerClientsPage(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
serverID := c.Param("id")
|
|
server, err := db.GetServerByID(serverID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
|
|
}
|
|
|
|
clientDataList, err := db.GetClients(true)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
|
|
false, fmt.Sprintf("Cannot get client list: %v", err),
|
|
})
|
|
}
|
|
var filtered []model.ClientData
|
|
for _, cd := range clientDataList {
|
|
if cd.Client.ServerID == serverID {
|
|
filtered = append(filtered, cd)
|
|
}
|
|
}
|
|
|
|
return c.Render(http.StatusOK, "server_clients.html", map[string]interface{}{
|
|
"baseData": model.BaseData{Active: "servers", CurrentUser: currentUser(c), Admin: isAdmin(c)},
|
|
"server": server,
|
|
"serverID": serverID,
|
|
"clientDataList": filtered,
|
|
})
|
|
}
|
|
}
|
|
|
|
// GetClients handler return a JSON list of Wireguard client data
|
|
func GetClients(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
clientDataList, err := db.GetClients(true)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
|
|
false, fmt.Sprintf("Cannot get client list: %v", err),
|
|
})
|
|
}
|
|
|
|
for i, clientData := range clientDataList {
|
|
clientDataList[i] = util.FillClientSubnetRange(clientData)
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, clientDataList)
|
|
}
|
|
}
|
|
|
|
// GetClient handler returns a JSON object of Wireguard client data
|
|
func GetClient(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
clientID := c.Param("id")
|
|
|
|
if _, err := xid.FromString(clientID); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"})
|
|
}
|
|
|
|
qrCodeSettings := model.QRCodeSettings{
|
|
Enabled: true,
|
|
IncludeDNS: true,
|
|
IncludeMTU: true,
|
|
}
|
|
|
|
clientData, err := db.GetClientByID(clientID, qrCodeSettings)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, util.FillClientSubnetRange(clientData))
|
|
}
|
|
}
|
|
|
|
// GetServerClient handler returns a single client's JSON, scoped to a
|
|
// server: refuses (404) if the client does not belong to the :id server.
|
|
func GetServerClient(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
serverID := c.Param("id")
|
|
clientID := c.Param("cid")
|
|
|
|
if _, err := xid.FromString(clientID); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"})
|
|
}
|
|
|
|
qrCodeSettings := model.QRCodeSettings{
|
|
Enabled: true,
|
|
IncludeDNS: true,
|
|
IncludeMTU: true,
|
|
}
|
|
|
|
clientData, err := db.GetClientByID(clientID, qrCodeSettings)
|
|
if err != nil || clientData.Client.ServerID != serverID {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, util.FillClientSubnetRange(clientData))
|
|
}
|
|
}
|
|
|
|
// ListServers handler returns a JSON list of registered servers (step 2 of
|
|
// the multi-server extension - read-only, additive alongside the existing
|
|
// single-server routes).
|
|
func ListServers(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
servers, err := db.GetServers()
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
|
|
false, fmt.Sprintf("Cannot get server list: %v", err),
|
|
})
|
|
}
|
|
|
|
// non-admins only see servers they have been explicitly granted
|
|
if !util.DisableLogin && !isAdmin(c) {
|
|
user, err := db.GetUserByName(currentUser(c))
|
|
if err != nil {
|
|
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Access denied"})
|
|
}
|
|
allowed := make(map[string]bool, len(user.ServerIDs))
|
|
for _, id := range user.ServerIDs {
|
|
allowed[id] = true
|
|
}
|
|
var filtered []model.Server
|
|
for _, s := range servers {
|
|
if allowed[s.ID] {
|
|
filtered = append(filtered, s)
|
|
}
|
|
}
|
|
servers = filtered
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, servers)
|
|
}
|
|
}
|
|
|
|
// GetServerClients handler returns a JSON list of clients belonging to a
|
|
// single server (step 2 of the multi-server extension). Filtering happens
|
|
// in-handler for now since db.GetClients has not yet been switched to a
|
|
// server-scoped signature (that's a later step).
|
|
func GetServerClients(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
serverID := c.Param("id")
|
|
if !util.ValidateRecordID(serverID) {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid server ID"})
|
|
}
|
|
|
|
if _, err := db.GetServerByID(serverID); err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
|
|
}
|
|
|
|
clientDataList, err := db.GetClients(false)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
|
|
false, fmt.Sprintf("Cannot get client list: %v", err),
|
|
})
|
|
}
|
|
|
|
var filtered []model.ClientData
|
|
for _, cd := range clientDataList {
|
|
if cd.Client.ServerID == serverID {
|
|
filtered = append(filtered, cd)
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, filtered)
|
|
}
|
|
}
|
|
|
|
// GetServerSettings handler returns a single server's per-server settings
|
|
// (endpoint address, table, firewall mark, config file path) as JSON.
|
|
func GetServerSettings(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
serverID := c.Param("id")
|
|
settings, err := db.GetServerSettings(serverID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server settings not found"})
|
|
}
|
|
return c.JSON(http.StatusOK, settings)
|
|
}
|
|
}
|
|
|
|
// SaveServerSettingsHandler updates a single server's per-server settings.
|
|
// Admin-only (registered with handler.NeedsAdmin).
|
|
func SaveServerSettingsHandler(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
serverID := c.Param("id")
|
|
if _, err := db.GetServerByID(serverID); err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
|
|
}
|
|
|
|
var settings model.ServerSetting
|
|
if err := c.Bind(&settings); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
|
}
|
|
settings.UpdatedAt = time.Now().UTC()
|
|
|
|
if err := db.SaveServerSettings(serverID, settings); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, fmt.Sprintf("Cannot save server settings: %v", err)})
|
|
}
|
|
log.Infof("Updated settings for server %s", serverID)
|
|
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated server settings successfully"})
|
|
}
|
|
}
|
|
|
|
// CreateServer handler creates a new WireGuard server (step 3 of the
|
|
// multi-server extension). Admin-only. Generates a fresh key pair,
|
|
// validates the ID/interface name/subnet, and stores the server plus its
|
|
// per-server settings.
|
|
func CreateServer(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
type createServerRequest struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Interface string `json:"interface"`
|
|
Addresses []string `json:"addresses"`
|
|
ListenPort int `json:"listen_port"`
|
|
}
|
|
var req createServerRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
|
}
|
|
|
|
if !util.ValidateRecordID(req.ID) {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid server ID (letters, digits, - and _ only)"})
|
|
}
|
|
if !util.ValidateInterfaceName(req.Interface) {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid interface name (max 15 chars, letters/digits/-/_ only)"})
|
|
}
|
|
if !util.ValidateServerAddresses(req.Addresses) {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Address ranges must be in CIDR format"})
|
|
}
|
|
if req.ListenPort <= 0 || req.ListenPort > 65535 {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid listen port"})
|
|
}
|
|
if req.Name == "" {
|
|
req.Name = req.ID
|
|
}
|
|
|
|
if _, err := db.GetServerByID(req.ID); err == nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "A server with this ID already exists"})
|
|
}
|
|
|
|
key, err := wgtypes.GeneratePrivateKey()
|
|
if err != nil {
|
|
log.Error("Cannot generate wireguard key pair: ", err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"})
|
|
}
|
|
|
|
server := model.Server{
|
|
ID: req.ID,
|
|
Name: req.Name,
|
|
KeyPair: &model.ServerKeypair{
|
|
PrivateKey: key.String(),
|
|
PublicKey: key.PublicKey().String(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
},
|
|
Interface: &model.ServerInterface{
|
|
Name: req.Interface,
|
|
Addresses: req.Addresses,
|
|
ListenPort: req.ListenPort,
|
|
UpdatedAt: time.Now().UTC(),
|
|
},
|
|
}
|
|
|
|
if err := db.CreateServer(server); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, fmt.Sprintf("Cannot create server: %v", err)})
|
|
}
|
|
|
|
settings := model.ServerSetting{
|
|
ConfigFilePath: fmt.Sprintf("/etc/wireguard/%s.conf", req.Interface),
|
|
FirewallMark: util.DefaultFirewallMark,
|
|
Table: util.DefaultTable,
|
|
UpdatedAt: time.Now().UTC(),
|
|
}
|
|
if err := db.SaveServerSettings(req.ID, settings); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, fmt.Sprintf("Server created but settings failed: %v", err)})
|
|
}
|
|
|
|
log.Infof("Created server %s (%s)", req.ID, req.Name)
|
|
return c.JSON(http.StatusOK, server)
|
|
}
|
|
}
|
|
|
|
// RemoveServer handler deletes a server, refusing if any client still
|
|
// references it (see store.DeleteServer). Admin-only.
|
|
func RemoveServer(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
serverID := c.Param("id")
|
|
if err := db.DeleteServer(serverID); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
log.Infof("Removed server %s", serverID)
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Server removed successfully"})
|
|
}
|
|
}
|
|
|
|
// loadFirewallRuleset fetches everything needed to render a server's full
|
|
// nftables ruleset (baseline + custom rules).
|
|
func loadFirewallRuleset(db store.IStore, serverID string) (string, error) {
|
|
server, err := db.GetServerByID(serverID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("server not found")
|
|
}
|
|
settings, err := db.GetServerSettings(serverID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("server settings not found")
|
|
}
|
|
rules, err := db.GetFirewallRules(serverID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot load firewall rules: %v", err)
|
|
}
|
|
return firewall.GenerateRuleset(server, settings, rules), nil
|
|
}
|
|
|
|
// GetServerFirewallPreview returns the generated nftables ruleset (baseline
|
|
// + custom rules) for a server as plain text. Preview only.
|
|
func GetServerFirewallPreview(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
serverID := c.Param("id")
|
|
ruleset, err := loadFirewallRuleset(db, serverID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
return c.String(http.StatusOK, ruleset)
|
|
}
|
|
}
|
|
|
|
var validFirewallDirections = map[string]bool{"input": true, "forward": true}
|
|
var validFirewallProtocols = map[string]bool{"": true, "tcp": true, "udp": true}
|
|
var validFirewallActions = map[string]bool{"accept": true, "drop": true, "reject": true}
|
|
var firewallPortRegexp = regexp.MustCompile(`^[0-9]{1,5}(-[0-9]{1,5})?$`)
|
|
|
|
func validateFirewallRule(rule model.FirewallRule) error {
|
|
if !validFirewallDirections[rule.Direction] {
|
|
return fmt.Errorf("direction must be 'input' or 'forward'")
|
|
}
|
|
if !validFirewallProtocols[rule.Protocol] {
|
|
return fmt.Errorf("protocol must be 'tcp', 'udp', or empty")
|
|
}
|
|
if !validFirewallActions[rule.Action] {
|
|
return fmt.Errorf("action must be 'accept', 'drop', or 'reject'")
|
|
}
|
|
if rule.Port != "" && !firewallPortRegexp.MatchString(rule.Port) {
|
|
return fmt.Errorf("port must be a number or range like 8000-9000")
|
|
}
|
|
if rule.Source != "" {
|
|
if !util.ValidateServerAddresses([]string{rule.Source}) && net.ParseIP(rule.Source) == nil {
|
|
return fmt.Errorf("source must be a valid IP or CIDR")
|
|
}
|
|
}
|
|
if len(rule.Comment) > 200 {
|
|
return fmt.Errorf("comment too long")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetFirewallRules lists a server's custom firewall rules.
|
|
func GetFirewallRules(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
serverID := c.Param("id")
|
|
rules, err := db.GetFirewallRules(serverID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, rules)
|
|
}
|
|
}
|
|
|
|
// CreateFirewallRuleHandler creates a new custom firewall rule for a server.
|
|
func CreateFirewallRuleHandler(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
serverID := c.Param("id")
|
|
if _, err := db.GetServerByID(serverID); err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
|
|
}
|
|
var rule model.FirewallRule
|
|
if err := c.Bind(&rule); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
|
}
|
|
rule.ServerID = serverID
|
|
if err := validateFirewallRule(rule); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
rule.ID = xid.New().String()
|
|
rule.CreatedAt = time.Now().UTC()
|
|
rule.UpdatedAt = rule.CreatedAt
|
|
if err := db.CreateFirewallRule(rule); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, fmt.Sprintf("Cannot create rule: %v", err)})
|
|
}
|
|
return c.JSON(http.StatusOK, rule)
|
|
}
|
|
}
|
|
|
|
// UpdateFirewallRuleHandler updates an existing custom firewall rule.
|
|
func UpdateFirewallRuleHandler(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
serverID := c.Param("id")
|
|
ruleID := c.Param("ruleId")
|
|
var rule model.FirewallRule
|
|
if err := c.Bind(&rule); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
|
}
|
|
rule.ID = ruleID
|
|
rule.ServerID = serverID
|
|
if err := validateFirewallRule(rule); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
rule.UpdatedAt = time.Now().UTC()
|
|
if err := db.UpdateFirewallRule(rule); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, rule)
|
|
}
|
|
}
|
|
|
|
// DeleteFirewallRuleHandler removes a custom firewall rule.
|
|
func DeleteFirewallRuleHandler(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
serverID := c.Param("id")
|
|
ruleID := c.Param("ruleId")
|
|
if err := db.DeleteFirewallRule(serverID, ruleID); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Rule deleted successfully"})
|
|
}
|
|
}
|
|
|
|
// ApplyFirewallHandler generates the full ruleset for a server and loads it
|
|
// live via `nft -f`. This DOES modify the running firewall, scoped to this
|
|
// server's own nftables table only (see firewall.TableName).
|
|
func ApplyFirewallHandler(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
serverID := c.Param("id")
|
|
ruleset, err := loadFirewallRuleset(db, serverID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
output, err := firewall.Apply(serverID, ruleset)
|
|
if err != nil {
|
|
log.Errorf("Failed to apply firewall rules for server %s: %v\n%s", serverID, err, output)
|
|
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
|
|
"success": false,
|
|
"message": err.Error(),
|
|
"output": output,
|
|
})
|
|
}
|
|
log.Infof("Applied firewall rules for server %s", serverID)
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"success": true,
|
|
"message": "Firewall rules applied successfully",
|
|
"output": output,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ServerServiceStatus reports whether a server's WireGuard interface is
|
|
// currently active/enabled at the systemd level (wg-quick@<iface>.service).
|
|
func ServerServiceStatus(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
serverID := c.Param("id")
|
|
server, err := db.GetServerByID(serverID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
|
|
}
|
|
|
|
active, enabled, err := wireguard.Status(c.Request().Context(), server.Interface.Name)
|
|
if err != nil {
|
|
log.Errorf("Failed to get service status for server %s: %v", serverID, err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"active": active,
|
|
"enabled": enabled,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ServerServiceStart brings up a server's WireGuard interface via
|
|
// `systemctl start wg-quick@<iface>.service`. Admin-only.
|
|
//
|
|
// wg-quick refuses to start when /etc/wireguard/<iface>.conf doesn't exist
|
|
// yet - true for a freshly created or imported server that nobody has
|
|
// applied config for - so the config is (re)written from current DB state
|
|
// first.
|
|
func ServerServiceStart(db store.IStore, tmplDir fs.FS) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
serverID := c.Param("id")
|
|
server, err := db.GetServerByID(serverID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
|
|
}
|
|
|
|
if err := writeServerConfigToDisk(db, tmplDir, serverID); err != nil {
|
|
log.Errorf("Failed to write config before starting server %s: %v", serverID, err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
|
|
false, fmt.Sprintf("Cannot write server config: %v", err),
|
|
})
|
|
}
|
|
|
|
if err := wireguard.Start(c.Request().Context(), server.Interface.Name); err != nil {
|
|
log.Errorf("Failed to start service for server %s: %v", serverID, err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
log.Infof("Started wireguard interface for server %s", serverID)
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"active": true})
|
|
}
|
|
}
|
|
|
|
// ServerServiceStop brings down a server's WireGuard interface via
|
|
// `systemctl stop wg-quick@<iface>.service`. Admin-only.
|
|
func ServerServiceStop(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
serverID := c.Param("id")
|
|
server, err := db.GetServerByID(serverID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
|
|
}
|
|
|
|
if err := wireguard.Stop(c.Request().Context(), server.Interface.Name); err != nil {
|
|
log.Errorf("Failed to stop service for server %s: %v", serverID, err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
log.Infof("Stopped wireguard interface for server %s", serverID)
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"active": false})
|
|
}
|
|
}
|
|
|
|
// ServerServiceRestart restarts a server's WireGuard interface via
|
|
// `systemctl restart wg-quick@<iface>.service`. Admin-only.
|
|
func ServerServiceRestart(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
serverID := c.Param("id")
|
|
server, err := db.GetServerByID(serverID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
|
|
}
|
|
|
|
if err := wireguard.Restart(c.Request().Context(), server.Interface.Name); err != nil {
|
|
log.Errorf("Failed to restart service for server %s: %v", serverID, err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
log.Infof("Restarted wireguard interface for server %s", serverID)
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"active": true})
|
|
}
|
|
}
|
|
|
|
// NewClient handler
|
|
func NewClient(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
var client model.Client
|
|
c.Bind(&client)
|
|
|
|
// Validate Telegram userid if provided
|
|
if client.TgUserid != "" {
|
|
idNum, err := strconv.ParseInt(client.TgUserid, 10, 64)
|
|
if err != nil || idNum == 0 {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Telegram userid must be a non-zero number"})
|
|
}
|
|
}
|
|
|
|
// read server information
|
|
serverID := resolveServerID(c)
|
|
server, err := db.GetServerByID(serverID)
|
|
if err != nil {
|
|
log.Error("Cannot fetch server from database: ", err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
client.ServerID = serverID
|
|
|
|
// validate the input Allocation IPs
|
|
allocatedIPs, err := util.GetAllocatedIPs("")
|
|
check, err := util.ValidateIPAllocation(server.Interface.Addresses, allocatedIPs, client.AllocatedIPs)
|
|
if !check {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, fmt.Sprintf("%s", err)})
|
|
}
|
|
|
|
// validate the input AllowedIPs
|
|
if util.ValidateAllowedIPs(client.AllowedIPs) == false {
|
|
log.Warnf("Invalid Allowed IPs input from user: %v", client.AllowedIPs)
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Allowed IPs must be in CIDR format"})
|
|
}
|
|
|
|
// validate extra AllowedIPs
|
|
if util.ValidateExtraAllowedIPs(client.ExtraAllowedIPs) == false {
|
|
log.Warnf("Invalid Extra AllowedIPs input from user: %v", client.ExtraAllowedIPs)
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Extra AllowedIPs must be in CIDR format"})
|
|
}
|
|
|
|
// gen ID
|
|
guid := xid.New()
|
|
client.ID = guid.String()
|
|
|
|
// gen Wireguard key pair
|
|
if client.PublicKey == "" {
|
|
key, err := wgtypes.GeneratePrivateKey()
|
|
if err != nil {
|
|
log.Error("Cannot generate wireguard key pair: ", err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"})
|
|
}
|
|
client.PrivateKey = key.String()
|
|
client.PublicKey = key.PublicKey().String()
|
|
} else {
|
|
_, err := wgtypes.ParseKey(client.PublicKey)
|
|
if err != nil {
|
|
log.Error("Cannot verify wireguard public key: ", err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify Wireguard public key"})
|
|
}
|
|
// check for duplicates
|
|
clients, err := db.GetClients(false)
|
|
if err != nil {
|
|
log.Error("Cannot get clients for duplicate check")
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get clients for duplicate check"})
|
|
}
|
|
for _, other := range clients {
|
|
if other.Client.PublicKey == client.PublicKey {
|
|
log.Error("Duplicate Public Key")
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Duplicate Public Key"})
|
|
}
|
|
}
|
|
}
|
|
|
|
if client.PresharedKey == "" {
|
|
presharedKey, err := wgtypes.GenerateKey()
|
|
if err != nil {
|
|
log.Error("Cannot generated preshared key: ", err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
|
|
false, "Cannot generate Wireguard preshared key",
|
|
})
|
|
}
|
|
client.PresharedKey = presharedKey.String()
|
|
} else if client.PresharedKey == "-" {
|
|
client.PresharedKey = ""
|
|
log.Infof("skipped PresharedKey generation for user: %v", client.Name)
|
|
} else {
|
|
_, err := wgtypes.ParseKey(client.PresharedKey)
|
|
if err != nil {
|
|
log.Error("Cannot verify wireguard preshared key: ", err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify Wireguard preshared key"})
|
|
}
|
|
}
|
|
client.CreatedAt = time.Now().UTC()
|
|
client.UpdatedAt = client.CreatedAt
|
|
|
|
// write client to the database
|
|
if err := db.SaveClient(client); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
|
|
false, err.Error(),
|
|
})
|
|
}
|
|
log.Infof("Created wireguard client: %v", client)
|
|
|
|
return c.JSON(http.StatusOK, client)
|
|
}
|
|
}
|
|
|
|
// EmailClient handler to send the configuration via email
|
|
func EmailClient(db store.IStore, mailer emailer.Emailer, emailSubject, emailContent string) echo.HandlerFunc {
|
|
type clientIdEmailPayload struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
return func(c echo.Context) error {
|
|
var payload clientIdEmailPayload
|
|
c.Bind(&payload)
|
|
// TODO validate email
|
|
|
|
if _, err := xid.FromString(payload.ID); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"})
|
|
}
|
|
|
|
qrCodeSettings := model.QRCodeSettings{
|
|
Enabled: true,
|
|
IncludeDNS: true,
|
|
IncludeMTU: true,
|
|
}
|
|
clientData, err := db.GetClientByID(payload.ID, qrCodeSettings)
|
|
if err != nil {
|
|
log.Errorf("Cannot generate client id %s config file for downloading: %v", payload.ID, err)
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"})
|
|
}
|
|
|
|
// build config
|
|
clientServerID := clientData.Client.ServerID
|
|
if clientServerID == "" {
|
|
clientServerID = util.DefaultServerID
|
|
}
|
|
server, _ := db.GetServerByID(clientServerID)
|
|
globalSettings, _ := buildEffectiveSettings(db, clientServerID)
|
|
config := util.BuildClientConfig(*clientData.Client, server, globalSettings)
|
|
|
|
cfgAtt := emailer.Attachment{Name: fmt.Sprintf("%s.conf", clientServerID), Data: []byte(config)}
|
|
var attachments []emailer.Attachment
|
|
if clientData.Client.PrivateKey != "" {
|
|
qrdata, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(clientData.QRCode, "data:image/png;base64,"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "decoding: " + err.Error()})
|
|
}
|
|
qrAtt := emailer.Attachment{Name: "wg.png", Data: qrdata}
|
|
attachments = []emailer.Attachment{cfgAtt, qrAtt}
|
|
} else {
|
|
attachments = []emailer.Attachment{cfgAtt}
|
|
}
|
|
err = mailer.Send(
|
|
clientData.Client.Name,
|
|
payload.Email,
|
|
emailSubject,
|
|
emailContent,
|
|
attachments,
|
|
)
|
|
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Email sent successfully"})
|
|
}
|
|
}
|
|
|
|
// SendTelegramClient handler to send the configuration via Telegram
|
|
func SendTelegramClient(db store.IStore) echo.HandlerFunc {
|
|
type clientIdUseridPayload struct {
|
|
ID string `json:"id"`
|
|
Userid string `json:"userid"`
|
|
}
|
|
return func(c echo.Context) error {
|
|
var payload clientIdUseridPayload
|
|
c.Bind(&payload)
|
|
|
|
clientData, err := db.GetClientByID(payload.ID, model.QRCodeSettings{Enabled: false})
|
|
if err != nil {
|
|
log.Errorf("Cannot generate client id %s config file for downloading: %v", payload.ID, err)
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"})
|
|
}
|
|
|
|
// build config
|
|
clientServerID := clientData.Client.ServerID
|
|
if clientServerID == "" {
|
|
clientServerID = util.DefaultServerID
|
|
}
|
|
server, _ := db.GetServerByID(clientServerID)
|
|
globalSettings, _ := buildEffectiveSettings(db, clientServerID)
|
|
config := util.BuildClientConfig(*clientData.Client, server, globalSettings)
|
|
configData := []byte(config)
|
|
var qrData []byte
|
|
|
|
if clientData.Client.PrivateKey != "" {
|
|
qrData, err = qrcode.Encode(config, qrcode.Medium, 512)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "qr gen: " + err.Error()})
|
|
}
|
|
}
|
|
|
|
userid, err := strconv.ParseInt(clientData.Client.TgUserid, 10, 64)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "userid: " + err.Error()})
|
|
}
|
|
|
|
err = telegram.SendConfig(userid, clientData.Client.Name, configData, qrData, false)
|
|
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Telegram message sent successfully"})
|
|
}
|
|
}
|
|
|
|
// UpdateClient handler to update client information
|
|
func UpdateClient(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
var _client model.Client
|
|
c.Bind(&_client)
|
|
|
|
if _, err := xid.FromString(_client.ID); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"})
|
|
}
|
|
|
|
// validate client existence
|
|
clientData, err := db.GetClientByID(_client.ID, model.QRCodeSettings{Enabled: false})
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"})
|
|
}
|
|
|
|
// if reached via a /servers/:id/... route, refuse cross-server edits
|
|
if routeServerID := c.Param("id"); routeServerID != "" && clientData.Client.ServerID != routeServerID {
|
|
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Client does not belong to this server"})
|
|
}
|
|
|
|
// Validate Telegram userid if provided
|
|
if _client.TgUserid != "" {
|
|
idNum, err := strconv.ParseInt(_client.TgUserid, 10, 64)
|
|
if err != nil || idNum == 0 {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Telegram userid must be a non-zero number"})
|
|
}
|
|
}
|
|
|
|
clientServerID := clientData.Client.ServerID
|
|
if clientServerID == "" {
|
|
clientServerID = util.DefaultServerID
|
|
}
|
|
server, err := db.GetServerByID(clientServerID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{
|
|
false, fmt.Sprintf("Cannot fetch server config: %s", err),
|
|
})
|
|
}
|
|
client := *clientData.Client
|
|
// validate the input Allocation IPs
|
|
allocatedIPs, err := util.GetAllocatedIPs(client.ID)
|
|
check, err := util.ValidateIPAllocation(server.Interface.Addresses, allocatedIPs, _client.AllocatedIPs)
|
|
if !check {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, fmt.Sprintf("%s", err)})
|
|
}
|
|
|
|
// validate the input AllowedIPs
|
|
if util.ValidateAllowedIPs(_client.AllowedIPs) == false {
|
|
log.Warnf("Invalid Allowed IPs input from user: %v", _client.AllowedIPs)
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Allowed IPs must be in CIDR format"})
|
|
}
|
|
|
|
if util.ValidateExtraAllowedIPs(_client.ExtraAllowedIPs) == false {
|
|
log.Warnf("Invalid Allowed IPs input from user: %v", _client.ExtraAllowedIPs)
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Extra Allowed IPs must be in CIDR format"})
|
|
}
|
|
|
|
// update Wireguard Client PublicKey
|
|
if client.PublicKey != _client.PublicKey && _client.PublicKey != "" {
|
|
_, err := wgtypes.ParseKey(_client.PublicKey)
|
|
if err != nil {
|
|
log.Error("Cannot verify provided Wireguard public key: ", err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify provided Wireguard public key"})
|
|
}
|
|
// check for duplicates
|
|
clients, err := db.GetClients(false)
|
|
if err != nil {
|
|
log.Error("Cannot get client list for duplicate public key check")
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get client list for duplicate public key check"})
|
|
}
|
|
for _, other := range clients {
|
|
if other.Client.PublicKey == _client.PublicKey {
|
|
log.Error("Duplicate Public Key")
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Duplicate Public Key"})
|
|
}
|
|
}
|
|
|
|
// When replacing any PublicKey, discard any locally stored Wireguard Client PrivateKey
|
|
// Client PubKey no longer corresponds to locally stored PrivKey.
|
|
// QR code (needs PrivateKey) for this client is no longer possible now.
|
|
|
|
if client.PrivateKey != "" {
|
|
client.PrivateKey = ""
|
|
}
|
|
}
|
|
|
|
// update Wireguard Client PresharedKey
|
|
if client.PresharedKey != _client.PresharedKey && _client.PresharedKey != "" {
|
|
_, err := wgtypes.ParseKey(_client.PresharedKey)
|
|
if err != nil {
|
|
log.Error("Cannot verify provided Wireguard preshared key: ", err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify provided Wireguard preshared key"})
|
|
}
|
|
}
|
|
|
|
// map new data
|
|
client.Name = _client.Name
|
|
client.Email = _client.Email
|
|
client.TgUserid = _client.TgUserid
|
|
client.Enabled = _client.Enabled
|
|
client.UseServerDNS = _client.UseServerDNS
|
|
client.AllocatedIPs = _client.AllocatedIPs
|
|
client.AllowedIPs = _client.AllowedIPs
|
|
client.ExtraAllowedIPs = _client.ExtraAllowedIPs
|
|
client.Endpoint = _client.Endpoint
|
|
client.PublicKey = _client.PublicKey
|
|
client.PresharedKey = _client.PresharedKey
|
|
client.UpdatedAt = time.Now().UTC()
|
|
client.AdditionalNotes = strings.ReplaceAll(strings.Trim(_client.AdditionalNotes, "\r\n"), "\r\n", "\n")
|
|
|
|
// write to the database
|
|
if err := db.SaveClient(client); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
log.Infof("Updated client information successfully => %v", client)
|
|
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated client successfully"})
|
|
}
|
|
}
|
|
|
|
// SetClientStatus handler to enable / disable a client
|
|
func SetClientStatus(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
data := make(map[string]interface{})
|
|
err := json.NewDecoder(c.Request().Body).Decode(&data)
|
|
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
|
}
|
|
|
|
clientID := data["id"].(string)
|
|
status := data["status"].(bool)
|
|
|
|
if _, err := xid.FromString(clientID); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"})
|
|
}
|
|
|
|
clientData, err := db.GetClientByID(clientID, model.QRCodeSettings{Enabled: false})
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
|
|
if routeServerID := c.Param("id"); routeServerID != "" && clientData.Client.ServerID != routeServerID {
|
|
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Client does not belong to this server"})
|
|
}
|
|
|
|
client := *clientData.Client
|
|
|
|
client.Enabled = status
|
|
if err := db.SaveClient(client); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
log.Infof("Changed client %s enabled status to %v", client.ID, status)
|
|
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Changed client status successfully"})
|
|
}
|
|
}
|
|
|
|
// DownloadClient handler
|
|
func DownloadClient(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
clientID := c.QueryParam("clientid")
|
|
if clientID == "" {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Missing clientid parameter"})
|
|
}
|
|
|
|
if _, err := xid.FromString(clientID); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"})
|
|
}
|
|
|
|
clientData, err := db.GetClientByID(clientID, model.QRCodeSettings{Enabled: false})
|
|
if err != nil {
|
|
log.Errorf("Cannot generate client id %s config file for downloading: %v", clientID, err)
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"})
|
|
}
|
|
|
|
if routeServerID := c.Param("id"); routeServerID != "" && clientData.Client.ServerID != routeServerID {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"})
|
|
}
|
|
|
|
// build config
|
|
clientServerID := clientData.Client.ServerID
|
|
if clientServerID == "" {
|
|
clientServerID = util.DefaultServerID
|
|
}
|
|
server, err := db.GetServerByID(clientServerID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
globalSettings, err := buildEffectiveSettings(db, clientServerID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
config := util.BuildClientConfig(*clientData.Client, server, globalSettings)
|
|
|
|
// create io reader from string
|
|
reader := strings.NewReader(config)
|
|
|
|
// set response header for downloading
|
|
c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s.conf", clientData.Client.Name))
|
|
return c.Stream(http.StatusOK, "text/conf", reader)
|
|
}
|
|
}
|
|
|
|
// RemoveClient handler
|
|
func RemoveClient(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
client := new(model.Client)
|
|
c.Bind(client)
|
|
|
|
if _, err := xid.FromString(client.ID); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"})
|
|
}
|
|
|
|
if routeServerID := c.Param("id"); routeServerID != "" {
|
|
existing, err := db.GetClientByID(client.ID, model.QRCodeSettings{Enabled: false})
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"})
|
|
}
|
|
if existing.Client.ServerID != routeServerID {
|
|
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Client does not belong to this server"})
|
|
}
|
|
}
|
|
|
|
// delete client from database
|
|
|
|
if err := db.DeleteClient(client.ID); err != nil {
|
|
log.Error("Cannot delete wireguard client: ", err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot delete client from database"})
|
|
}
|
|
|
|
log.Infof("Removed wireguard client: %v", client)
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Client removed"})
|
|
}
|
|
}
|
|
|
|
// UpdateServerInterfaceHandler updates the WireGuard interface settings
|
|
// (addresses, listen port, up/down scripts) for a single server, identified
|
|
// by :id. This is the multi-server-aware handler surfaced from the "All
|
|
// Servers" page; the per-server registry (servers/<id>.json) is the single
|
|
// source of truth for every server, including the default one.
|
|
func UpdateServerInterfaceHandler(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
serverID := c.Param("id")
|
|
if _, err := db.GetServerByID(serverID); err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
|
|
}
|
|
|
|
var serverInterface model.ServerInterface
|
|
if err := c.Bind(&serverInterface); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
|
}
|
|
|
|
// validate the input addresses
|
|
if util.ValidateServerAddresses(serverInterface.Addresses) == false {
|
|
log.Warnf("Invalid server interface addresses input from user: %v", serverInterface.Addresses)
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Interface IP address must be in CIDR format"})
|
|
}
|
|
|
|
serverInterface.UpdatedAt = time.Now().UTC()
|
|
|
|
if err := db.UpdateServerInterface(serverID, serverInterface); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, fmt.Sprintf("Cannot update server interface: %v", err)})
|
|
}
|
|
|
|
log.Infof("Updated wireguard server interface settings for server %s: %v", serverID, serverInterface)
|
|
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated interface addresses successfully"})
|
|
}
|
|
}
|
|
|
|
// UpdateServerKeyPairHandler generates a fresh WireGuard key pair for a
|
|
// single server, identified by :id. The per-server registry
|
|
// (servers/<id>.json) is the single source of truth for every server,
|
|
// including the default one.
|
|
func UpdateServerKeyPairHandler(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
serverID := c.Param("id")
|
|
if _, err := db.GetServerByID(serverID); err != nil {
|
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
|
|
}
|
|
|
|
key, err := wgtypes.GeneratePrivateKey()
|
|
if err != nil {
|
|
log.Error("Cannot generate wireguard key pair: ", err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"})
|
|
}
|
|
|
|
var serverKeyPair model.ServerKeypair
|
|
serverKeyPair.PrivateKey = key.String()
|
|
serverKeyPair.PublicKey = key.PublicKey().String()
|
|
serverKeyPair.UpdatedAt = time.Now().UTC()
|
|
|
|
if err := db.UpdateServerKeyPair(serverID, serverKeyPair); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, fmt.Sprintf("Cannot update server key pair: %v", err)})
|
|
}
|
|
|
|
log.Infof("Updated wireguard server key pair for server %s", serverID)
|
|
|
|
// Never return the private key in the HTTP response body; the
|
|
// caller only needs the public key to update its view.
|
|
serverKeyPair.PrivateKey = ""
|
|
return c.JSON(http.StatusOK, serverKeyPair)
|
|
}
|
|
}
|
|
|
|
// GlobalSettings handler
|
|
func GlobalSettings(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
globalSettings, err := db.GetGlobalSettings()
|
|
if err != nil {
|
|
log.Error("Cannot get global settings: ", err)
|
|
}
|
|
|
|
return c.Render(http.StatusOK, "global_settings.html", map[string]interface{}{
|
|
"baseData": model.BaseData{Active: "global-settings", CurrentUser: currentUser(c), Admin: isAdmin(c)},
|
|
"globalSettings": globalSettings,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Status handler
|
|
func Status(db store.IStore) echo.HandlerFunc {
|
|
type PeerVM struct {
|
|
Name string
|
|
Email string
|
|
PublicKey string
|
|
ReceivedBytes int64
|
|
TransmitBytes int64
|
|
LastHandshakeTime time.Time
|
|
LastHandshakeRel time.Duration
|
|
Connected bool
|
|
AllocatedIP string
|
|
Endpoint string
|
|
}
|
|
|
|
type DeviceVM struct {
|
|
Name string
|
|
Peers []PeerVM
|
|
}
|
|
return func(c echo.Context) error {
|
|
wgClient, err := wgctrl.New()
|
|
if err != nil {
|
|
return c.Render(http.StatusInternalServerError, "status.html", map[string]interface{}{
|
|
"baseData": model.BaseData{Active: "status", CurrentUser: currentUser(c), Admin: isAdmin(c)},
|
|
"error": err.Error(),
|
|
"devices": nil,
|
|
})
|
|
}
|
|
|
|
devices, err := wgClient.Devices()
|
|
if err != nil {
|
|
return c.Render(http.StatusInternalServerError, "status.html", map[string]interface{}{
|
|
"baseData": model.BaseData{Active: "status", CurrentUser: currentUser(c), Admin: isAdmin(c)},
|
|
"error": err.Error(),
|
|
"devices": nil,
|
|
})
|
|
}
|
|
|
|
devicesVm := make([]DeviceVM, 0, len(devices))
|
|
if len(devices) > 0 {
|
|
m := make(map[string]*model.Client)
|
|
clients, err := db.GetClients(false)
|
|
if err != nil {
|
|
return c.Render(http.StatusInternalServerError, "status.html", map[string]interface{}{
|
|
"baseData": model.BaseData{Active: "status", CurrentUser: currentUser(c), Admin: isAdmin(c)},
|
|
"error": err.Error(),
|
|
"devices": nil,
|
|
})
|
|
}
|
|
for i := range clients {
|
|
if clients[i].Client != nil {
|
|
m[clients[i].Client.PublicKey] = clients[i].Client
|
|
}
|
|
}
|
|
|
|
conv := map[bool]int{true: 1, false: 0}
|
|
for i := range devices {
|
|
devVm := DeviceVM{Name: devices[i].Name}
|
|
for j := range devices[i].Peers {
|
|
var allocatedIPs string
|
|
for _, ip := range devices[i].Peers[j].AllowedIPs {
|
|
if len(allocatedIPs) > 0 {
|
|
allocatedIPs += "</br>"
|
|
}
|
|
allocatedIPs += ip.String()
|
|
}
|
|
pVm := PeerVM{
|
|
PublicKey: devices[i].Peers[j].PublicKey.String(),
|
|
ReceivedBytes: devices[i].Peers[j].ReceiveBytes,
|
|
TransmitBytes: devices[i].Peers[j].TransmitBytes,
|
|
LastHandshakeTime: devices[i].Peers[j].LastHandshakeTime,
|
|
LastHandshakeRel: time.Since(devices[i].Peers[j].LastHandshakeTime),
|
|
AllocatedIP: allocatedIPs,
|
|
}
|
|
pVm.Connected = pVm.LastHandshakeRel.Minutes() < 3.
|
|
|
|
if isAdmin(c) {
|
|
pVm.Endpoint = devices[i].Peers[j].Endpoint.String()
|
|
}
|
|
|
|
if _client, ok := m[pVm.PublicKey]; ok {
|
|
pVm.Name = _client.Name
|
|
pVm.Email = _client.Email
|
|
}
|
|
devVm.Peers = append(devVm.Peers, pVm)
|
|
}
|
|
sort.SliceStable(devVm.Peers, func(i, j int) bool { return devVm.Peers[i].Name < devVm.Peers[j].Name })
|
|
sort.SliceStable(devVm.Peers, func(i, j int) bool { return conv[devVm.Peers[i].Connected] > conv[devVm.Peers[j].Connected] })
|
|
devicesVm = append(devicesVm, devVm)
|
|
}
|
|
}
|
|
|
|
return c.Render(http.StatusOK, "status.html", map[string]interface{}{
|
|
"baseData": model.BaseData{Active: "status", CurrentUser: currentUser(c), Admin: isAdmin(c)},
|
|
"devices": devicesVm,
|
|
"error": "",
|
|
})
|
|
}
|
|
}
|
|
|
|
// GlobalSettingSubmit handler to update the global settings
|
|
func GlobalSettingSubmit(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
var globalSettings model.GlobalSetting
|
|
c.Bind(&globalSettings)
|
|
|
|
// validate the input dns server list
|
|
if util.ValidateIPAddressList(globalSettings.DNSServers) == false {
|
|
log.Warnf("Invalid DNS server list input from user: %v", globalSettings.DNSServers)
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Invalid DNS server address"})
|
|
}
|
|
|
|
globalSettings.UpdatedAt = time.Now().UTC()
|
|
|
|
// write the app-wide settings (DNS/MTU/PersistentKeepalive); per-server
|
|
// concerns (EndpointAddress/Table/FirewallMark/ConfigFilePath) live
|
|
// solely in the per-server registry's ServerSetting record, edited via
|
|
// the per-server settings editor, and are not duplicated here.
|
|
if err := db.SaveGlobalSettings(globalSettings); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"})
|
|
}
|
|
|
|
log.Infof("Updated global settings: %v", globalSettings)
|
|
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated global settings successfully"})
|
|
}
|
|
}
|
|
|
|
// MachineIPAddresses handler to get local interface ip addresses
|
|
func MachineIPAddresses() echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
// get private ip addresses
|
|
interfaceList, err := util.GetInterfaceIPs()
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get machine ip addresses"})
|
|
}
|
|
|
|
// get public ip address
|
|
// TODO: Remove the go-external-ip dependency
|
|
publicInterface, err := util.GetPublicIP()
|
|
if err != nil {
|
|
log.Warn("Cannot get machine public ip address: ", err)
|
|
} else {
|
|
// prepend public ip to the list
|
|
interfaceList = append([]model.Interface{publicInterface}, interfaceList...)
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, interfaceList)
|
|
}
|
|
}
|
|
|
|
// GetOrderedSubnetRanges handler to get the ordered list of subnet ranges
|
|
func GetOrderedSubnetRanges() echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
return c.JSON(http.StatusOK, util.SubnetRangesOrder)
|
|
}
|
|
}
|
|
|
|
// SuggestIPAllocation handler to get the list of ip address for client
|
|
func SuggestIPAllocation(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
serverID := c.QueryParam("server_id")
|
|
if serverID == "" {
|
|
serverID = util.DefaultServerID
|
|
}
|
|
server, err := db.GetServerByID(serverID)
|
|
if err != nil {
|
|
log.Error("Cannot fetch server config from database: ", err)
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
|
|
// return the list of suggestedIPs
|
|
// we take the first available ip address from
|
|
// each server's network addresses.
|
|
suggestedIPs := make([]string, 0)
|
|
allocatedIPs, err := util.GetAllocatedIPs("")
|
|
if err != nil {
|
|
log.Error("Cannot suggest ip allocation. Failed to get list of allocated ip addresses: ", err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
|
|
false, "Cannot suggest ip allocation: failed to get list of allocated ip addresses",
|
|
})
|
|
}
|
|
|
|
sr := c.QueryParam("sr")
|
|
searchCIDRList := make([]string, 0)
|
|
found := false
|
|
|
|
// Use subnet range or default to interface addresses
|
|
if util.SubnetRanges[sr] != nil {
|
|
for _, cidr := range util.SubnetRanges[sr] {
|
|
searchCIDRList = append(searchCIDRList, cidr.String())
|
|
}
|
|
} else {
|
|
searchCIDRList = append(searchCIDRList, server.Interface.Addresses...)
|
|
}
|
|
|
|
// Save only unique IPs
|
|
ipSet := make(map[string]struct{})
|
|
|
|
for _, cidr := range searchCIDRList {
|
|
ip, err := util.GetAvailableIP(cidr, allocatedIPs, server.Interface.Addresses)
|
|
if err != nil {
|
|
log.Error("Failed to get available ip from a CIDR: ", err)
|
|
continue
|
|
}
|
|
found = true
|
|
if strings.Contains(ip, ":") {
|
|
ipSet[fmt.Sprintf("%s/128", ip)] = struct{}{}
|
|
} else {
|
|
ipSet[fmt.Sprintf("%s/32", ip)] = struct{}{}
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
|
|
false,
|
|
"Cannot suggest ip allocation: failed to get available ip. Try a different subnet or deallocate some ips.",
|
|
})
|
|
}
|
|
|
|
for ip := range ipSet {
|
|
suggestedIPs = append(suggestedIPs, ip)
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, suggestedIPs)
|
|
}
|
|
}
|
|
|
|
// writeServerConfigToDisk regenerates a server's /etc/wireguard/<iface>.conf
|
|
// from current DB state (server, its own clients, users, effective
|
|
// settings). Shared by ApplyServerConfig and ServerServiceStart, since
|
|
// wg-quick refuses to start when the file is missing (e.g. right after a
|
|
// server is created or imported and no one has hit "Apply Config" yet).
|
|
func writeServerConfigToDisk(db store.IStore, tmplDir fs.FS, serverID string) error {
|
|
server, err := db.GetServerByID(serverID)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot get server config: %w", err)
|
|
}
|
|
|
|
allClients, err := db.GetClients(false)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot get client config: %w", err)
|
|
}
|
|
// only include this server's own clients as peers - other servers'
|
|
// clients must never leak into this config file
|
|
clients := make([]model.ClientData, 0, len(allClients))
|
|
for _, cd := range allClients {
|
|
clientServerID := cd.Client.ServerID
|
|
if clientServerID == "" {
|
|
clientServerID = util.DefaultServerID
|
|
}
|
|
if clientServerID == serverID {
|
|
clients = append(clients, cd)
|
|
}
|
|
}
|
|
|
|
users, err := db.GetUsers()
|
|
if err != nil {
|
|
return fmt.Errorf("cannot get users config: %w", err)
|
|
}
|
|
|
|
settings, err := buildEffectiveSettings(db, serverID)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot get global settings: %w", err)
|
|
}
|
|
if serverID != util.DefaultServerID {
|
|
if serverSettings, sErr := db.GetServerSettings(serverID); sErr == nil && serverSettings.ConfigFilePath != "" {
|
|
settings.ConfigFilePath = serverSettings.ConfigFilePath
|
|
}
|
|
}
|
|
|
|
return util.WriteWireGuardServerConfig(tmplDir, server, clients, users, settings)
|
|
}
|
|
|
|
// ApplyServerConfig handler to write config file and restart Wireguard server
|
|
func ApplyServerConfig(db store.IStore, tmplDir fs.FS) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
serverID := resolveServerID(c)
|
|
|
|
if err := writeServerConfigToDisk(db, tmplDir, serverID); err != nil {
|
|
log.Error("Cannot apply server config: ", err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
|
|
false, fmt.Sprintf("Cannot apply server config: %v", err),
|
|
})
|
|
}
|
|
|
|
err := util.UpdateHashes(db)
|
|
if err != nil {
|
|
log.Error("Cannot update hashes: ", err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
|
|
false, fmt.Sprintf("Cannot update hashes: %v", err),
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Applied server config successfully"})
|
|
}
|
|
}
|
|
|
|
// GetHashesChanges handler returns if database hashes have changed
|
|
func GetHashesChanges(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
if util.HashesChanged(db) {
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Hashes changed"})
|
|
} else {
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{false, "Hashes not changed"})
|
|
}
|
|
}
|
|
}
|
|
|
|
// DownloadBackup handler builds a tar.gz snapshot of the entire jsondb
|
|
// directory (all servers/clients/users/settings, everything needed to
|
|
// restore this installation) and returns it as a file download. If a
|
|
// non-empty "passphrase" is given in the JSON body, the archive is
|
|
// encrypted (AES-256-GCM via backup.Encrypt) before being returned.
|
|
// Admin-only. The archive is never transmitted anywhere automatically -
|
|
// it only ever goes out as this one HTTP response to the requesting admin.
|
|
func DownloadBackup(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
var payload struct {
|
|
Passphrase string `json:"passphrase"`
|
|
}
|
|
// best-effort bind; an empty/absent body just means "no encryption"
|
|
c.Bind(&payload)
|
|
|
|
archiveData, err := backup.BuildArchive(db.GetPath())
|
|
if err != nil {
|
|
log.Error("Cannot build backup archive: ", err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot build backup archive"})
|
|
}
|
|
|
|
filename := fmt.Sprintf("wireguard-ui-multi-backup-%s.tar.gz", time.Now().UTC().Format("20060102-150405"))
|
|
if payload.Passphrase != "" {
|
|
archiveData, err = backup.Encrypt(archiveData, payload.Passphrase)
|
|
if err != nil {
|
|
log.Error("Cannot encrypt backup archive: ", err)
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot encrypt backup archive"})
|
|
}
|
|
filename += ".enc"
|
|
}
|
|
|
|
c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s", filename))
|
|
return c.Blob(http.StatusOK, "application/octet-stream", archiveData)
|
|
}
|
|
}
|
|
|
|
// AboutPage handler
|
|
func AboutPage() echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
return c.Render(http.StatusOK, "about.html", map[string]interface{}{
|
|
"baseData": model.BaseData{Active: "about", CurrentUser: currentUser(c), Admin: isAdmin(c)},
|
|
})
|
|
}
|
|
}
|
|
|
|
// GetSystemUpdateStatus reports pending OS package updates (Debian/Ubuntu
|
|
// via apt), read-only - nothing is installed or upgraded.
|
|
func GetSystemUpdateStatus() echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
return c.JSON(http.StatusOK, system.CheckAptUpdates())
|
|
}
|
|
}
|
|
|
|
// FirewallListsPage renders the host-wide allow/block list management page.
|
|
func FirewallListsPage() echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
return c.Render(http.StatusOK, "firewall_lists.html", map[string]interface{}{
|
|
"baseData": model.BaseData{Active: "firewall-lists", CurrentUser: currentUser(c), Admin: isAdmin(c)},
|
|
})
|
|
}
|
|
}
|
|
|
|
// dangerousBlockRanges are private/reserved/bogon IPv4 supernets that public
|
|
// threat-intel feeds (FireHOL level1, some Spamhaus-derived lists, etc.)
|
|
// often include, since they're meaningless on a public WAN edge. On a host
|
|
// that also uses private address space internally (LAN, WireGuard client
|
|
// subnets - as this one does), blocking these at the global (host-wide)
|
|
// priority-10 hook would drop that host's own internal/VPN traffic. So
|
|
// "block" entries overlapping any of these are rejected outright; "allow"
|
|
// entries are never affected.
|
|
var dangerousBlockRanges = mustParseCIDRs([]string{
|
|
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8",
|
|
"169.254.0.0/16", "172.16.0.0/12", "192.168.0.0/16",
|
|
"192.0.0.0/24", "192.0.2.0/24", "198.18.0.0/15", "198.51.100.0/24",
|
|
"203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4",
|
|
})
|
|
|
|
func mustParseCIDRs(cidrs []string) []*net.IPNet {
|
|
nets := make([]*net.IPNet, 0, len(cidrs))
|
|
for _, c := range cidrs {
|
|
_, n, err := net.ParseCIDR(c)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
nets = append(nets, n)
|
|
}
|
|
return nets
|
|
}
|
|
|
|
// overlapsDangerousRange reports whether cidr (an IP or CIDR string)
|
|
// overlaps any private/reserved/bogon supernet in dangerousBlockRanges.
|
|
func overlapsDangerousRange(cidr string) bool {
|
|
var ipNet *net.IPNet
|
|
if ip, n, err := net.ParseCIDR(cidr); err == nil {
|
|
ipNet = n
|
|
_ = ip
|
|
} else if ip := net.ParseIP(cidr); ip != nil {
|
|
ipNet = &net.IPNet{IP: ip, Mask: net.CIDRMask(32, 32)}
|
|
} else {
|
|
return false
|
|
}
|
|
for _, dangerous := range dangerousBlockRanges {
|
|
if dangerous.Contains(ipNet.IP) || ipNet.Contains(dangerous.IP) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validateIPListEntry(entry model.IPListEntry) error {
|
|
if entry.ListType != "allow" && entry.ListType != "block" {
|
|
return fmt.Errorf("list_type must be 'allow' or 'block'")
|
|
}
|
|
if !util.ValidateServerAddresses([]string{entry.CIDR}) && net.ParseIP(entry.CIDR) == nil {
|
|
return fmt.Errorf("cidr must be a valid IP or CIDR")
|
|
}
|
|
if entry.ListType == "block" && overlapsDangerousRange(entry.CIDR) {
|
|
return fmt.Errorf("refusing to block %s: overlaps a private/reserved range - this host uses private address space internally (LAN/WireGuard subnets), blocking it here would cut off your own traffic", entry.CIDR)
|
|
}
|
|
if len(entry.Comment) > 200 {
|
|
return fmt.Errorf("comment too long")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetIPListEntries lists every host-wide allow/block list entry.
|
|
func GetIPListEntries(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
entries, err := db.GetIPListEntries()
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, entries)
|
|
}
|
|
}
|
|
|
|
// CreateIPListEntryHandler adds a new host-wide allow/block list entry.
|
|
func CreateIPListEntryHandler(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
var entry model.IPListEntry
|
|
if err := c.Bind(&entry); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
|
}
|
|
if err := validateIPListEntry(entry); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
entry.ID = xid.New().String()
|
|
entry.CreatedAt = time.Now().UTC()
|
|
if err := db.CreateIPListEntry(entry); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, fmt.Sprintf("Cannot create entry: %v", err)})
|
|
}
|
|
return c.JSON(http.StatusOK, entry)
|
|
}
|
|
}
|
|
|
|
// DeleteIPListEntryHandler removes a host-wide allow/block list entry.
|
|
func DeleteIPListEntryHandler(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
id := c.Param("id")
|
|
if err := db.DeleteIPListEntry(id); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Entry deleted successfully"})
|
|
}
|
|
}
|
|
|
|
// maxBulkImportLines caps the number of lines accepted by
|
|
// BulkImportIPListEntries, to avoid pathological input.
|
|
const maxBulkImportLines = 5000
|
|
|
|
// BulkImportIPListEntries imports many allow/block list entries at once from
|
|
// a pasted block of text, one CIDR/IP per line. Lines may have an optional
|
|
// "# comment" suffix. Blank lines and lines starting with '#' are ignored.
|
|
// All imported entries share the same list_type for the whole batch.
|
|
func BulkImportIPListEntries(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
var payload struct {
|
|
ListType string `json:"list_type"`
|
|
Text string `json:"text"`
|
|
}
|
|
if err := c.Bind(&payload); err != nil {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
|
}
|
|
if payload.ListType != "allow" && payload.ListType != "block" {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "list_type must be 'allow' or 'block'"})
|
|
}
|
|
|
|
lines := strings.Split(payload.Text, "\n")
|
|
if len(lines) > maxBulkImportLines {
|
|
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false,
|
|
fmt.Sprintf("Too many lines: %d (max %d)", len(lines), maxBulkImportLines)})
|
|
}
|
|
|
|
existing, err := db.GetIPListEntries()
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
existingSet := make(map[string]bool, len(existing))
|
|
for _, e := range existing {
|
|
existingSet[e.ListType+"|"+e.CIDR] = true
|
|
}
|
|
|
|
type invalidLine struct {
|
|
Line int `json:"line"`
|
|
Text string `json:"text"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
imported := 0
|
|
skippedDuplicates := 0
|
|
var invalidLines []invalidLine
|
|
|
|
for i, raw := range lines {
|
|
lineNum := i + 1
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
|
continue
|
|
}
|
|
|
|
cidr := trimmed
|
|
comment := ""
|
|
if idx := strings.Index(trimmed, "#"); idx >= 0 {
|
|
cidr = strings.TrimSpace(trimmed[:idx])
|
|
comment = strings.TrimSpace(trimmed[idx+1:])
|
|
}
|
|
if cidr == "" {
|
|
continue
|
|
}
|
|
|
|
entry := model.IPListEntry{
|
|
ListType: payload.ListType,
|
|
CIDR: cidr,
|
|
Comment: comment,
|
|
}
|
|
if err := validateIPListEntry(entry); err != nil {
|
|
invalidLines = append(invalidLines, invalidLine{Line: lineNum, Text: raw, Error: err.Error()})
|
|
continue
|
|
}
|
|
|
|
key := entry.ListType + "|" + entry.CIDR
|
|
if existingSet[key] {
|
|
skippedDuplicates++
|
|
continue
|
|
}
|
|
|
|
entry.ID = xid.New().String()
|
|
entry.CreatedAt = time.Now().UTC()
|
|
if err := db.CreateIPListEntry(entry); err != nil {
|
|
invalidLines = append(invalidLines, invalidLine{Line: lineNum, Text: raw, Error: err.Error()})
|
|
continue
|
|
}
|
|
existingSet[key] = true
|
|
imported++
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"success": true,
|
|
"imported": imported,
|
|
"skipped_duplicates": skippedDuplicates,
|
|
"invalid_count": len(invalidLines),
|
|
"invalid_lines": invalidLines,
|
|
})
|
|
}
|
|
}
|
|
|
|
// GetGlobalFirewallPreview returns the generated host-wide allow/block list
|
|
// ruleset as plain text. Preview only.
|
|
func GetGlobalFirewallPreview(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
entries, err := db.GetIPListEntries()
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
return c.String(http.StatusOK, firewall.GenerateGlobalRuleset(entries))
|
|
}
|
|
}
|
|
|
|
// GetGlobalFirewallStatus reports whether the host-wide allow/block list
|
|
// table is currently loaded in the live firewall.
|
|
func GetGlobalFirewallStatus() echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"enabled": firewall.IsGlobalEnabled(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// DisableGlobalFirewallHandler removes the host-wide allow/block list table
|
|
// from the live firewall, without touching the stored entries.
|
|
func DisableGlobalFirewallHandler() echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
output, err := firewall.DisableGlobal()
|
|
if err != nil {
|
|
log.Errorf("Failed to disable global firewall: %v\n%s", err, output)
|
|
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
|
|
"success": false,
|
|
"message": err.Error(),
|
|
"output": output,
|
|
})
|
|
}
|
|
log.Infof("Disabled global firewall allow/block lists")
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"success": true,
|
|
"message": "Global firewall disabled",
|
|
"output": output,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ApplyGlobalFirewallHandler loads the host-wide allow/block list ruleset
|
|
// live via `nft -f`, scoped to firewall.GlobalTableName only. Runs at
|
|
// priority -10, before every per-server WireGuard firewall table, so it
|
|
// applies to all traffic on the host.
|
|
func ApplyGlobalFirewallHandler(db store.IStore) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
entries, err := db.GetIPListEntries()
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
|
}
|
|
ruleset := firewall.GenerateGlobalRuleset(entries)
|
|
output, err := firewall.ApplyGlobal(ruleset)
|
|
if err != nil {
|
|
log.Errorf("Failed to apply global firewall lists: %v\n%s", err, output)
|
|
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
|
|
"success": false,
|
|
"message": err.Error(),
|
|
"output": output,
|
|
})
|
|
}
|
|
log.Infof("Applied global firewall allow/block lists")
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"success": true,
|
|
"message": "Global firewall lists applied successfully",
|
|
"output": output,
|
|
})
|
|
}
|
|
}
|