From 4eecb39747affa93a5046c9da5817d8310230e84 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Tue, 20 Aug 2024 11:16:58 -0500 Subject: [PATCH 1/8] split and add vpn configuration in UI --- pkg/rest/router.go | 3 +- pkg/rest/setup.go | 55 ++++- pkg/rest/types.go | 13 +- webapp/src/Routes/Setup/GeneralSetup.tsx | 195 +++++++++++++++++ webapp/src/Routes/Setup/Setup.tsx | 257 +++-------------------- webapp/src/Routes/Setup/VPNSetup.tsx | 214 +++++++++++++++++++ 6 files changed, 503 insertions(+), 234 deletions(-) create mode 100644 webapp/src/Routes/Setup/GeneralSetup.tsx create mode 100644 webapp/src/Routes/Setup/VPNSetup.tsx diff --git a/pkg/rest/router.go b/pkg/rest/router.go index f10e858..07bbc5c 100644 --- a/pkg/rest/router.go +++ b/pkg/rest/router.go @@ -53,7 +53,8 @@ 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/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..06fc9eb 100644 --- a/pkg/rest/setup.go +++ b/pkg/rest/setup.go @@ -100,21 +100,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 +116,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 +138,50 @@ 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: 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 setupRequest VPNSetupRequest + decoder := json.NewDecoder(r.Body) + decoder.Decode(&setupRequest) if strings.Join(vpnConfig.ClientRoutes, ", ") != setupRequest.Routes { networks := strings.Split(setupRequest.Routes, ",") validatedNetworks := []string{} diff --git a/pkg/rest/types.go b/pkg/rest/types.go index 67c3578..1131f79 100644 --- a/pkg/rest/types.go +++ b/pkg/rest/types.go @@ -93,7 +93,7 @@ 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"` @@ -103,6 +103,17 @@ type SetupRequest struct { VPNEndpoint string `json:"vpnEndpoint"` } +type VPNSetupRequest struct { + Routes string `json:"routes"` + VPNEndpoint string `json:"vpnEndpoint"` + AddressRange string `json:"addressRange"` + ClientAddressPrefix string `json:"clientAddressPrefix"` + Port int `json:"port"` + ExternalInterface string `json:"externalInterface"` + Nameservers string `json:"nameservers"` + DisableNAT bool `json:"disableNAT"` +} + type NewConnectionResponse struct { Name string `json:"name"` } diff --git a/webapp/src/Routes/Setup/GeneralSetup.tsx b/webapp/src/Routes/Setup/GeneralSetup.tsx new file mode 100644 index 0000000..a19963e --- /dev/null +++ b/webapp/src/Routes/Setup/GeneralSetup.tsx @@ -0,0 +1,195 @@ +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 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', setupRequest, { + headers: { + "Authorization": "Bearer " + authInfo.token + }, + }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }) + setSaved(true) + }, + onError: (error:AxiosError) => { + setSaveError("Error: "+ error.message) + } + }) + + + 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 ? 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/Setup.tsx b/webapp/src/Routes/Setup/Setup.tsx index 2057366..5ff1f70 100644 --- a/webapp/src/Routes/Setup/Setup.tsx +++ b/webapp/src/Routes/Setup/Setup.tsx @@ -1,229 +1,40 @@ -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, IconSettings } from "@tabler/icons-react"; +import { GeneralSetup } from "./GeneralSetup"; +import { VPNSetup } from "./VPNSetup"; -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 + + + + + + + + + + Templates will go here + + + - 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/VPNSetup.tsx b/webapp/src/Routes/Setup/VPNSetup.tsx new file mode 100644 index 0000000..da25f67 --- /dev/null +++ b/webapp/src/Routes/Setup/VPNSetup.tsx @@ -0,0 +1,214 @@ + +import { Container, TextInput, Alert, InputWrapper, Button, Space, UnstyledButton, Checkbox, Text } from "@mantine/core"; +import { useEffect, useState } from "react"; +import classes from './Setup.module.css'; +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 VPNSetupRequest = { + routes: string; + vpnEndpoint: string; + addressRange: string, + clientAddressPrefix: string, + port: number, + externalInterface: string, + nameservers: string, + disableNAT: boolean, +}; +export function VPNSetup() { + const [saved, setSaved] = useState(false) + const [saveError, setSaveError] = useState("") + const {authInfo} = useAuthContext(); + const queryClient = useQueryClient() + const { isPending, error, data, isSuccess } = useQuery({ + queryKey: ['vpn-setup'], + queryFn: () => + fetch(AppSettings.url + '/setup/vpn', { + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + authInfo.token + }, + }).then((res) => { + return res.json() + } + + ), + }) + const form = useForm({ + mode: 'uncontrolled', + initialValues: { + routes: "", + vpnEndpoint: "", + addressRange: "", + clientAddressPrefix: "", + port: 0, + externalInterface: "", + nameservers: "", + disableNAT: false, + }, + }); + const setupMutation = useMutation({ + mutationFn: (setupRequest: VPNSetupRequest) => { + 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 alertIcon = ; + + useEffect(() => { + if (isSuccess) { + form.setValues({ ...data }); + } + }, [isSuccess]); + + + if(isPending) return "Loading..." + if(error) return 'A backend error has occurred: ' + error.message + + return ( + + {saved ? Settings Saved! : null} + {saveError !== "" ? saveError : null} + +
setupMutation.mutate(values))}> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + form.setFieldValue("disableNAT", !form.getValues().disableNAT )}> + +
+ + Disable NAT + + + Packets will be routed to anywhere on the network, using Network Address Translation (NAT). If the VPN clients only need to access the VPN server and not other devices in the network, you can disable NAT. + +
+
+ + + + +
+ ) +} \ No newline at end of file From 3d3b706137d6b76a58de4be78ac3d2c668690bef Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Tue, 20 Aug 2024 13:58:55 -0500 Subject: [PATCH 2/8] vpn configuration --- pkg/rest/setup.go | 82 ++++++++++++++++++++++------ webapp/src/Routes/Setup/VPNSetup.tsx | 4 +- 2 files changed, 69 insertions(+), 17 deletions(-) diff --git a/pkg/rest/setup.go b/pkg/rest/setup.go index 06fc9eb..04b1715 100644 --- a/pkg/rest/setup.go +++ b/pkg/rest/setup.go @@ -6,6 +6,8 @@ import ( "fmt" "net" "net/http" + "net/netip" + "reflect" "strings" "github.com/google/uuid" @@ -179,7 +181,12 @@ func (c *Context) vpnSetupHandler(w http.ResponseWriter, r *http.Request) { } c.write(w, out) case http.MethodPost: - var setupRequest VPNSetupRequest + var ( + writeVPNConfig bool + rewriteClientConfigs bool + rewriteServerConfig bool + setupRequest VPNSetupRequest + ) decoder := json.NewDecoder(r.Body) decoder.Decode(&setupRequest) if strings.Join(vpnConfig.ClientRoutes, ", ") != setupRequest.Routes { @@ -196,11 +203,66 @@ func (c *Context) vpnSetupHandler(w http.ResponseWriter, r *http.Request) { } } 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 + rewriteServerConfig = true + } + if setupRequest.ClientAddressPrefix != vpnConfig.ClientAddressPrefix { + vpnConfig.ClientAddressPrefix = setupRequest.ClientAddressPrefix + writeVPNConfig = true + rewriteClientConfigs = true + } + if setupRequest.Port != vpnConfig.Port { + vpnConfig.Port = setupRequest.Port + writeVPNConfig = true + rewriteClientConfigs = true + rewriteServerConfig = true + } + + nameservers := strings.Split(setupRequest.Nameservers, ",") + for k := range nameservers { + nameservers[k] = strings.TrimSpace(nameservers[k]) + } + if !reflect.DeepEqual(nameservers, vpnConfig.Nameservers) { + vpnConfig.ExternalInterface = setupRequest.ExternalInterface + writeVPNConfig = true + rewriteClientConfigs = true + } + if setupRequest.ExternalInterface != vpnConfig.ExternalInterface { // don't rewrite client config + vpnConfig.ExternalInterface = setupRequest.ExternalInterface + writeVPNConfig = true + rewriteServerConfig = true + } + if setupRequest.DisableNAT != vpnConfig.DisableNAT { // don't rewrite client config + vpnConfig.DisableNAT = setupRequest.DisableNAT + writeVPNConfig = true + rewriteServerConfig = 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 { @@ -208,26 +270,14 @@ func (c *Context) vpnSetupHandler(w http.ResponseWriter, r *http.Request) { return } } - if vpnConfig.Endpoint != setupRequest.VPNEndpoint { - vpnConfig.Endpoint = setupRequest.VPNEndpoint - err = wireguard.WriteVPNConfig(c.Storage.Client, vpnConfig) - if err != nil { - c.SetupCompleted = false - c.returnError(w, fmt.Errorf("unable to write vpn-config: %s", err), http.StatusBadRequest) - return - } - // rewrite client configs - err = wireguard.UpdateClientsConfig(c.Storage.Client) + if rewriteServerConfig { + // rewrite server config + err = wireguard.WriteWireGuardServerConfig(c.Storage.Client) if err != nil { c.returnError(w, fmt.Errorf("could not update client vpn configs: %s", err), http.StatusBadRequest) return } } - 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) diff --git a/webapp/src/Routes/Setup/VPNSetup.tsx b/webapp/src/Routes/Setup/VPNSetup.tsx index da25f67..12a0177 100644 --- a/webapp/src/Routes/Setup/VPNSetup.tsx +++ b/webapp/src/Routes/Setup/VPNSetup.tsx @@ -82,6 +82,7 @@ export function VPNSetup() { return ( + Changes to Address Range, Port, External Interface, or NAT will need a wireguard reload. You can click the "Reload Wireguard" button at the bottom after submitting the changes. This will disconnect active VPN clients. {saved ? Settings Saved! : null} {saveError !== "" ? saveError : null} @@ -89,7 +90,8 @@ export function VPNSetup() { Date: Tue, 20 Aug 2024 16:35:53 -0500 Subject: [PATCH 3/8] vpn config, template config --- pkg/configmanager/handlers.go | 20 ++++ pkg/configmanager/router.go | 1 + pkg/configmanager/start_darwin.go | 5 + pkg/configmanager/start_linux.go | 4 + pkg/rest/router.go | 1 + pkg/rest/setup.go | 98 +++++++++++++++++-- pkg/rest/types.go | 9 +- pkg/wireguard/startup.go | 19 +++- pkg/wireguard/wireguardclientconfig.go | 29 ++++-- pkg/wireguard/wireguardserverconfig.go | 27 +++++- webapp/src/Routes/Setup/GeneralSetup.tsx | 19 +++- webapp/src/Routes/Setup/Setup.tsx | 3 +- webapp/src/Routes/Setup/TemplateSetup.tsx | 111 ++++++++++++++++++++++ webapp/src/Routes/Setup/VPNSetup.tsx | 27 ++++-- 14 files changed, 337 insertions(+), 36 deletions(-) create mode 100644 webapp/src/Routes/Setup/TemplateSetup.tsx diff --git a/pkg/configmanager/handlers.go b/pkg/configmanager/handlers.go index fd36f51..351a024 100644 --- a/pkg/configmanager/handlers.go +++ b/pkg/configmanager/handlers.go @@ -117,6 +117,26 @@ 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 + } + 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/rest/router.go b/pkg/rest/router.go index 07bbc5c..2197538 100644 --- a/pkg/rest/router.go +++ b/pkg/rest/router.go @@ -55,6 +55,7 @@ func (c *Context) getRouter(assets fs.FS, indexHtml []byte) *http.ServeMux { mux.Handle("/api/oidc/{id}", c.authMiddleware(c.injectUserMiddleware(c.isAdminMiddleware(http.HandlerFunc(c.oidcProviderElementHandler))))) 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/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 04b1715..3367e13 100644 --- a/pkg/rest/setup.go +++ b/pkg/rest/setup.go @@ -8,6 +8,7 @@ import ( "net/http" "net/netip" "reflect" + "strconv" "strings" "github.com/google/uuid" @@ -169,7 +170,7 @@ func (c *Context) vpnSetupHandler(w http.ResponseWriter, r *http.Request) { VPNEndpoint: vpnConfig.Endpoint, AddressRange: vpnConfig.AddressRange.String(), ClientAddressPrefix: vpnConfig.ClientAddressPrefix, - Port: vpnConfig.Port, + Port: strconv.Itoa(vpnConfig.Port), ExternalInterface: vpnConfig.ExternalInterface, Nameservers: strings.Join(vpnConfig.Nameservers, ","), DisableNAT: vpnConfig.DisableNAT, @@ -196,10 +197,12 @@ func (c *Context) vpnSetupHandler(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 @@ -227,8 +230,13 @@ func (c *Context) vpnSetupHandler(w http.ResponseWriter, r *http.Request) { writeVPNConfig = true rewriteClientConfigs = true } - if setupRequest.Port != vpnConfig.Port { - vpnConfig.Port = setupRequest.Port + 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 rewriteServerConfig = true @@ -239,7 +247,7 @@ func (c *Context) vpnSetupHandler(w http.ResponseWriter, r *http.Request) { nameservers[k] = strings.TrimSpace(nameservers[k]) } if !reflect.DeepEqual(nameservers, vpnConfig.Nameservers) { - vpnConfig.ExternalInterface = setupRequest.ExternalInterface + vpnConfig.Nameservers = nameservers writeVPNConfig = true rewriteClientConfigs = true } @@ -274,7 +282,7 @@ func (c *Context) vpnSetupHandler(w http.ResponseWriter, r *http.Request) { // rewrite server config err = wireguard.WriteWireGuardServerConfig(c.Storage.Client) if err != nil { - c.returnError(w, fmt.Errorf("could not update client vpn configs: %s", err), http.StatusBadRequest) + c.returnError(w, fmt.Errorf("could not write wireguard server config: %s", err), http.StatusBadRequest) return } } @@ -289,6 +297,80 @@ func (c *Context) vpnSetupHandler(w http.ResponseWriter, r *http.Request) { } } +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.returnError(w, fmt.Errorf("WriteClientTemplate error: %s", err), http.StatusBadRequest) + return + } + // rewrite client configs + err = wireguard.UpdateClientsConfig(c.Storage.Client) + if err != nil { + c.returnError(w, fmt.Errorf("could not update client vpn configs: %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 + } + // rewrite server config + err = wireguard.WriteWireGuardServerConfig(c.Storage.Client) + if err != nil { + c.returnError(w, fmt.Errorf("could not write wireguard server config: %s", err), http.StatusBadRequest) + return + } + } + out, err := json.Marshal(templateSetupRequest) + 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) 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 1131f79..d4287ac 100644 --- a/pkg/rest/types.go +++ b/pkg/rest/types.go @@ -99,8 +99,6 @@ type GeneralSetupRequest struct { RedirectToHttps bool `json:"redirectToHttps"` DisableLocalAuth bool `json:"disableLocalAuth"` EnableOIDCTokenRenewal bool `json:"enableOIDCTokenRenewal"` - Routes string `json:"routes"` - VPNEndpoint string `json:"vpnEndpoint"` } type VPNSetupRequest struct { @@ -108,12 +106,17 @@ type VPNSetupRequest struct { VPNEndpoint string `json:"vpnEndpoint"` AddressRange string `json:"addressRange"` ClientAddressPrefix string `json:"clientAddressPrefix"` - Port int `json:"port"` + 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 { Name string `json:"name"` } 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..952b917 100644 --- a/pkg/wireguard/wireguardclientconfig.go +++ b/pkg/wireguard/wireguardclientconfig.go @@ -159,9 +159,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 +171,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 +225,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/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 index a19963e..ee17276 100644 --- a/webapp/src/Routes/Setup/GeneralSetup.tsx +++ b/webapp/src/Routes/Setup/GeneralSetup.tsx @@ -8,6 +8,10 @@ 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; @@ -47,7 +51,7 @@ export function GeneralSetup() { const alertIcon = ; const setupMutation = useMutation({ mutationFn: (setupRequest: GeneralSetupRequest) => { - return axios.post(AppSettings.url + '/setup', setupRequest, { + return axios.post(AppSettings.url + '/setup/general', setupRequest, { headers: { "Authorization": "Bearer " + authInfo.token }, @@ -56,9 +60,15 @@ export function GeneralSetup() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }) setSaved(true) + setSaveError("") }, onError: (error:AxiosError) => { - setSaveError("Error: "+ error.message) + const errorMessage = error.response?.data as GeneralSetupError + if(errorMessage?.error === undefined) { + setSaveError("Error: "+ error.message) + } else { + setSaveError("Error: "+ errorMessage.error) + } } }) @@ -90,8 +100,9 @@ export function GeneralSetup() { return ( - {saved ? Settings Saved! : null} - {saveError !== "" ? saveError : null} + {saved && saveError === "" ? Settings Saved! : null} + {saveError !== "" ? {saveError} : null} +
setupMutation.mutate(values))}> - Templates will go here + diff --git a/webapp/src/Routes/Setup/TemplateSetup.tsx b/webapp/src/Routes/Setup/TemplateSetup.tsx new file mode 100644 index 0000000..db0486d --- /dev/null +++ b/webapp/src/Routes/Setup/TemplateSetup.tsx @@ -0,0 +1,111 @@ +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 } 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 { 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("") + }, + 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))}> +