Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions aks-flex-node-sudoers
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/systemctl status node-problem-det
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/systemctl check kubelet
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/systemctl check containerd
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/systemctl check node-problem-detector
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/systemctl enable openvpn@*
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/systemctl start openvpn@*
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/systemctl stop openvpn@*
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/systemctl restart openvpn@*
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/systemctl status openvpn@*
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/systemctl is-active *
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/systemctl is-enabled *
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/systemctl list-unit-files *
Expand Down Expand Up @@ -82,10 +87,11 @@ aks-flex-node ALL=(root) NOPASSWD:SETENV: /usr/bin/apt -y remove *
aks-flex-node ALL=(root) NOPASSWD:SETENV: /usr/bin/dpkg -i *
aks-flex-node ALL=(root) NOPASSWD:SETENV: /usr/bin/dpkg --purge *
aks-flex-node ALL=(root) NOPASSWD:SETENV: /usr/bin/lsof *
aks-flex-node ALL=(root) NOPASSWD:SETENV: /usr/bin/apt install -y openvpn

# Directory and file operations for Kubernetes paths - simplified for compatibility
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/mkdir *, /usr/bin/mkdir *
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/mkdir -p /etc/kubernetes/*, /bin/mkdir -p /var/lib/kubelet/*, /bin/mkdir -p /var/lib/cni/*, /bin/mkdir -p /etc/containerd/*, /bin/mkdir -p /opt/cni/bin, /bin/mkdir -p /etc/cni/net.d, /bin/mkdir -p /etc/systemd/system/kubelet.service.d, /bin/mkdir -p /etc/sysctl.d, /bin/mkdir -p /etc/modules-load.d
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/mkdir -p /etc/kubernetes/*, /bin/mkdir -p /var/lib/kubelet/*, /bin/mkdir -p /var/lib/cni/*, /bin/mkdir -p /etc/containerd/*, /bin/mkdir -p /opt/cni/bin, /bin/mkdir -p /etc/cni/net.d, /bin/mkdir -p /etc/systemd/system/kubelet.service.d, /bin/mkdir -p /etc/sysctl.d, /bin/mkdir -p /etc/modules-load.d, /bin/mkdir -p /etc/aks-flex-node/*, /bin/mkdir -p /etc/openvpn
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/cp *, /bin/mv *, /bin/rm *
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/chmod *, /bin/chown *
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/ln *, /usr/bin/ln *
Expand All @@ -94,6 +100,7 @@ aks-flex-node ALL=(root) NOPASSWD:SETENV: /usr/bin/curl *, /usr/bin/wget *
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/tar *, /usr/bin/unzip *
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/ls *, /usr/bin/ls *
aks-flex-node ALL=(root) NOPASSWD:SETENV: /usr/bin/test *, /bin/test *
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/cat *, /usr/bin/cat *

# System configuration for Kubernetes
aks-flex-node ALL=(root) NOPASSWD:SETENV: /sbin/sysctl --system
Expand All @@ -117,9 +124,15 @@ aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/cat /etc/kubernetes/*
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/cat /var/lib/kubelet/kubeconfig


# Network operations for troubleshooting
# Network operations for troubleshooting and VPN gateway management
aks-flex-node ALL=(root) NOPASSWD:SETENV: /sbin/ip route
aks-flex-node ALL=(root) NOPASSWD:SETENV: /sbin/ip addr
aks-flex-node ALL=(root) NOPASSWD:SETENV: /sbin/ip route add *
aks-flex-node ALL=(root) NOPASSWD:SETENV: /sbin/ip route del *
aks-flex-node ALL=(root) NOPASSWD:SETENV: /sbin/ip route delete *
aks-flex-node ALL=(root) NOPASSWD:SETENV: /sbin/ip route show *
aks-flex-node ALL=(root) NOPASSWD:SETENV: /usr/sbin/iptables *
aks-flex-node ALL=(root) NOPASSWD:SETENV: /sbin/iptables *
aks-flex-node ALL=(root) NOPASSWD:SETENV: /bin/netstat -rn

# Read-only Kubernetes API check for node readiness (used by status collector)
Expand Down
Binary file added assets/img/README/image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ require (

require (
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice v1.0.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4 v4.3.0 // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDo
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3 v3.0.0-beta.2 h1:qiir/pptnHqp6hV8QwV+IExYIf6cPsXBfUDUXQ27t2Y=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3 v3.0.0-beta.2/go.mod h1:jVRrRDLCOuif95HDYC23ADTMlvahB7tMdl519m9Iyjc=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice v1.0.0 h1:figxyQZXzZQIcP3njhC68bYUiTw45J8/SsHaLW8Ax0M=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice v1.0.0/go.mod h1:TmlMW4W5OvXOmOyKNnor8nlMMiO1ctIyzmHme/VHsrA=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v5 v5.0.0 h1:5n7dPVqsWfVKw+ZiEKSd3Kzu7gwBkbEBkeXb8rgaE9Q=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v5 v5.0.0/go.mod h1:HcZY0PHPo/7d75p99lB6lK0qYOP4vLRJUBpiehYXtLQ=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/hybridcompute/armhybridcompute v1.2.0 h1:7UuAn4ljE+H3GQ7qts3c7oAaMRvge68EgyckoNP/1Ro=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/hybridcompute/armhybridcompute v1.2.0/go.mod h1:F2eDq/BGK2LOEoDtoHbBOphaPqcjT0K/Y5Am8vf7+0w=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0 h1:QM6sE5k2ZT/vI5BEe0r7mqjsUSnhVBFbOsVkEuaEfiA=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0/go.mod h1:243D9iHbcQXoFUtgHJwL7gl2zx1aDuDMjvBZVGr2uW0=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4 v4.3.0 h1:bXwSugBiSbgtz7rOtbfGf+woewp4f06orW9OP5BjHLA=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4 v4.3.0/go.mod h1:Y/HgrePTmGy9HjdSGTqZNa+apUpTVIEVKXJyARP2lrk=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.1.1 h1:7CBQ+Ei8SP2c6ydQTGCCrS35bDxgTMfoP2miAwK++OU=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.1.1/go.mod h1:c/wcGeGx5FUPbM/JltUYHZcKmigwyVLJlDq+4HdtXaw=
github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
Expand Down
3 changes: 3 additions & 0 deletions pkg/bootstrapper/bootstrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"go.goms.io/aks/AKSFlexNode/pkg/components/runc"
"go.goms.io/aks/AKSFlexNode/pkg/components/services"
"go.goms.io/aks/AKSFlexNode/pkg/components/system_configuration"
"go.goms.io/aks/AKSFlexNode/pkg/components/vpn_gateway"
"go.goms.io/aks/AKSFlexNode/pkg/config"
)

Expand All @@ -34,6 +35,7 @@ func (b *Bootstrapper) Bootstrap(ctx context.Context) (*ExecutionResult, error)
// Define the bootstrap steps in order - using modules directly
steps := []Executor{
arc.NewInstaller(b.logger), // Setup Arc
vpn_gateway.NewInstaller(b.logger), // Setup VPN Gateway (if enabled)
services.NewUnInstaller(b.logger), // Stop kubelet before setup
system_configuration.NewInstaller(b.logger), // Configure system (early)
runc.NewInstaller(b.logger), // Install runc
Expand All @@ -59,6 +61,7 @@ func (b *Bootstrapper) Unbootstrap(ctx context.Context) (*ExecutionResult, error
containerd.NewUnInstaller(b.logger), // Uninstall containerd binary
runc.NewUnInstaller(b.logger), // Uninstall runc binary
system_configuration.NewUnInstaller(b.logger), // Clean system settings
vpn_gateway.NewUnInstaller(b.logger), // Clean VPN Gateway
arc.NewUnInstaller(b.logger), // Uninstall Arc (after cleanup)
}

Expand Down
7 changes: 0 additions & 7 deletions pkg/components/cni/cni_setup_installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,6 @@ func (i *Installer) IsCompleted(ctx context.Context) bool {
}
}

// Validate Step 3: Bridge configuration
configPath := filepath.Join(DefaultCNIConfDir, bridgeConfigFile)
if !utils.FileExistsAndValid(configPath) {
i.logger.Debug("Bridge configuration file not found")
return false
}

i.logger.Debug("CNI setup validation passed - all components properly configured")
return true
}
Expand Down
103 changes: 72 additions & 31 deletions pkg/components/kubelet/kubelet_installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,42 +208,58 @@ func (i *Installer) createKubeletDefaultsFile() error {
labels = append(labels, fmt.Sprintf("%s=%s", key, value))
}

// Build kubelet flags dynamically
kubeletFlags := []string{
fmt.Sprintf("--v=%d", i.config.Node.Kubelet.Verbosity),
"--address=0.0.0.0",
"--anonymous-auth=false",
"--authentication-token-webhook=true",
"--authorization-mode=Webhook",
"--cgroup-driver=systemd",
"--cgroups-per-qos=true",
"--enforce-node-allocatable=pods",
fmt.Sprintf("--cluster-dns=%s", i.config.Node.Kubelet.DNSServiceIP),
"--cluster-domain=cluster.local",
"--event-qps=0",
fmt.Sprintf("--eviction-hard=%s", mapToEvictionThresholds(i.config.Node.Kubelet.EvictionHard, ",")),
fmt.Sprintf("--kube-reserved=%s", mapToKeyValuePairs(i.config.Node.Kubelet.KubeReserved, ",")),
fmt.Sprintf("--image-gc-high-threshold=%d", i.config.Node.Kubelet.ImageGCHighThreshold),
fmt.Sprintf("--image-gc-low-threshold=%d", i.config.Node.Kubelet.ImageGCLowThreshold),
fmt.Sprintf("--max-pods=%d", i.config.Node.MaxPods),
"--node-status-update-frequency=10s",
fmt.Sprintf("--pod-infra-container-image=%s", i.config.Containerd.PauseImage),
"--pod-max-pids=-1",
"--protect-kernel-defaults=true",
"--read-only-port=0",
"--resolv-conf=/run/systemd/resolve/resolv.conf",
"--streaming-connection-idle-timeout=4h",
"--tls-cipher-suites=TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_128_GCM_SHA256",
}

// Add VPN node IP if VPN gateway is enabled and connected
if vpnIP := i.getVPNInterfaceIP(); vpnIP != "" {
kubeletFlags = append(kubeletFlags, fmt.Sprintf("--node-ip=%s", vpnIP))
i.logger.Infof("Configuring kubelet to use VPN interface IP: %s", vpnIP)
}

// Format flags with proper line continuation
flagsFormatted := make([]string, len(kubeletFlags))
for i, flag := range kubeletFlags {
flagsFormatted[i] = fmt.Sprintf(" %s \\", flag)
}
// Remove trailing backslash from last flag
if len(flagsFormatted) > 0 {
lastFlag := flagsFormatted[len(flagsFormatted)-1]
flagsFormatted[len(flagsFormatted)-1] = strings.TrimSuffix(lastFlag, " \\")
}

kubeletDefaults := fmt.Sprintf(`KUBELET_NODE_LABELS="%s"
KUBELET_CONFIG_FILE_FLAGS=""
KUBELET_FLAGS="\
--v=%d \
--address=0.0.0.0 \
--anonymous-auth=false \
--authentication-token-webhook=true \
--authorization-mode=Webhook \
--cgroup-driver=systemd \
--cgroups-per-qos=true \
--enforce-node-allocatable=pods \
--cluster-dns=%s \
--cluster-domain=cluster.local \
--event-qps=0 \
--eviction-hard=%s \
--kube-reserved=%s \
--image-gc-high-threshold=%d \
--image-gc-low-threshold=%d \
--max-pods=%d \
--node-status-update-frequency=10s \
--pod-max-pids=-1 \
--protect-kernel-defaults=true \
--read-only-port=0 \
--resolv-conf=/run/systemd/resolve/resolv.conf \
--streaming-connection-idle-timeout=4h \
--rotate-certificates=true \
--tls-cipher-suites=TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_128_GCM_SHA256 \
%s \
"`,
strings.Join(labels, ","),
i.config.Node.Kubelet.Verbosity,
i.config.Node.Kubelet.DNSServiceIP,
mapToEvictionThresholds(i.config.Node.Kubelet.EvictionHard, ","),
mapToKeyValuePairs(i.config.Node.Kubelet.KubeReserved, ","),
i.config.Node.Kubelet.ImageGCHighThreshold,
i.config.Node.Kubelet.ImageGCLowThreshold,
i.config.Node.MaxPods)
strings.Join(flagsFormatted, "\n"))

// Ensure /etc/default directory exists
if err := utils.RunSystemCommand("mkdir", "-p", etcDefaultDir); err != nil {
Expand Down Expand Up @@ -709,3 +725,28 @@ func mapToEvictionThresholds(m map[string]string, separator string) string {
}
return strings.Join(pairs, separator)
}

// getVPNInterfaceIP returns the IP address of the VPN interface if VPN is enabled and connected
func (i *Installer) getVPNInterfaceIP() string {
// Check if VPN gateway is enabled in configuration
if !i.config.IsVPNGatewayEnabled() {
return ""
}

// Get VPN interface using the generic utility function
vpnInterface, err := utils.GetVPNInterface()
if err != nil {
i.logger.Debugf("VPN interface not found: %v", err)
return ""
}

// Get IP address of the VPN interface
ip, err := utils.GetVPNInterfaceIP(vpnInterface)
if err != nil {
i.logger.Debugf("Failed to get VPN interface IP: %v", err)
return ""
}

i.logger.Infof("Found VPN interface %s with IP: %s", vpnInterface, ip)
return ip
}
29 changes: 29 additions & 0 deletions pkg/components/vpn_gateway/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# VPN Gateway Component

This component provides VPN connectivity for AKS Flex Node using OpenVPN over Point-to-Site (P2S) connections. It's designed for scenarios where a limited number of clients need secure access to a virtual network.

![VPN Gateway Architecture](../../../assets/img/README/image.png)

## Overview

The VPN Gateway component enables secure connectivity between AKS Flex Nodes and Azure Virtual Networks through:

- **Certificate-based authentication** using self-generated root and client certificates
- **OpenVPN SSL tunnel** for encrypted communication
- **Automatic IP management** to update node IPs when VPN interface changes
- **Azure integration** for seamless VPN gateway configuration

## Steps
1. Prepare Azure Resources
- Create a GatewaySubnet within the AKS VNet
- Deploy a Route-based Azure VPN Gateway into the GatewaySubnet

2. Prepare Certificates
- root certificate: will be uploaded to Azure as a "trusted" cert (a Base64 encoded X.509 .cer file.)
- client certificates: generated from the root certificate and to be installed on each client computer for client authentication

3. Configure VPN client profile

## References

[Configure server settings for P2S VPN Gateway certificate authentication](https://learn.microsoft.com/en-us/azure/vpn-gateway/point-to-site-certificate-gateway)
96 changes: 96 additions & 0 deletions pkg/components/vpn_gateway/consts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package vpn_gateway

import (
"path/filepath"
"time"
)

const (
// VPN Gateway default name
defaultVPNGatewayName = "vpn-gateway"

// Azure VPN Gateway configuration
vpnClientRootCertName = "VPNClientRootCert"
gatewaySubnetName = "GatewaySubnet"
gatewaySubnetPrefix = 27 // /27 subnet for GatewaySubnet

// Directory paths
systemConfigDir = "/etc/aks-flex-node"
certificatesDir = "/etc/aks-flex-node/certs"
openVPNConfigDir = "/etc/openvpn"

// File names
vpnConfigFileName = "vpn-config.ovpn"
vpnClientCertFileName = "vpn-client.crt"
vpnClientKeyFileName = "vpn-client.key"
vpnRootCertFileName = "vpn-root-ca.crt"
openVPNConfigFileName = "vpnconfig.conf"

// File permissions
certificatesDirPerm = 0700
configDirPerm = 0755
privateKeyFilePerm = 0600
certificateFilePerm = 0644

// Certificate configuration
certificateKeySize = 2048
certificateValidYears = 10
certificateCommonName = "VPN CA"

// PEM block types
rsaPrivateKeyType = "RSA PRIVATE KEY"
certificateType = "CERTIFICATE"

// Timeouts and intervals
gatewayProvisioningTimeout = 30 * time.Minute // VPN Gateway provisioning timeout
gatewayStatusCheckInterval = 30 * time.Second // Polling interval for gateway status
vpnConnectionTimeout = 1 * time.Minute // VPN connection establishment timeout
vpnConnectionCheckInterval = 2 * time.Second // Interval for VPN connection checks

// System paths for validation
systemEtcPrefix = "/etc/"
systemUsrPrefix = "/usr/"
systemVarPrefix = "/var/"

// Temporary file patterns
tempVPNConfigPattern = "vpnconfig-*.ovpn"
tempVPNCertPattern = "vpn-cert-*.tmp"
tempVPNZipPattern = "vpnconfig-*.zip"
tempVPNExtractPrefix = "vpnconfig-"

// OpenVPN service template
openVPNServiceTemplate = "openvpn@vpnconfig"
openVPNServiceName = "vpnconfig"

// Public IP naming pattern
gatewayPublicIPName = "vpn-gateway-ip"
vpnGatewayName = "vpn-gateway"

// Point-to-Site configuration name
p2sConfigName = "P2SConfig"
)

// GetVPNClientCertPath returns the full path to the VPN client certificate file
func GetVPNClientCertPath() string {
return filepath.Join(certificatesDir, vpnClientCertFileName)
}

// GetVPNClientKeyPath returns the full path to the VPN client private key file
func GetVPNClientKeyPath() string {
return filepath.Join(certificatesDir, vpnClientKeyFileName)
}

// GetVPNRootCertPath returns the full path to the VPN root CA certificate file
func GetVPNRootCertPath() string {
return filepath.Join(certificatesDir, vpnRootCertFileName)
}

// GetOpenVPNConfigPath returns the full path to the OpenVPN configuration file
func GetOpenVPNConfigPath() string {
return filepath.Join(openVPNConfigDir, openVPNConfigFileName)
}

// GetVPNConfigPath returns the full path to the VPN configuration file in system config directory
func GetVPNConfigPath() string {
return filepath.Join(systemConfigDir, vpnConfigFileName)
}
Loading
Loading