From ff521c4a31b277fe9026ea112b5321819015f23a Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Wed, 21 Aug 2024 23:47:18 +0200 Subject: [PATCH] split and add vpn configuration in UI (#8) * split and add vpn configuration in UI --- docs/release-notes.md | 7 +- latest | 2 +- pkg/configmanager/handlers.go | 25 ++ pkg/configmanager/router.go | 1 + pkg/configmanager/start_darwin.go | 5 + pkg/configmanager/start_linux.go | 4 + pkg/configmanager/upgrade.go | 3 + pkg/configmanager/upgrade_test.go | 63 +++++ pkg/rest/router.go | 5 +- pkg/rest/setup.go | 234 +++++++++++++++-- pkg/rest/types.go | 20 +- pkg/wireguard/ip.go | 40 ++- pkg/wireguard/ip_test.go | 82 +++++- pkg/wireguard/startup.go | 19 +- pkg/wireguard/wireguardclientconfig.go | 58 ++++- pkg/wireguard/wireguardclientconfig_test.go | 212 +++++++++++++++- pkg/wireguard/wireguardserverconfig.go | 27 +- webapp/src/Routes/Setup/GeneralSetup.tsx | 208 +++++++++++++++ webapp/src/Routes/Setup/Restart.tsx | 55 ++++ webapp/src/Routes/Setup/Setup.tsx | 265 ++++---------------- webapp/src/Routes/Setup/TemplateSetup.tsx | 114 +++++++++ webapp/src/Routes/Setup/VPNSetup.tsx | 228 +++++++++++++++++ webapp/src/Routes/Users/Users.tsx | 2 +- 23 files changed, 1395 insertions(+), 284 deletions(-) create mode 100644 webapp/src/Routes/Setup/GeneralSetup.tsx create mode 100644 webapp/src/Routes/Setup/Restart.tsx create mode 100644 webapp/src/Routes/Setup/TemplateSetup.tsx create mode 100644 webapp/src/Routes/Setup/VPNSetup.tsx diff --git a/docs/release-notes.md b/docs/release-notes.md index cded076..2b79097 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,5 +1,10 @@ # Release Notes +## Version v1.1.0 +* UI: change VPN configuration within the admin UI +* UI: ability to reload WireGuard® configuration +* UI: modify client/server WireGuard® configuration files using templates + ## Version v1.0.41 * UI: axios version bump * UI: disable https forwarding when request is served over http @@ -47,4 +52,4 @@ Once upgraded to this release, new upgrades can be done through the UI. * Local Users Support * OIDC Support -* Wireguard® for VPN Connections +* WireGuard® for VPN Connections diff --git a/latest b/latest index 4f45e65..795460f 100644 --- a/latest +++ b/latest @@ -1 +1 @@ -v1.0.41 +v1.1.0 diff --git a/pkg/configmanager/handlers.go b/pkg/configmanager/handlers.go index fd36f51..2b8edba 100644 --- a/pkg/configmanager/handlers.go +++ b/pkg/configmanager/handlers.go @@ -117,6 +117,31 @@ func (c *ConfigManager) version(w http.ResponseWriter, r *http.Request) { } } +func (c *ConfigManager) restartVpn(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + err := stopVPN(c.Storage) + if err != nil { // don't exit, as the VPN might be down already. + fmt.Println("========= Warning =========") + fmt.Printf("Warning: vpn stop error: %s\n", err) + fmt.Println("=========================") + } + err = startVPN(c.Storage) + if err != nil { + returnError(w, fmt.Errorf("vpn start error: %s", err), http.StatusBadRequest) + return + } + err = refreshAllClientsAndServer(c.Storage) + if err != nil { + returnError(w, fmt.Errorf("could not refresh all clients: %s", err), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusAccepted) + default: + returnError(w, fmt.Errorf("method not supported"), http.StatusBadRequest) + } +} + func returnError(w http.ResponseWriter, err error, statusCode int) { fmt.Println("========= ERROR =========") fmt.Printf("Error: %s\n", err) diff --git a/pkg/configmanager/router.go b/pkg/configmanager/router.go index 662bd3d..59a32d8 100644 --- a/pkg/configmanager/router.go +++ b/pkg/configmanager/router.go @@ -8,6 +8,7 @@ func (c *ConfigManager) getRouter() *http.ServeMux { mux.Handle("/pubkey", http.HandlerFunc(c.getPubKey)) mux.Handle("/refresh-clients", http.HandlerFunc(c.refreshClients)) mux.Handle("/upgrade", http.HandlerFunc(c.upgrade)) + mux.Handle("/restart-vpn", http.HandlerFunc(c.restartVpn)) mux.Handle("/version", http.HandlerFunc(c.version)) return mux diff --git a/pkg/configmanager/start_darwin.go b/pkg/configmanager/start_darwin.go index 963b023..47864ee 100644 --- a/pkg/configmanager/start_darwin.go +++ b/pkg/configmanager/start_darwin.go @@ -13,3 +13,8 @@ func startVPN(storage storage.Iface) error { fmt.Printf("Warning: startVPN is not implemented in darwin\n") return nil } + +func stopVPN(storage storage.Iface) error { + fmt.Printf("Warning: startVPN is not implemented in darwin\n") + return nil +} diff --git a/pkg/configmanager/start_linux.go b/pkg/configmanager/start_linux.go index c1bbe27..5516755 100644 --- a/pkg/configmanager/start_linux.go +++ b/pkg/configmanager/start_linux.go @@ -17,3 +17,7 @@ func startVPN(storage storage.Iface) error { } return wireguard.StartVPN() } + +func stopVPN(storage storage.Iface) error { + return wireguard.StopVPN() +} diff --git a/pkg/configmanager/upgrade.go b/pkg/configmanager/upgrade.go index 19254c1..7cdfc0e 100644 --- a/pkg/configmanager/upgrade.go +++ b/pkg/configmanager/upgrade.go @@ -42,6 +42,9 @@ func newVersionAvailable() (bool, string, error) { if i1 > i2 { return true, latestVersion, nil } + if i1 < i2 { + return false, latestVersion, nil + } } } return false, latestVersion, nil diff --git a/pkg/configmanager/upgrade_test.go b/pkg/configmanager/upgrade_test.go index 3724bb4..bcc5f18 100644 --- a/pkg/configmanager/upgrade_test.go +++ b/pkg/configmanager/upgrade_test.go @@ -145,3 +145,66 @@ func TestNewVersionAvailableBogus2(t *testing.T) { t.Fatalf("expected new version not to be available: %s", version) } } +func TestNewVersionAvailableHigherVersionMajor(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.RequestURI() == "/latest" { + currentVersionSplit := strings.Split(getVersion(), ".") + if len(currentVersionSplit) != 3 { + t.Fatalf("unsupported current version: %s", getVersion()) + } + i, err := strconv.Atoi(currentVersionSplit[1]) + if err != nil { + t.Fatalf("unsupported current version: %s", getVersion()) + } + i++ + newVersion := strings.Join([]string{currentVersionSplit[0], strconv.Itoa(i), "0"}, ".") + w.Write([]byte(newVersion)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + + defer server.Close() + + BINARIES_URL = server.URL + + available, version, err := newVersionAvailable() + if err != nil { + t.Fatalf("error: %s", err) + } + if !available { + t.Fatalf("expected new version expected to be available: %s", version) + } +} + +func TestNewVersionNotAvailableHigherVersionMajor(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.RequestURI() == "/latest" { + currentVersionSplit := strings.Split(getVersion(), ".") + if len(currentVersionSplit) != 3 { + t.Fatalf("unsupported current version: %s", getVersion()) + } + i, err := strconv.Atoi(currentVersionSplit[1]) + if err != nil { + t.Fatalf("unsupported current version: %s", getVersion()) + } + i-- + newVersion := strings.Join([]string{currentVersionSplit[0], strconv.Itoa(i), "99"}, ".") + w.Write([]byte(newVersion)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + + defer server.Close() + + BINARIES_URL = server.URL + + available, version, err := newVersionAvailable() + if err != nil { + t.Fatalf("error: %s", err) + } + if available { + t.Fatalf("expected new version expected not to be available: %s (current version: %s)", version, getVersion()) + } +} diff --git a/pkg/rest/router.go b/pkg/rest/router.go index f10e858..9c11787 100644 --- a/pkg/rest/router.go +++ b/pkg/rest/router.go @@ -53,7 +53,10 @@ func (c *Context) getRouter(assets fs.FS, indexHtml []byte) *http.ServeMux { mux.Handle("/api/oidc", c.authMiddleware(c.injectUserMiddleware(c.isAdminMiddleware(http.HandlerFunc(c.oidcProviderHandler))))) mux.Handle("/api/oidc-renew-tokens", c.authMiddleware(c.injectUserMiddleware(c.isAdminMiddleware(http.HandlerFunc(c.oidcRenewTokensHandler))))) mux.Handle("/api/oidc/{id}", c.authMiddleware(c.injectUserMiddleware(c.isAdminMiddleware(http.HandlerFunc(c.oidcProviderElementHandler))))) - mux.Handle("/api/setup", c.authMiddleware(c.injectUserMiddleware(c.isAdminMiddleware(http.HandlerFunc(c.setupHandler))))) + mux.Handle("/api/setup/general", c.authMiddleware(c.injectUserMiddleware(c.isAdminMiddleware(http.HandlerFunc(c.setupHandler))))) + mux.Handle("/api/setup/vpn", c.authMiddleware(c.injectUserMiddleware(c.isAdminMiddleware(http.HandlerFunc(c.vpnSetupHandler))))) + mux.Handle("/api/setup/templates", c.authMiddleware(c.injectUserMiddleware(c.isAdminMiddleware(http.HandlerFunc(c.templateSetupHandler))))) + mux.Handle("/api/setup/restart-vpn", c.authMiddleware(c.injectUserMiddleware(c.isAdminMiddleware(http.HandlerFunc(c.restartVPNHandler))))) mux.Handle("/api/scim-setup", c.authMiddleware(c.injectUserMiddleware(c.isAdminMiddleware(http.HandlerFunc(c.scimSetupHandler))))) mux.Handle("/api/saml-setup", c.authMiddleware(c.injectUserMiddleware(c.isAdminMiddleware(http.HandlerFunc(c.samlSetupHandler))))) mux.Handle("/api/saml-setup/{id}", c.authMiddleware(c.injectUserMiddleware(c.isAdminMiddleware(http.HandlerFunc(c.samlSetupElementHandler))))) diff --git a/pkg/rest/setup.go b/pkg/rest/setup.go index 1be5cde..236c1c9 100644 --- a/pkg/rest/setup.go +++ b/pkg/rest/setup.go @@ -4,9 +4,14 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "net" "net/http" + "net/netip" + "reflect" + "strconv" "strings" + "time" "github.com/google/uuid" "github.com/in4it/wireguard-server/pkg/auth/oidc" @@ -100,21 +105,14 @@ func (c *Context) contextHandler(w http.ResponseWriter, r *http.Request) { } func (c *Context) setupHandler(w http.ResponseWriter, r *http.Request) { - vpnConfig, err := wireguard.GetVPNConfig(c.Storage.Client) - if err != nil { - c.returnError(w, fmt.Errorf("could not get vpn config: %s", err), http.StatusBadRequest) - return - } switch r.Method { case http.MethodGet: - setupRequest := SetupRequest{ + setupRequest := GeneralSetupRequest{ Hostname: c.Hostname, EnableTLS: c.EnableTLS, RedirectToHttps: c.RedirectToHttps, DisableLocalAuth: c.LocalAuthDisabled, EnableOIDCTokenRenewal: c.EnableOIDCTokenRenewal, - Routes: strings.Join(vpnConfig.ClientRoutes, ", "), - VPNEndpoint: vpnConfig.Endpoint, } out, err := json.Marshal(setupRequest) if err != nil { @@ -123,7 +121,7 @@ func (c *Context) setupHandler(w http.ResponseWriter, r *http.Request) { } c.write(w, out) case http.MethodPost: - var setupRequest SetupRequest + var setupRequest GeneralSetupRequest decoder := json.NewDecoder(r.Body) decoder.Decode(&setupRequest) if c.Hostname != setupRequest.Hostname { @@ -145,6 +143,54 @@ func (c *Context) setupHandler(w http.ResponseWriter, r *http.Request) { c.EnableOIDCTokenRenewal = setupRequest.EnableOIDCTokenRenewal c.OIDCRenewal.SetEnabled(c.EnableOIDCTokenRenewal) } + err := SaveConfig(c) + if err != nil { + c.returnError(w, fmt.Errorf("could not save config to disk: %s", err), http.StatusBadRequest) + return + } + out, err := json.Marshal(setupRequest) + if err != nil { + c.returnError(w, fmt.Errorf("could not marshal SetupRequest: %s", err), http.StatusBadRequest) + return + } + c.write(w, out) + default: + c.returnError(w, fmt.Errorf("method not supported"), http.StatusBadRequest) + } +} + +func (c *Context) vpnSetupHandler(w http.ResponseWriter, r *http.Request) { + vpnConfig, err := wireguard.GetVPNConfig(c.Storage.Client) + if err != nil { + c.returnError(w, fmt.Errorf("could not get vpn config: %s", err), http.StatusBadRequest) + return + } + switch r.Method { + case http.MethodGet: + setupRequest := VPNSetupRequest{ + Routes: strings.Join(vpnConfig.ClientRoutes, ", "), + VPNEndpoint: vpnConfig.Endpoint, + AddressRange: vpnConfig.AddressRange.String(), + ClientAddressPrefix: vpnConfig.ClientAddressPrefix, + Port: strconv.Itoa(vpnConfig.Port), + ExternalInterface: vpnConfig.ExternalInterface, + Nameservers: strings.Join(vpnConfig.Nameservers, ","), + DisableNAT: vpnConfig.DisableNAT, + } + out, err := json.Marshal(setupRequest) + if err != nil { + c.returnError(w, fmt.Errorf("could not marshal SetupRequest: %s", err), http.StatusBadRequest) + return + } + c.write(w, out) + case http.MethodPost: + var ( + writeVPNConfig bool + rewriteClientConfigs bool + setupRequest VPNSetupRequest + ) + decoder := json.NewDecoder(r.Body) + decoder.Decode(&setupRequest) if strings.Join(vpnConfig.ClientRoutes, ", ") != setupRequest.Routes { networks := strings.Split(setupRequest.Routes, ",") validatedNetworks := []string{} @@ -152,18 +198,76 @@ func (c *Context) setupHandler(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(network) == "::/0" { validatedNetworks = append(validatedNetworks, "::/0") } else { - _, ipnet, err := net.ParseCIDR(network) - if err == nil { - validatedNetworks = append(validatedNetworks, ipnet.String()) + _, ipnet, err := net.ParseCIDR(strings.TrimSpace(network)) + if err != nil { + c.returnError(w, fmt.Errorf("client route %s in wrong format: %s", strings.TrimSpace(network), err), http.StatusBadRequest) + return } + validatedNetworks = append(validatedNetworks, ipnet.String()) } } vpnConfig.ClientRoutes = validatedNetworks + writeVPNConfig = true + rewriteClientConfigs = true + } + if vpnConfig.Endpoint != setupRequest.VPNEndpoint { + vpnConfig.Endpoint = setupRequest.VPNEndpoint + writeVPNConfig = true + rewriteClientConfigs = true + } + addressRangeParsed, err := netip.ParsePrefix(setupRequest.AddressRange) + if err != nil { + c.returnError(w, fmt.Errorf("AddressRange in wrong format: %s", err), http.StatusBadRequest) + return + } + if addressRangeParsed.String() != vpnConfig.AddressRange.String() { + vpnConfig.AddressRange = addressRangeParsed + writeVPNConfig = true + rewriteClientConfigs = true + } + if setupRequest.ClientAddressPrefix != vpnConfig.ClientAddressPrefix { + vpnConfig.ClientAddressPrefix = setupRequest.ClientAddressPrefix + writeVPNConfig = true + rewriteClientConfigs = true + } + port, err := strconv.Atoi(setupRequest.Port) + if err != nil { + c.returnError(w, fmt.Errorf("port in wrong format: %s", err), http.StatusBadRequest) + return + } + if port != vpnConfig.Port { + vpnConfig.Port = port + writeVPNConfig = true + rewriteClientConfigs = true + } + + nameservers := strings.Split(setupRequest.Nameservers, ",") + for k := range nameservers { + nameservers[k] = strings.TrimSpace(nameservers[k]) + } + if !reflect.DeepEqual(nameservers, vpnConfig.Nameservers) { + vpnConfig.Nameservers = nameservers + writeVPNConfig = true + rewriteClientConfigs = true + } + if setupRequest.ExternalInterface != vpnConfig.ExternalInterface { // don't rewrite client config + vpnConfig.ExternalInterface = setupRequest.ExternalInterface + writeVPNConfig = true + } + if setupRequest.DisableNAT != vpnConfig.DisableNAT { // don't rewrite client config + vpnConfig.DisableNAT = setupRequest.DisableNAT + writeVPNConfig = true + } + + // write vpn config if config has changed + if writeVPNConfig { err = wireguard.WriteVPNConfig(c.Storage.Client, vpnConfig) if err != nil { c.returnError(w, fmt.Errorf("could write vpn config: %s", err), http.StatusBadRequest) return } + } + if rewriteClientConfigs { // rewrite client configs err = wireguard.UpdateClientsConfig(c.Storage.Client) if err != nil { @@ -171,12 +275,58 @@ func (c *Context) setupHandler(w http.ResponseWriter, r *http.Request) { return } } - if vpnConfig.Endpoint != setupRequest.VPNEndpoint { - vpnConfig.Endpoint = setupRequest.VPNEndpoint - err = wireguard.WriteVPNConfig(c.Storage.Client, vpnConfig) + out, err := json.Marshal(setupRequest) + if err != nil { + c.returnError(w, fmt.Errorf("could not marshal SetupRequest: %s", err), http.StatusBadRequest) + return + } + c.write(w, out) + default: + c.returnError(w, fmt.Errorf("method not supported"), http.StatusBadRequest) + } +} + +func (c *Context) templateSetupHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + clientTemplate, err := wireguard.GetClientTemplate(c.Storage.Client) + if err != nil { + c.returnError(w, fmt.Errorf("could not retrieve client template: %s", err), http.StatusBadRequest) + return + } + serverTemplate, err := wireguard.GetServerTemplate(c.Storage.Client) + if err != nil { + c.returnError(w, fmt.Errorf("could not retrieve server template: %s", err), http.StatusBadRequest) + return + } + setupRequest := TemplateSetupRequest{ + ClientTemplate: string(clientTemplate), + ServerTemplate: string(serverTemplate), + } + out, err := json.Marshal(setupRequest) + if err != nil { + c.returnError(w, fmt.Errorf("could not marshal SetupRequest: %s", err), http.StatusBadRequest) + return + } + c.write(w, out) + case http.MethodPost: + var templateSetupRequest TemplateSetupRequest + decoder := json.NewDecoder(r.Body) + decoder.Decode(&templateSetupRequest) + clientTemplate, err := wireguard.GetClientTemplate(c.Storage.Client) + if err != nil { + c.returnError(w, fmt.Errorf("could not retrieve client template: %s", err), http.StatusBadRequest) + return + } + serverTemplate, err := wireguard.GetServerTemplate(c.Storage.Client) + if err != nil { + c.returnError(w, fmt.Errorf("could not retrieve server template: %s", err), http.StatusBadRequest) + return + } + if string(clientTemplate) != templateSetupRequest.ClientTemplate { + err = wireguard.WriteClientTemplate(c.Storage.Client, []byte(templateSetupRequest.ClientTemplate)) if err != nil { - c.SetupCompleted = false - c.returnError(w, fmt.Errorf("unable to write vpn-config: %s", err), http.StatusBadRequest) + c.returnError(w, fmt.Errorf("WriteClientTemplate error: %s", err), http.StatusBadRequest) return } // rewrite client configs @@ -186,12 +336,14 @@ func (c *Context) setupHandler(w http.ResponseWriter, r *http.Request) { return } } - err := SaveConfig(c) - if err != nil { - c.returnError(w, fmt.Errorf("could not save config to disk: %s", err), http.StatusBadRequest) - return + if string(serverTemplate) != templateSetupRequest.ServerTemplate { + err = wireguard.WriteServerTemplate(c.Storage.Client, []byte(templateSetupRequest.ServerTemplate)) + if err != nil { + c.returnError(w, fmt.Errorf("WriteServerTemplate error: %s", err), http.StatusBadRequest) + return + } } - out, err := json.Marshal(setupRequest) + out, err := json.Marshal(templateSetupRequest) if err != nil { c.returnError(w, fmt.Errorf("could not marshal SetupRequest: %s", err), http.StatusBadRequest) return @@ -202,6 +354,44 @@ func (c *Context) setupHandler(w http.ResponseWriter, r *http.Request) { } } +func (c *Context) restartVPNHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + c.returnError(w, fmt.Errorf("unsupported method"), http.StatusBadRequest) + return + } + client := http.Client{ + Timeout: 10 * time.Second, + } + req, err := http.NewRequest(r.Method, "http://"+wireguard.CONFIGMANAGER_URI+"/restart-vpn", nil) + if err != nil { + c.returnError(w, fmt.Errorf("restart request error: %s", err), http.StatusBadRequest) + return + } + resp, err := client.Do(req) + if err != nil { + c.returnError(w, fmt.Errorf("restart error: %s", err), http.StatusBadRequest) + return + } + if resp.StatusCode != http.StatusAccepted { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + c.returnError(w, fmt.Errorf("restart error: got status code: %d. Response: %s", resp.StatusCode, bodyBytes), http.StatusBadRequest) + return + } + c.returnError(w, fmt.Errorf("restart error: got status code: %d. Couldn't get response", resp.StatusCode), http.StatusBadRequest) + return + } + + defer resp.Body.Close() + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + c.returnError(w, fmt.Errorf("body read error: %s", err), http.StatusBadRequest) + return + } + + c.write(w, bodyBytes) +} + func (c *Context) scimSetupHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: diff --git a/pkg/rest/types.go b/pkg/rest/types.go index 67c3578..d4287ac 100644 --- a/pkg/rest/types.go +++ b/pkg/rest/types.go @@ -93,14 +93,28 @@ type UserInfoResponse struct { UserType string `json:"userType"` } -type SetupRequest struct { +type GeneralSetupRequest struct { Hostname string `json:"hostname"` EnableTLS bool `json:"enableTLS"` RedirectToHttps bool `json:"redirectToHttps"` DisableLocalAuth bool `json:"disableLocalAuth"` EnableOIDCTokenRenewal bool `json:"enableOIDCTokenRenewal"` - Routes string `json:"routes"` - VPNEndpoint string `json:"vpnEndpoint"` +} + +type VPNSetupRequest struct { + Routes string `json:"routes"` + VPNEndpoint string `json:"vpnEndpoint"` + AddressRange string `json:"addressRange"` + ClientAddressPrefix string `json:"clientAddressPrefix"` + Port string `json:"port"` + ExternalInterface string `json:"externalInterface"` + Nameservers string `json:"nameservers"` + DisableNAT bool `json:"disableNAT"` +} + +type TemplateSetupRequest struct { + ClientTemplate string `json:"clientTemplate"` + ServerTemplate string `json:"serverTemplate"` } type NewConnectionResponse struct { diff --git a/pkg/wireguard/ip.go b/pkg/wireguard/ip.go index 18f6839..3def8ad 100644 --- a/pkg/wireguard/ip.go +++ b/pkg/wireguard/ip.go @@ -4,13 +4,19 @@ import ( "encoding/json" "fmt" "net" + "net/netip" "path" + "strings" "github.com/in4it/wireguard-server/pkg/storage" ) -func getNextFreeIP(storage storage.Iface, startIP net.IP) (net.IP, error) { +func getNextFreeIP(storage storage.Iface, addressRange netip.Prefix, addressPrefix string) (net.IP, error) { ipList := []string{} + startIP, addressRangeParsed, err := net.ParseCIDR(addressRange.String()) + if err != nil { + return nil, fmt.Errorf("cannot parse address range: %s: %s", addressRange, err) + } clients, err := storage.ReadDir(storage.ConfigPath(VPN_CLIENTS_DIR)) if err != nil { @@ -26,34 +32,48 @@ func getNextFreeIP(storage storage.Iface, startIP net.IP) (net.IP, error) { if err != nil { return nil, fmt.Errorf("cannot unmarshal %s: %s", clientFilename, err) } - peerConfigAddress, _, err := net.ParseCIDR(peerConfig.Address) - if err != nil { - return nil, fmt.Errorf("could not parse peer config address %s: %s", peerConfig.Address, err) - } - ipList = append(ipList, peerConfigAddress.String()) + ipList = append(ipList, peerConfig.Address) } - newIP, err := getNextFreeIPFromList(startIP, ipList) + newIP, err := getNextFreeIPFromList(startIP, addressRangeParsed, ipList, addressPrefix) if err != nil { return nil, fmt.Errorf("getNextFreeIPFromList error: %s", err) } return newIP, nil } -func getNextFreeIPFromList(startIP net.IP, ipList []string) (net.IP, error) { +func getNextFreeIPFromList(startIP net.IP, addressRange *net.IPNet, ipList []string, addressPrefix string) (net.IP, error) { nextIPAddress := startIP for i := 0; i < 100000; i++ { nextIPAddress = nextIP(nextIPAddress, 1) ipExists := false for _, ip := range ipList { - if nextIPAddress.String() == ip { + ipRange := ip + if !strings.Contains(ip, "/") { + ipRange += addressPrefix + } + _, ipRangeParsed, err := net.ParseCIDR(ipRange) + if err != nil { + return nil, fmt.Errorf("cannot parse IP address: %s (ip range %s)", ip, ipRange) + } + if ipRangeParsed.Contains(nextIPAddress) { ipExists = true } } if !ipExists { - return nextIPAddress, nil + if !addressRange.Contains(nextIPAddress) { + return nil, fmt.Errorf("next IP (%s) is not within address range (%s). Address Range might be too small", nextIPAddress.String(), addressRange.String()) + } + _, ipRangeParsed, err := net.ParseCIDR(nextIPAddress.String() + addressPrefix) + if err != nil { + return nil, fmt.Errorf("cannot parse new IP address range: %s: %s", nextIPAddress.String()+addressPrefix, err) + } + if !ipRangeParsed.Contains(startIP) { // don't pick a range where the start ip is in the range + return nextIPAddress, nil + } } } + return nil, fmt.Errorf("couldn't determine next ip address") } diff --git a/pkg/wireguard/ip_test.go b/pkg/wireguard/ip_test.go index 029a35c..414fa8c 100644 --- a/pkg/wireguard/ip_test.go +++ b/pkg/wireguard/ip_test.go @@ -2,15 +2,16 @@ package wireguard import ( "net" + "strings" "testing" ) func TestGetNextFreeIPFromLisWithList(t *testing.T) { - startIP, _, err := net.ParseCIDR("10.189.184.1/21") + startIP, addressRange, err := net.ParseCIDR("10.189.184.1/21") if err != nil { t.Fatalf("error: %s", err) } - nextIP, err := getNextFreeIPFromList(startIP, []string{"10.189.184.2"}) + nextIP, err := getNextFreeIPFromList(startIP, addressRange, []string{"10.189.184.2"}, "/32") if err != nil { t.Fatalf("error: %s", err) } @@ -18,3 +19,80 @@ func TestGetNextFreeIPFromLisWithList(t *testing.T) { t.Fatalf("Wrong IP: %s", nextIP) } } + +func TestGetNextFreeIPFromLisWithList2(t *testing.T) { + startIP, addressRange, err := net.ParseCIDR("10.189.184.1/21") + if err != nil { + t.Fatalf("error: %s", err) + } + nextIP, err := getNextFreeIPFromList(startIP, addressRange, []string{"10.190.190.2", "10.189.184.2", "10.190.190.3"}, "/32") + if err != nil { + t.Fatalf("error: %s", err) + } + if nextIP.String() != "10.189.184.3" { + t.Fatalf("Wrong IP: %s", nextIP) + } +} + +func TestGetNextFreeIPWithRange(t *testing.T) { + startIP, addressRange, err := net.ParseCIDR("10.189.184.1/21") + if err != nil { + t.Fatalf("error: %s", err) + } + networkPrefix := []string{ + "/32", + "/32", + "/32", + "/32", + "/32", + "/32", + "/30", + "/30", + "/32", + } + testCases := [][]string{ + {}, + {"10.189.184.2"}, + {"10.189.184.2/32"}, + {"10.189.184.2", "10.189.184.3", "10.189.184.4/30"}, + {"10.189.184.2", "10.189.184.3", "10.189.184.4/30", "10.189.184.8/32"}, + {"10.189.184.1/30", "10.189.184.4/30", "10.189.184.8/30"}, + {}, + {"10.189.184.4/30", "10.189.184.8/30"}, + {"10.189.189.2/32", "10.189.189.3/32", "10.189.189.4/32"}, + } + expected := []string{ + "10.189.184.2", + "10.189.184.3", + "10.189.184.3", + "10.189.184.8", + "10.189.184.9", + "10.189.184.12", + "10.189.184.4", + "10.189.184.12", + "10.189.184.2", + } + + for k := range testCases { + nextIP, err := getNextFreeIPFromList(startIP, addressRange, testCases[k], networkPrefix[k]) + if err != nil { + t.Fatalf("error: %s", err) + } + if nextIP.String() != expected[k] { + t.Fatalf("Wrong IP: %s", nextIP) + } + } + +} + +func TestIPNotInRange(t *testing.T) { + startIP, addressRange, err := net.ParseCIDR("10.189.184.1/21") + if err != nil { + t.Fatalf("error: %s", err) + } + _, err = getNextFreeIPFromList(startIP, addressRange, []string{"10.189.188.0/22"}, "/22") + if !strings.Contains(err.Error(), "not within address range") { + t.Fatalf("Expected error, got: %s", err) + } + +} diff --git a/pkg/wireguard/startup.go b/pkg/wireguard/startup.go index 40f1770..708b7dc 100644 --- a/pkg/wireguard/startup.go +++ b/pkg/wireguard/startup.go @@ -14,10 +14,27 @@ func StartVPN() error { if err := cmd.Wait(); err != nil { if exiterr, ok := err.(*exec.ExitError); ok { - return fmt.Errorf("exit Status: %d", exiterr.ExitCode()) + return fmt.Errorf("start vpn exit Status: %d", exiterr.ExitCode()) } else { return fmt.Errorf("error while waiting for the VPN to start: %v", err) } } return nil } + +func StopVPN() error { + cmd := exec.Command("wg-quick", "down", "vpn") + + if err := cmd.Start(); err != nil { + return fmt.Errorf("VPN stop error: %v", err) + } + + if err := cmd.Wait(); err != nil { + if exiterr, ok := err.(*exec.ExitError); ok { + return fmt.Errorf("stop vpn exit Status: %d", exiterr.ExitCode()) + } else { + return fmt.Errorf("error while waiting for the VPN to stop: %v", err) + } + } + return nil +} diff --git a/pkg/wireguard/wireguardclientconfig.go b/pkg/wireguard/wireguardclientconfig.go index 4c7ee49..9d03f0d 100644 --- a/pkg/wireguard/wireguardclientconfig.go +++ b/pkg/wireguard/wireguardclientconfig.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "net/http" + "net/netip" "path" "slices" "strconv" @@ -49,12 +50,7 @@ func NewEmptyClientConfig(storage storage.Iface, userID string) (PeerConfig, err } // get next IP address, write in client file - addressRangeSplit := strings.Split(vpnConfig.AddressRange.String(), "/") - firstIP := net.ParseIP(addressRangeSplit[0]) - if firstIP == nil { - return PeerConfig{}, fmt.Errorf("couldn't determine address range from vpn setup") - } - nextFreeIP, err := getNextFreeIP(storage, firstIP) + nextFreeIP, err := getNextFreeIP(storage, vpnConfig.AddressRange, vpnConfig.ClientAddressPrefix) if err != nil { return PeerConfig{}, fmt.Errorf("getNextFreeIP error: %s", err) } @@ -79,7 +75,7 @@ func NewEmptyClientConfig(storage storage.Iface, userID string) (PeerConfig, err DNS: strings.Join(vpnConfig.Nameservers, ", "), Name: fmt.Sprintf("connection-%d", newConfigNumber), Address: nextFreeIP.String() + vpnConfig.ClientAddressPrefix, - ServerAllowedIPs: []string{nextFreeIP.String() + "/32"}, + ServerAllowedIPs: []string{nextFreeIP.String() + vpnConfig.ClientAddressPrefix}, ClientAllowedIPs: clientAllowedIPs, } @@ -130,6 +126,25 @@ func UpdateClientsConfig(storage storage.Iface) error { peerConfig.DNS = strings.Join(vpnConfig.Nameservers, ", ") } + addressParsed, err := netip.ParsePrefix(peerConfig.Address) + if err != nil { + return fmt.Errorf("couldn't parse existing address of vpn config %s", clientFilename) + } + if !vpnConfig.AddressRange.Contains(addressParsed.Addr()) { // client IP address is not in address range (address range might have changed) + nextFreeIP, err := getNextFreeIP(storage, vpnConfig.AddressRange, vpnConfig.ClientAddressPrefix) + if err != nil { + return fmt.Errorf("getNextFreeIP error: %s", err) + } + peerConfig.Address = nextFreeIP.String() + vpnConfig.ClientAddressPrefix + peerConfig.ServerAllowedIPs = []string{nextFreeIP.String() + "/32"} + rewriteFile = true + } + + if !strings.HasSuffix(peerConfig.Address, vpnConfig.ClientAddressPrefix) { + rewriteFile = true + peerConfig.Address = addressParsed.Addr().String() + vpnConfig.ClientAddressPrefix + } + if rewriteFile { peerConfigOut, err := json.Marshal(peerConfig) if err != nil { @@ -159,9 +174,7 @@ func getPeerConfig(storage storage.Iface, connectionID string) (PeerConfig, erro return peerConfig, nil } -func GenerateNewClientConfig(storage storage.Iface, connectionID, userID string) ([]byte, error) { - clientConfigMutex.Lock() - defer clientConfigMutex.Unlock() +func GetClientTemplate(storage storage.Iface) ([]byte, error) { filename := storage.ConfigPath("templates/client.tmpl") err := storage.EnsurePath(storage.ConfigPath("templates")) if err != nil { @@ -173,6 +186,25 @@ func GenerateNewClientConfig(storage storage.Iface, connectionID, userID string) return nil, fmt.Errorf("could not create initial client template: %s", err) } } + data, err := storage.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("could not read client template: %s", err) + } + return data, err +} + +func WriteClientTemplate(storage storage.Iface, body []byte) error { + filename := storage.ConfigPath("templates/client.tmpl") + err := storage.WriteFile(filename, body) + if err != nil { + return fmt.Errorf("could not write client template: %s", err) + } + return nil +} + +func GenerateNewClientConfig(storage storage.Iface, connectionID, userID string) ([]byte, error) { + clientConfigMutex.Lock() + defer clientConfigMutex.Unlock() // parse template privateKey, publicKey, err := GenerateKeys() @@ -208,12 +240,12 @@ func GenerateNewClientConfig(storage storage.Iface, connectionID, userID string) AllowedIPs: peerConfig.ClientAllowedIPs, } - templatefileContents, err := storage.ReadFile(filename) + templatefileContents, err := GetClientTemplate(storage) if err != nil { - return nil, fmt.Errorf("could not read client template: %s", err) + return nil, fmt.Errorf("could not get client template: %s", err) } - tmpl, err := template.New(path.Base(filename)).Funcs(template.FuncMap{"StringsJoin": strings.Join}).Parse(string(templatefileContents)) + tmpl, err := template.New("client.tmpl").Funcs(template.FuncMap{"StringsJoin": strings.Join}).Parse(string(templatefileContents)) if err != nil { return nil, fmt.Errorf("could not parse client template: %s", err) } diff --git a/pkg/wireguard/wireguardclientconfig_test.go b/pkg/wireguard/wireguardclientconfig_test.go index 3e2c159..5c1d346 100644 --- a/pkg/wireguard/wireguardclientconfig_test.go +++ b/pkg/wireguard/wireguardclientconfig_test.go @@ -16,9 +16,12 @@ import ( ) func TestGetNextFreeIPFromList(t *testing.T) { - ip := net.ParseIP("10.0.0.1") + startIP, addressRange, err := net.ParseCIDR("10.0.0.1/21") + if err != nil { + t.Fatalf("error: %s", err) + } ipList := []string{"10.0.0.2", "10.0.0.3"} - nextIP, err := getNextFreeIPFromList(ip, ipList) + nextIP, err := getNextFreeIPFromList(startIP, addressRange, ipList, "/32") if err != nil { t.Errorf("next IP error: %s", err) } @@ -581,3 +584,208 @@ func TestUpdateClientConfig(t *testing.T) { } } + +func TestUpdateClientConfigNewAddressRange(t *testing.T) { + var ( + l net.Listener + err error + ) + for { + l, err = net.Listen("tcp", CONFIGMANAGER_URI) + if err != nil { + if !strings.HasSuffix(err.Error(), "address already in use") { + t.Fatal(err) + } + time.Sleep(1 * time.Second) + } else { + break + } + } + + ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + if r.RequestURI == "/refresh-clients" { + w.WriteHeader(http.StatusAccepted) + w.Write([]byte("OK")) + return + } + w.WriteHeader(http.StatusBadRequest) + default: + w.WriteHeader(http.StatusBadRequest) + } + })) + + ts.Listener.Close() + ts.Listener = l + ts.Start() + defer ts.Close() + defer l.Close() + + storage := &testingmocks.MockMemoryStorage{} + + // first create a new vpn config + vpnconfig, err := CreateNewVPNConfig(storage) + if err != nil { + t.Fatalf("CreateNewVPNConfig error: %s", err) + } + prefix, err := netip.ParsePrefix(DEFAULT_VPN_PREFIX) + if err != nil { + t.Errorf("ParsePrefix error: %s", err) + } + if vpnconfig.AddressRange.String() != prefix.String() { + t.Fatalf("wrong AddressRange: %s vs %s", vpnconfig.AddressRange.String(), prefix.String()) + } + // generate the peerconfig + peerConfig, err := NewEmptyClientConfig(storage, "2-2-2-2") + if err != nil { + t.Fatalf("NewEmptyClientConfig error: %s", err) + } + + if peerConfig.ClientAllowedIPs[0] != "0.0.0.0/0" { + t.Fatalf("wrong client allowed ips") + } + + newClientRoutes := []string{"1.2.3.4/32"} + vpnconfig.ClientRoutes = newClientRoutes + vpnconfig.AddressRange, err = netip.ParsePrefix("10.190.190.1/21") + vpnconfig.Nameservers = []string{"3.4.5.6", "8.8.8.8"} + if err != nil { + t.Fatalf("can't parse new ip range") + } + err = WriteVPNConfig(storage, vpnconfig) + if err != nil { + t.Fatalf("WriteVPNConfig error: %s", err) + } + err = UpdateClientsConfig(storage) + if err != nil { + t.Fatalf("UpdateClientsConfig error: %s", err) + } + + peerConfigCurrent, err := getPeerConfig(storage, "2-2-2-2-1") + if err != nil { + t.Fatalf("getPeerConfig error: %s", err) + } + + if peerConfigCurrent.ServerAllowedIPs[0] != "10.190.190.2/32" { + t.Fatalf("expected different server allowed IP. Got: %s", strings.Join(peerConfigCurrent.ServerAllowedIPs, ", ")) + } + if peerConfigCurrent.Address != "10.190.190.2/32" { + t.Fatalf("expected different client config address. Got: %s", peerConfigCurrent.Address) + } + if peerConfigCurrent.DNS != strings.Join(vpnconfig.Nameservers, ", ") { + t.Fatalf("Unexpected DNS Servers: %s (expected %s)", peerConfig.DNS, strings.Join(vpnconfig.Nameservers, ", ")) + } + + peerConfig, err = NewEmptyClientConfig(storage, "2-2-2-2") + if err != nil { + t.Fatalf("NewEmptyClientConfig error: %s", err) + } + + if peerConfig.ClientAllowedIPs[0] != "1.2.3.4/32" { + t.Fatalf("wrong client allowed ips") + } + + if peerConfig.ServerAllowedIPs[0] != "10.190.190.3/32" { + t.Fatalf("expected different server allowed IP. Got: %s", strings.Join(peerConfig.ServerAllowedIPs, ", ")) + } + if peerConfig.Address != "10.190.190.3/32" { + t.Fatalf("expected different client config address. Got: %s", peerConfig.Address) + } + if peerConfig.DNS != strings.Join(vpnconfig.Nameservers, ", ") { + t.Fatalf("Unexpected DNS Servers: %s (expected %s)", peerConfig.DNS, strings.Join(vpnconfig.Nameservers, ", ")) + } +} + +func TestUpdateClientConfigNewClientAddressPrefix(t *testing.T) { + var ( + l net.Listener + err error + ) + for { + l, err = net.Listen("tcp", CONFIGMANAGER_URI) + if err != nil { + if !strings.HasSuffix(err.Error(), "address already in use") { + t.Fatal(err) + } + time.Sleep(1 * time.Second) + } else { + break + } + } + + ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + if r.RequestURI == "/refresh-clients" { + w.WriteHeader(http.StatusAccepted) + w.Write([]byte("OK")) + return + } + w.WriteHeader(http.StatusBadRequest) + default: + w.WriteHeader(http.StatusBadRequest) + } + })) + + ts.Listener.Close() + ts.Listener = l + ts.Start() + defer ts.Close() + defer l.Close() + + storage := &testingmocks.MockMemoryStorage{} + + // first create a new vpn config + vpnconfig, err := CreateNewVPNConfig(storage) + if err != nil { + t.Fatalf("CreateNewVPNConfig error: %s", err) + } + prefix, err := netip.ParsePrefix(DEFAULT_VPN_PREFIX) + if err != nil { + t.Errorf("ParsePrefix error: %s", err) + } + if vpnconfig.AddressRange.String() != prefix.String() { + t.Fatalf("wrong AddressRange: %s vs %s", vpnconfig.AddressRange.String(), prefix.String()) + } + if vpnconfig.ClientAddressPrefix != "/32" { + t.Fatalf("unexpected default for address prefix: %s", vpnconfig.ClientAddressPrefix) + } + // generate the peerconfig + peerConfig, err := NewEmptyClientConfig(storage, "2-2-2-2") + if err != nil { + t.Fatalf("NewEmptyClientConfig error: %s", err) + } + + if peerConfig.ClientAllowedIPs[0] != "0.0.0.0/0" { + t.Fatalf("wrong client allowed ips") + } + + vpnconfig.ClientAddressPrefix = "/30" + + err = WriteVPNConfig(storage, vpnconfig) + if err != nil { + t.Fatalf("WriteVPNConfig error: %s", err) + } + err = UpdateClientsConfig(storage) + if err != nil { + t.Fatalf("UpdateClientsConfig error: %s", err) + } + + peerConfigCurrent, err := getPeerConfig(storage, "2-2-2-2-1") + if err != nil { + t.Fatalf("getPeerConfig error: %s", err) + } + + if peerConfigCurrent.Address != "10.189.184.2/30" { + t.Fatalf("expected different client address. Got: %s", peerConfigCurrent.Address) + } + peerConfig, err = NewEmptyClientConfig(storage, "2-2-2-2") + if err != nil { + t.Fatalf("NewEmptyClientConfig error: %s", err) + } + if peerConfig.Address != "10.189.184.4/30" { + t.Fatalf("expected different client config address. Got: %s", peerConfig.Address) + } + +} diff --git a/pkg/wireguard/wireguardserverconfig.go b/pkg/wireguard/wireguardserverconfig.go index d06df6f..be0155a 100644 --- a/pkg/wireguard/wireguardserverconfig.go +++ b/pkg/wireguard/wireguardserverconfig.go @@ -23,7 +23,7 @@ func WriteWireGuardServerConfig(storage storage.Iface) error { return nil } -func generateWireGuardServerConfig(storage storage.Iface) ([]byte, error) { +func GetServerTemplate(storage storage.Iface) ([]byte, error) { templatefile := storage.ConfigPath(path.Join(WIREGUARD_TEMPLATE_DIR, WIREGUARD_TEMPLATE_SERVER)) err := storage.EnsurePath(storage.ConfigPath(WIREGUARD_TEMPLATE_DIR)) if err != nil { @@ -45,6 +45,25 @@ func generateWireGuardServerConfig(storage storage.Iface) ([]byte, error) { } } + templateContents, err := storage.ReadFile(templatefile) + if err != nil { + return nil, fmt.Errorf("cannot read template file (%s): %s", templatefile, err) + } + return templateContents, nil +} + +func WriteServerTemplate(storage storage.Iface, body []byte) error { + templatefile := storage.ConfigPath(path.Join(WIREGUARD_TEMPLATE_DIR, WIREGUARD_TEMPLATE_SERVER)) + + err := storage.WriteFile(templatefile, body) + if err != nil { + return fmt.Errorf("could not write template (%s): %s", templatefile, err) + } + + return nil +} + +func generateWireGuardServerConfig(storage storage.Iface) ([]byte, error) { vpnConfig, err := GetVPNConfig(storage) if err != nil { return nil, fmt.Errorf("failed to get vpn config: %s", err) @@ -61,11 +80,11 @@ func generateWireGuardServerConfig(storage storage.Iface) ([]byte, error) { ExternalInterface: vpnConfig.ExternalInterface, } - templateContents, err := storage.ReadFile(templatefile) + templateContents, err := GetServerTemplate(storage) if err != nil { - return nil, fmt.Errorf("cannot read template file (%s): %s", templatefile, err) + return nil, fmt.Errorf("cannot get template file: %s", err) } - tmpl, err := template.New(path.Base(templatefile)).Parse(string(templateContents)) + tmpl, err := template.New(WIREGUARD_TEMPLATE_SERVER).Parse(string(templateContents)) if err != nil { return nil, fmt.Errorf("could not parse client template: %s", err) } diff --git a/webapp/src/Routes/Setup/GeneralSetup.tsx b/webapp/src/Routes/Setup/GeneralSetup.tsx new file mode 100644 index 0000000..95e84e0 --- /dev/null +++ b/webapp/src/Routes/Setup/GeneralSetup.tsx @@ -0,0 +1,208 @@ +import { Text, Checkbox, Container, UnstyledButton, Tooltip, Center, rem, TextInput, Space, Button, Alert } from "@mantine/core"; +import classes from './Setup.module.css'; +import { useEffect, useState } from "react"; +import { IconInfoCircle } from "@tabler/icons-react"; +import { AppSettings } from "../../Constants/Constants"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAuthContext } from "../../Auth/Auth"; +import { useForm } from '@mantine/form'; +import axios, { AxiosError } from "axios"; + +type GeneralSetupError = { + error: string; +} + +type GeneralSetupRequest = { + hostname: string; + enableTLS: boolean; + redirectToHttps: boolean; + disableLocalAuth: boolean; + enableOIDCTokenRenewal: boolean; +}; +export function GeneralSetup() { + const [saved, setSaved] = useState(false) + const [saveError, setSaveError] = useState("") + const {authInfo} = useAuthContext(); + const queryClient = useQueryClient() + const { isPending, error, data, isSuccess } = useQuery({ + queryKey: ['general-setup'], + queryFn: () => + fetch(AppSettings.url + '/setup/general', { + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + authInfo.token + }, + }).then((res) => { + return res.json() + } + + ), + }) + const form = useForm({ + mode: 'uncontrolled', + initialValues: { + hostname: "", + enableTLS: false, + redirectToHttps: false, + disableLocalAuth: false, + enableOIDCTokenRenewal: false, + }, + }); + const alertIcon = ; + const setupMutation = useMutation({ + mutationFn: (setupRequest: GeneralSetupRequest) => { + return axios.post(AppSettings.url + '/setup/general', setupRequest, { + headers: { + "Authorization": "Bearer " + authInfo.token + }, + }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }) + queryClient.invalidateQueries({ queryKey: ['general-setup'] }) + setSaved(true) + setSaveError("") + window.scrollTo(0, 0) + }, + onError: (error:AxiosError) => { + const errorMessage = error.response?.data as GeneralSetupError + if(errorMessage?.error === undefined) { + setSaveError("Error: "+ error.message) + } else { + setSaveError("Error: "+ errorMessage.error) + } + } + }) + + + useEffect(() => { + if (isSuccess) { + form.setValues({ ...data }); + } + }, [isSuccess]); + + + const hostnameTooltip = ( + + +
+ +
+
+
+ ); + + if(isPending) return "Loading..." + if(error) return 'A backend error has occurred: ' + error.message + + return ( + + {saved && saveError === "" ? Settings Saved! : null} + {saveError !== "" ? {saveError} : null} + +
setupMutation.mutate(values))}> + + + form.setFieldValue("enableTLS", !form.getValues().enableTLS )}> + +
+ + Enable TLS (https) + + + Enable TLS (https) using Let's Encrypt (recommended) + +
+
+ + window.location.protocol === "https:" ? form.setFieldValue("redirectToHttps", !form.getValues().redirectToHttps) : null }> + +
+ + Redirect http to https + + + Redirect http requests to https. + Not needed when terminating TLS on an external LoadBalancer. + Can only be enabled once this page is requested through https. + +
+
+ + form.setFieldValue("disableLocalAuth", !form.getValues().disableLocalAuth )}> + +
+ + Disable local auth + + + Once an OIDC Connection is setup, you can disable local authentication. Make sure to have assigned a new admin role. + +
+
+ + form.setFieldValue("enableOIDCTokenRenewal", !form.getValues().enableOIDCTokenRenewal )}> + +
+ + Deactivate a user's VPN connection on OIDC token renewal failure + + + OIDC Tokens can be refreshed when expired. + The OIDC tokens will be renewed, and on renewal failure, the VPN connection of that user will be disabled until the user logs in again. + + Note: Only use this when SCIM provisioning is not possible in your setup. +
+
+ + +
+ + ) +} \ No newline at end of file diff --git a/webapp/src/Routes/Setup/Restart.tsx b/webapp/src/Routes/Setup/Restart.tsx new file mode 100644 index 0000000..8337bf3 --- /dev/null +++ b/webapp/src/Routes/Setup/Restart.tsx @@ -0,0 +1,55 @@ +import { Container, Button, Alert, Space } from "@mantine/core"; +import { useState } from "react"; +import { IconInfoCircle } from "@tabler/icons-react"; +import { AppSettings } from "../../Constants/Constants"; +import { useMutation } from "@tanstack/react-query"; +import { useAuthContext } from "../../Auth/Auth"; +import axios, { AxiosError } from "axios"; + +type RestartError = { + error: string; +} + +export function Restart() { + const [saved, setSaved] = useState(false) + const [pending, setPending] = useState(false) + const [saveError, setSaveError] = useState("") + const {authInfo} = useAuthContext(); + const alertIcon = ; + const setupMutation = useMutation({ + mutationFn: () => { + return axios.post(AppSettings.url + '/setup/restart-vpn', {}, { + headers: { + "Authorization": "Bearer " + authInfo.token + }, + }) + }, + onSuccess: () => { + setSaved(true) + setSaveError("") + setTimeout(function() { setPending(false); }, 1000); + }, + onError: (error:AxiosError) => { + setTimeout(function() { setPending(false); }, 1000); + const errorMessage = error.response?.data as RestartError + if(errorMessage?.error === undefined) { + setSaveError("Error: "+ error.message) + } else { + setSaveError("Error: "+ errorMessage.error) + } + } + }) + + return ( + + This button will reload the WireGuard® Configuration. VPN Clients will be disconnected during the reload. If the configuration has changed, clients might have to download new configuration files (for example if the port or address range has changed). The VPN Server admin UI will not be restarted. + + {saved && saveError === "" ? VPN Restarted! : null} + {saveError !== "" ? {saveError} : null} + + + + ) +} \ No newline at end of file diff --git a/webapp/src/Routes/Setup/Setup.tsx b/webapp/src/Routes/Setup/Setup.tsx index 2057366..54c236a 100644 --- a/webapp/src/Routes/Setup/Setup.tsx +++ b/webapp/src/Routes/Setup/Setup.tsx @@ -1,229 +1,48 @@ -import { Text, Checkbox, Container, Title, UnstyledButton, Tooltip, Center, rem, TextInput, Space, Button, Alert, Divider, InputWrapper } from "@mantine/core"; +import { Container, Tabs, Title, rem } from "@mantine/core"; import classes from './Setup.module.css'; -import { useEffect, useState } from "react"; -import { IconInfoCircle } from "@tabler/icons-react"; -import { AppSettings } from "../../Constants/Constants"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useAuthContext } from "../../Auth/Auth"; -import { useForm } from '@mantine/form'; -import axios, { AxiosError } from "axios"; +import { IconFile, IconNetwork, IconRestore, IconSettings } from "@tabler/icons-react"; +import { GeneralSetup } from "./GeneralSetup"; +import { VPNSetup } from "./VPNSetup"; +import { TemplateSetup } from "./TemplateSetup"; +import { Restart } from "./Restart"; -type SetupRequest = { - hostname: string; - enableTLS: boolean; - redirectToHttps: boolean; - disableLocalAuth: boolean; - enableOIDCTokenRenewal: boolean; - routes: string; - vpnEndpoint: string; -}; export function Setup() { - const [saved, setSaved] = useState(false) - const [saveError, setSaveError] = useState("") - const {authInfo} = useAuthContext(); - const queryClient = useQueryClient() - const { isPending, error, data, isSuccess } = useQuery({ - queryKey: ['setup'], - queryFn: () => - fetch(AppSettings.url + '/setup', { - headers: { - "Content-Type": "application/json", - "Authorization": "Bearer " + authInfo.token - }, - }).then((res) => { - return res.json() - } - - ), - }) - const form = useForm({ - mode: 'uncontrolled', - initialValues: { - hostname: "", - enableTLS: false, - redirectToHttps: false, - disableLocalAuth: false, - enableOIDCTokenRenewal: false, - routes: "", - vpnEndpoint: "", - }, - }); - const alertIcon = ; - const setupMutation = useMutation({ - mutationFn: (setupRequest: SetupRequest) => { - return axios.post(AppSettings.url + '/setup', setupRequest, { - headers: { - "Authorization": "Bearer " + authInfo.token - }, - }) - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['users'] }) - setSaved(true) - }, - onError: (error:AxiosError) => { - setSaveError("Error: "+ error.message) - } - }) + const iconStyle = { width: rem(12), height: rem(12) }; + return ( + + + VPN Setup + + + + }> + General + + }> + VPN + + }> + Templates + + }> + Restart + + + + + + + + + + + + + + + + - useEffect(() => { - if (isSuccess) { - form.setValues({ ...data }); - } - }, [isSuccess]); - - - const hostnameTooltip = ( - - -
- -
-
-
- ); - - if(isPending) return "Loading..." - if(error) return 'A backend error has occurred: ' + error.message - - return ( - - - VPN Server Setup - - - {saved ? Settings Saved! : null} - {saveError !== "" ? saveError : null} -
setupMutation.mutate(values))}> - - - form.setFieldValue("enableTLS", !form.getValues().enableTLS )}> - -
- - Enable TLS (https) - - - Enable TLS (https) using Let's Encrypt (recommended) - -
-
- - window.location.protocol === "https:" ? form.setFieldValue("redirectToHttps", !form.getValues().redirectToHttps) : null }> - -
- - Redirect http to https - - - Redirect http requests to https. - Not needed when terminating TLS on an external LoadBalancer. - Can only be enabled once this page is requested through https. - -
-
- - form.setFieldValue("disableLocalAuth", !form.getValues().disableLocalAuth )}> - -
- - Disable local auth - - - Once an OIDC Connection is setup, you can disable local authentication. Make sure to have assigned a new admin role. - -
-
- - form.setFieldValue("enableOIDCTokenRenewal", !form.getValues().enableOIDCTokenRenewal )}> - -
- - Deactivate a user's VPN connection on OIDC token renewal failure - - - OIDC Tokens can be refreshed when expired. - The OIDC tokens will be renewed, and on renewal failure, the VPN connection of that user will be disabled until the user logs in again. - - Note: Only use this when SCIM provisioning is not possible in your setup. -
-
- - - - - - - - - -
- - ) + ) } \ No newline at end of file diff --git a/webapp/src/Routes/Setup/TemplateSetup.tsx b/webapp/src/Routes/Setup/TemplateSetup.tsx new file mode 100644 index 0000000..e96035a --- /dev/null +++ b/webapp/src/Routes/Setup/TemplateSetup.tsx @@ -0,0 +1,114 @@ +import { Container, Button, Alert, Textarea, Space } from "@mantine/core"; +import { useEffect, useState } from "react"; +import { IconInfoCircle } from "@tabler/icons-react"; +import { AppSettings } from "../../Constants/Constants"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAuthContext } from "../../Auth/Auth"; +import { useForm } from '@mantine/form'; +import axios, { AxiosError } from "axios"; + +type TemplateSetupError = { + error: string; +} + +type TemplateSetupRequest = { + clientTemplate: string; + serverTemplate: string; +}; +export function TemplateSetup() { + const [saved, setSaved] = useState(false) + const [saveError, setSaveError] = useState("") + const {authInfo} = useAuthContext(); + const queryClient = useQueryClient() + const { isPending, error, data, isSuccess } = useQuery({ + queryKey: ['templates-setup'], + queryFn: () => + fetch(AppSettings.url + '/setup/templates', { + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + authInfo.token + }, + }).then((res) => { + return res.json() + } + + ), + }) + const form = useForm({ + mode: 'uncontrolled', + initialValues: { + clientTemplate: "", + serverTemplate: "", + }, + }); + const alertIcon = ; + const setupMutation = useMutation({ + mutationFn: (setupRequest: TemplateSetupRequest) => { + return axios.post(AppSettings.url + '/setup/templates', setupRequest, { + headers: { + "Authorization": "Bearer " + authInfo.token + }, + }) + }, + onSuccess: () => { + setSaved(true) + setSaveError("") + queryClient.invalidateQueries({ queryKey: ['templates-setup'] }) + window.scrollTo(0, 0) + }, + onError: (error:AxiosError) => { + const errorMessage = error.response?.data as TemplateSetupError + if(errorMessage?.error === undefined) { + setSaveError("Error: "+ error.message) + } else { + setSaveError("Error: "+ errorMessage.error) + } + } + }) + + + useEffect(() => { + if (isSuccess) { + form.setValues({ ...data }); + } + }, [isSuccess]); + + + if(isPending) return "Loading..." + if(error) return 'A backend error has occurred: ' + error.message + + return ( + + The template files use the Golang template package (see also https://pkg.go.dev/text/template). + + {saved && saveError === "" ? Settings Saved! : null} + {saveError !== "" ? {saveError} : null} + +
setupMutation.mutate(values))}> +