mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-21 18:54:01 -04:00
network: support fortinet vpn with saml and password auth (#3384)
Adds Fortinet gateway support to the NetworkManager openconnect path. A SAML gateway authenticates through the browser, with the loopback redirect handled in core and the session traded for the cookie openconnect needs. Certificate trust is confirmed once and stored as the digest openfortivpn uses. openfortivpn config files can be imported as profiles. A saml-login line selects the browser login, anything else imports as a password profile carrying whatever credentials the file had. Co-authored-by: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -315,6 +315,38 @@ func (a *SecretAgent) GetSecrets(
|
||||
}
|
||||
a.backend.cachedOpenConnectMu.Unlock()
|
||||
|
||||
if len(fields) == 1 && fields[0] == "fortinet-saml" {
|
||||
data := vpnDataFromSettings(conn)
|
||||
|
||||
log.Infof("[SecretAgent] Starting Fortinet SAML authentication for gateway=%s", data["gateway"])
|
||||
|
||||
samlCtx, samlCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer samlCancel()
|
||||
|
||||
authResult, err := a.backend.authenticateFortinetSAML(samlCtx, fortinetAuthTarget{
|
||||
ConnName: displayName,
|
||||
ConnUUID: connUuid,
|
||||
ConnectionPath: string(path),
|
||||
VpnService: vpnSvc,
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
log.Warnf("[SecretAgent] Fortinet SAML authentication failed: %v", err)
|
||||
return nil, dbus.MakeFailedError(fmt.Errorf("SAML authentication failed for the Fortinet gateway: %w", err))
|
||||
}
|
||||
|
||||
a.backend.cachedOpenConnectMu.Lock()
|
||||
a.backend.cachedOpenConnectAuth = &cachedOpenConnectAuth{
|
||||
ConnectionUUID: connUuid,
|
||||
Cookie: authResult.Cookie,
|
||||
Host: authResult.Host,
|
||||
Fingerprint: authResult.Fingerprint,
|
||||
}
|
||||
a.backend.cachedOpenConnectMu.Unlock()
|
||||
|
||||
return buildOpenConnectSecretsResponse(settingName, authResult.Cookie, authResult.Host, authResult.Fingerprint), nil
|
||||
}
|
||||
|
||||
if len(fields) == 1 && fields[0] == "gp-saml" {
|
||||
gateway := ""
|
||||
protocol := ""
|
||||
@@ -876,6 +908,22 @@ func buildFieldsInfo(setting string, fields []string, vpnService string) []Field
|
||||
return result
|
||||
}
|
||||
|
||||
func vpnDataFromSettings(conn map[string]nmVariantMap) map[string]string {
|
||||
vpnSettings, ok := conn["vpn"]
|
||||
if !ok {
|
||||
return map[string]string{}
|
||||
}
|
||||
dataVariant, ok := vpnSettings["data"]
|
||||
if !ok {
|
||||
return map[string]string{}
|
||||
}
|
||||
dataMap, ok := dataVariant.Value().(map[string]string)
|
||||
if !ok {
|
||||
return map[string]string{}
|
||||
}
|
||||
return dataMap
|
||||
}
|
||||
|
||||
func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
|
||||
fields := []string{"password"}
|
||||
|
||||
@@ -911,8 +959,11 @@ func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
|
||||
case "gp":
|
||||
log.Infof("[SecretAgent] GlobalProtect SAML auth detected")
|
||||
return []string{"gp-saml"}
|
||||
case "fortinet":
|
||||
log.Infof("[SecretAgent] Fortinet SAML auth detected")
|
||||
return []string{"fortinet-saml"}
|
||||
default:
|
||||
log.Infof("[SecretAgent] External browser auth detected for protocol '%s' but only GlobalProtect (gp) SAML is currently supported, falling back to credentials", protocol)
|
||||
log.Infof("[SecretAgent] External browser auth detected for protocol '%s' but only GlobalProtect (gp) and Fortinet SAML are currently supported, falling back to credentials", protocol)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -989,6 +1040,8 @@ func vpnFieldMeta(field, _ string) (label string, isSecret bool) {
|
||||
switch field {
|
||||
case "gp-saml":
|
||||
return "GlobalProtect SAML/SSO", false
|
||||
case "fortinet-saml":
|
||||
return "Fortinet SAML/SSO", false
|
||||
case "key_pass":
|
||||
return "PIN", true
|
||||
case "password":
|
||||
|
||||
@@ -353,6 +353,29 @@ func TestInferVPNFields_GPSaml(t *testing.T) {
|
||||
expectedLen: 1,
|
||||
shouldHave: []string{"password"},
|
||||
},
|
||||
{
|
||||
name: "Fortinet SAML",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
dataMap: map[string]string{
|
||||
"protocol": "fortinet",
|
||||
"authtype": "saml",
|
||||
"gateway": "vpn.example.com",
|
||||
},
|
||||
expectedLen: 1,
|
||||
shouldHave: []string{"fortinet-saml"},
|
||||
},
|
||||
{
|
||||
name: "Fortinet password auth is not SAML",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
dataMap: map[string]string{
|
||||
"protocol": "fortinet",
|
||||
"authtype": "password",
|
||||
"username": "john",
|
||||
"gateway": "vpn.example.com",
|
||||
},
|
||||
expectedLen: 1,
|
||||
shouldHave: []string{"password"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -111,7 +111,10 @@ type pendingVPNCredentials struct {
|
||||
// falls back to Password under the "password" key when empty.
|
||||
Secrets map[string]string
|
||||
PersistentSecrets map[string]string
|
||||
SavePassword bool
|
||||
// PersistentData holds non-secret vpn data entries to merge on success,
|
||||
// such as a server certificate the user chose to trust.
|
||||
PersistentData map[string]string
|
||||
SavePassword bool
|
||||
}
|
||||
|
||||
type cachedVPNCredentials struct {
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/Wifx/gonetworkmanager/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
// FortiGate redirects the browser back to this loopback port after SAML
|
||||
// login; 8020 is the openfortivpn default and what gateways are configured
|
||||
// with. Overridden per profile with the "saml-port" data key.
|
||||
fortinetSAMLDefaultListenPort = 8020
|
||||
fortinetDefaultGatewayPort = 443
|
||||
fortinetUserAgent = "Mozilla/5.0 SV1"
|
||||
)
|
||||
|
||||
var errFortinetSAMLNoCookie = errors.New("gateway did not return an SVPNCOOKIE")
|
||||
|
||||
type fortinetSAMLRequest struct {
|
||||
Gateway string
|
||||
Realm string
|
||||
ListenPort int
|
||||
// CertPin is the openconnect-style "pin-sha256:" hash the gateway
|
||||
// connection is pinned to for the token exchange.
|
||||
CertPin string
|
||||
}
|
||||
|
||||
type fortinetGatewayCert struct {
|
||||
// Pin is the openconnect --servercert form: sha256 of the public key.
|
||||
Pin string
|
||||
// Digest is the sha256 of the whole certificate, matching the hex digest
|
||||
// openfortivpn stores as "trusted-cert".
|
||||
Digest string
|
||||
Verified bool
|
||||
}
|
||||
|
||||
// fortinetProfile is what a Fortinet gateway needs to be reachable,
|
||||
// independent of where those settings were read from.
|
||||
type fortinetProfile struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
Realm string
|
||||
SAML bool
|
||||
SAMLPort int
|
||||
TrustedCerts []string
|
||||
}
|
||||
|
||||
// buildFortinetSettings describes the openconnect profile for a gateway,
|
||||
// either through the browser SAML login or through password authentication.
|
||||
func buildFortinetSettings(p fortinetProfile, name, serviceType string) gonetworkmanager.ConnectionSettings {
|
||||
data := map[string]string{
|
||||
"gateway": net.JoinHostPort(p.Host, strconv.Itoa(p.Port)),
|
||||
"protocol": "fortinet",
|
||||
}
|
||||
if p.Username != "" {
|
||||
data["username"] = p.Username
|
||||
}
|
||||
if p.Realm != "" {
|
||||
data["usergroup"] = p.Realm
|
||||
}
|
||||
if len(p.TrustedCerts) > 0 {
|
||||
data["trusted-cert"] = strings.Join(p.TrustedCerts, ",")
|
||||
}
|
||||
|
||||
settings := gonetworkmanager.ConnectionSettings{
|
||||
"connection": {
|
||||
"id": name,
|
||||
"type": "vpn",
|
||||
"autoconnect": false,
|
||||
},
|
||||
"vpn": {
|
||||
"service-type": serviceType,
|
||||
"data": data,
|
||||
},
|
||||
"ipv4": {"method": "auto"},
|
||||
"ipv6": {"method": "auto"},
|
||||
}
|
||||
|
||||
if !p.SAML {
|
||||
data["authtype"] = "password"
|
||||
if p.Password != "" {
|
||||
data["password-flags"] = "0"
|
||||
settings["vpn"]["secrets"] = map[string]string{"password": p.Password}
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
data["authtype"] = "saml"
|
||||
data["cookie-flags"] = "2"
|
||||
data["gateway-flags"] = "2"
|
||||
data["gwcert-flags"] = "2"
|
||||
if p.SAMLPort != 0 {
|
||||
data["saml-port"] = strconv.Itoa(p.SAMLPort)
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) runFortinetSAMLAuth(ctx context.Context, req fortinetSAMLRequest) (*openConnectAuthResult, error) {
|
||||
host, port, err := splitGatewayHostPort(req.Gateway)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
listenPort := req.ListenPort
|
||||
if listenPort == 0 {
|
||||
listenPort = fortinetSAMLDefaultListenPort
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(listenPort)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot listen for the SAML redirect on port %d: %w", listenPort, err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
authURL := fortinetSAMLStartURL(host, port, req.Realm)
|
||||
log.Infof("[Fortinet-SAML] Listening on 127.0.0.1:%d, authenticate at %s", listenPort, authURL)
|
||||
|
||||
if err := openInBrowser(authURL); err != nil {
|
||||
return nil, fmt.Errorf("cannot open a browser for SAML login, authenticate at %s: %w", authURL, err)
|
||||
}
|
||||
|
||||
sessionID, err := waitForFortinetSAMLSession(ctx, listener)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cookie, err := exchangeFortinetSAMLSession(ctx, host, port, sessionID, req.CertPin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Infof("[Fortinet-SAML] Authentication successful for %s", req.Gateway)
|
||||
|
||||
return &openConnectAuthResult{
|
||||
Cookie: "SVPNCOOKIE=" + cookie,
|
||||
Host: net.JoinHostPort(host, strconv.Itoa(port)),
|
||||
Fingerprint: req.CertPin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type fortinetAuthTarget struct {
|
||||
ConnName string
|
||||
ConnUUID string
|
||||
ConnectionPath string
|
||||
VpnService string
|
||||
Data map[string]string
|
||||
}
|
||||
|
||||
// authenticateFortinetSAML establishes gateway trust, runs the browser SAML
|
||||
// login and returns the cookie NetworkManager hands to openconnect.
|
||||
func (b *NetworkManagerBackend) authenticateFortinetSAML(ctx context.Context, target fortinetAuthTarget) (*openConnectAuthResult, error) {
|
||||
gateway := target.Data["gateway"]
|
||||
host, port, err := splitGatewayHostPort(gateway)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cert, err := probeGatewayCert(ctx, host, port)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newlyTrusted, err := b.resolveFortinetCertTrust(ctx, target, cert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
auth, err := b.runFortinetSAMLAuth(ctx, fortinetSAMLRequest{
|
||||
Gateway: gateway,
|
||||
Realm: target.Data["usergroup"],
|
||||
ListenPort: fortinetSAMLListenPort(target.Data),
|
||||
CertPin: cert.Pin,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if newlyTrusted {
|
||||
b.pendingVPNSaveMu.Lock()
|
||||
b.pendingVPNSave = &pendingVPNCredentials{
|
||||
ConnectionPath: target.ConnectionPath,
|
||||
PersistentData: map[string]string{
|
||||
"trusted-cert": appendTrustedCert(target.Data["trusted-cert"], cert.Digest),
|
||||
},
|
||||
}
|
||||
b.pendingVPNSaveMu.Unlock()
|
||||
}
|
||||
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// resolveFortinetCertTrust reports whether the user just approved a certificate
|
||||
// that should be remembered. Trust is stored as the whole-certificate digest
|
||||
// under the "trusted-cert" data key, the same value openfortivpn configs use.
|
||||
func (b *NetworkManagerBackend) resolveFortinetCertTrust(
|
||||
ctx context.Context,
|
||||
target fortinetAuthTarget,
|
||||
cert *fortinetGatewayCert,
|
||||
) (bool, error) {
|
||||
if cert.Verified {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
trusted := target.Data["trusted-cert"]
|
||||
if certDigestTrusted(trusted, cert.Digest) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if b.promptBroker == nil {
|
||||
return false, fmt.Errorf("VPN server certificate is untrusted: %s", cert.Pin)
|
||||
}
|
||||
|
||||
reason := "server-certificate"
|
||||
if trusted != "" {
|
||||
reason = "server-certificate-changed"
|
||||
}
|
||||
|
||||
token, err := b.promptBroker.Ask(ctx, PromptRequest{
|
||||
Name: target.ConnName,
|
||||
ConnType: "vpn",
|
||||
VpnService: target.VpnService,
|
||||
SettingName: "vpn",
|
||||
Hints: []string{cert.Pin},
|
||||
Reason: reason,
|
||||
ConnectionId: target.ConnName,
|
||||
ConnectionUuid: target.ConnUUID,
|
||||
ConnectionPath: target.ConnectionPath,
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to request certificate confirmation: %w", err)
|
||||
}
|
||||
if _, err := b.promptBroker.Wait(ctx, token); err != nil {
|
||||
return false, fmt.Errorf("certificate confirmation failed: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// certDigestTrusted matches a certificate digest against the trusted list,
|
||||
// which openfortivpn configs may carry as several entries.
|
||||
func certDigestTrusted(trusted, digest string) bool {
|
||||
if trusted == "" || digest == "" {
|
||||
return false
|
||||
}
|
||||
for entry := range strings.SplitSeq(trusted, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(entry), digest) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// appendTrustedCert adds a digest to the trusted list, keeping the entries the
|
||||
// profile already carries so a gateway that answers from several nodes is only
|
||||
// confirmed once per certificate.
|
||||
func appendTrustedCert(trusted, digest string) string {
|
||||
if certDigestTrusted(trusted, digest) {
|
||||
return trusted
|
||||
}
|
||||
|
||||
entries := []string{}
|
||||
for entry := range strings.SplitSeq(trusted, ",") {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return strings.Join(append(entries, digest), ",")
|
||||
}
|
||||
|
||||
func fortinetSAMLListenPort(data map[string]string) int {
|
||||
port, err := strconv.Atoi(data["saml-port"])
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return fortinetSAMLDefaultListenPort
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
func splitGatewayHostPort(gateway string) (string, int, error) {
|
||||
trimmed := strings.TrimSpace(gateway)
|
||||
if trimmed == "" {
|
||||
return "", 0, errors.New("VPN gateway is empty")
|
||||
}
|
||||
|
||||
if idx := strings.Index(trimmed, "://"); idx >= 0 {
|
||||
trimmed = trimmed[idx+3:]
|
||||
}
|
||||
trimmed = strings.TrimSuffix(trimmed, "/")
|
||||
if idx := strings.IndexAny(trimmed, "/?"); idx >= 0 {
|
||||
trimmed = trimmed[:idx]
|
||||
}
|
||||
if trimmed == "" {
|
||||
return "", 0, fmt.Errorf("invalid VPN gateway: %s", gateway)
|
||||
}
|
||||
|
||||
host, portStr, err := net.SplitHostPort(trimmed)
|
||||
if err != nil {
|
||||
return strings.TrimSuffix(strings.TrimPrefix(trimmed, "["), "]"), fortinetDefaultGatewayPort, nil
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return "", 0, fmt.Errorf("invalid VPN gateway port: %s", portStr)
|
||||
}
|
||||
return host, port, nil
|
||||
}
|
||||
|
||||
func fortinetSAMLStartURL(host string, port int, realm string) string {
|
||||
base := fmt.Sprintf("https://%s/remote/saml/start?redirect=1",
|
||||
net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
if realm == "" {
|
||||
return base
|
||||
}
|
||||
return base + "&realm=" + url.QueryEscape(realm)
|
||||
}
|
||||
|
||||
// waitForFortinetSAMLSession serves the loopback redirect target the gateway
|
||||
// sends the browser to once SAML login succeeds, and returns the session id.
|
||||
//
|
||||
// Only the session id identifies the redirect. Browsers also ask this origin
|
||||
// for favicons, restore stale tabs and follow gateway bounces through the
|
||||
// root, so every other request is answered with a holding page and the login
|
||||
// keeps waiting; the context deadline is the only way this fails.
|
||||
func waitForFortinetSAMLSession(ctx context.Context, listener net.Listener) (string, error) {
|
||||
ids := make(chan string, 1)
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
if id == "" {
|
||||
writeFortinetSAMLPage(w, "Waiting for the VPN login to complete.", false)
|
||||
return
|
||||
}
|
||||
|
||||
writeFortinetSAMLPage(w, "VPN login complete.", true)
|
||||
select {
|
||||
case ids <- id:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
server := &http.Server{Handler: handler, ReadHeaderTimeout: 10 * time.Second}
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
defer func() {
|
||||
// Close() would cut the connection before the browser has the page.
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", fmt.Errorf("SAML login was not completed: %w", ctx.Err())
|
||||
case id := <-ids:
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
|
||||
// writeFortinetSAMLPage renders the loopback page the browser lands on. The
|
||||
// success page asks to close itself, which browsers only honour for tabs a
|
||||
// script opened, so it falls back to telling the user to close it.
|
||||
func writeFortinetSAMLPage(w http.ResponseWriter, message string, autoClose bool) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
if !autoClose {
|
||||
fmt.Fprintf(w, fortinetSAMLPageTemplate, message, "")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, fortinetSAMLPageTemplate, message, fortinetSAMLCloseScript)
|
||||
}
|
||||
|
||||
const fortinetSAMLPageTemplate = `<!DOCTYPE html><html><head><meta charset="utf-8">` +
|
||||
`<title>DMS VPN</title></head><body style="font-family:system-ui,sans-serif;text-align:center;padding:3rem">` +
|
||||
`<p>%s</p><p id="hint"></p>%s</body></html>`
|
||||
|
||||
const fortinetSAMLCloseScript = `<script>
|
||||
setTimeout(function () {
|
||||
window.close();
|
||||
document.getElementById("hint").textContent = "You can close this tab.";
|
||||
}, 3000);
|
||||
</script>`
|
||||
|
||||
// exchangeFortinetSAMLSession trades the SAML session id for the SVPNCOOKIE
|
||||
// that openconnect uses as its Fortinet cookie.
|
||||
func exchangeFortinetSAMLSession(ctx context.Context, host string, port int, sessionID, certPin string) (string, error) {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create cookie jar: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Jar: jar,
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{TLSClientConfig: pinnedTLSConfig(host, certPin)},
|
||||
}
|
||||
defer client.CloseIdleConnections()
|
||||
|
||||
endpoint := fmt.Sprintf("https://%s/remote/saml/auth_id?id=%s",
|
||||
net.JoinHostPort(host, strconv.Itoa(port)), url.QueryEscape(sessionID))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build token request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", fortinetUserAgent)
|
||||
req.Header.Set("Accept", "*/*")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to exchange SAML session for a cookie: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("gateway rejected the SAML session (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
if cookie := findSVPNCookie(resp.Cookies()); cookie != "" {
|
||||
return cookie, nil
|
||||
}
|
||||
if cookie := findSVPNCookie(jar.Cookies(req.URL)); cookie != "" {
|
||||
return cookie, nil
|
||||
}
|
||||
return "", errFortinetSAMLNoCookie
|
||||
}
|
||||
|
||||
func findSVPNCookie(cookies []*http.Cookie) string {
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == "SVPNCOOKIE" && cookie.Value != "" {
|
||||
return cookie.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// probeGatewayCert reads the gateway's leaf certificate and reports both the
|
||||
// openconnect pin and the whole-certificate digest, along with whether the
|
||||
// chain validates against the system trust store.
|
||||
func probeGatewayCert(ctx context.Context, host string, port int) (*fortinetGatewayCert, error) {
|
||||
dialer := &tls.Dialer{
|
||||
NetDialer: &net.Dialer{Timeout: 10 * time.Second},
|
||||
Config: &tls.Config{InsecureSkipVerify: true, ServerName: host},
|
||||
}
|
||||
|
||||
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot reach VPN gateway %s: %w", host, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
state := conn.(*tls.Conn).ConnectionState()
|
||||
if len(state.PeerCertificates) == 0 {
|
||||
return nil, fmt.Errorf("VPN gateway %s presented no certificate", host)
|
||||
}
|
||||
|
||||
leaf := state.PeerCertificates[0]
|
||||
intermediates := x509.NewCertPool()
|
||||
for _, cert := range state.PeerCertificates[1:] {
|
||||
intermediates.AddCert(cert)
|
||||
}
|
||||
_, verifyErr := leaf.Verify(x509.VerifyOptions{DNSName: host, Intermediates: intermediates})
|
||||
|
||||
return &fortinetGatewayCert{
|
||||
Pin: certificatePin(leaf),
|
||||
Digest: certificateDigest(leaf),
|
||||
Verified: verifyErr == nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// certificatePin hashes the public key, matching openconnect's --servercert.
|
||||
func certificatePin(cert *x509.Certificate) string {
|
||||
sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
|
||||
return "pin-sha256:" + base64.StdEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// certificateDigest hashes the whole certificate, matching the hex digest
|
||||
// openfortivpn stores as "trusted-cert".
|
||||
func certificateDigest(cert *x509.Certificate) string {
|
||||
sum := sha256.Sum256(cert.Raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func pinnedTLSConfig(host, pin string) *tls.Config {
|
||||
if pin == "" {
|
||||
return &tls.Config{ServerName: host}
|
||||
}
|
||||
|
||||
return &tls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: true,
|
||||
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
||||
if len(rawCerts) == 0 {
|
||||
return errors.New("VPN gateway presented no certificate")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(rawCerts[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse gateway certificate: %w", err)
|
||||
}
|
||||
if certificatePin(cert) != pin {
|
||||
return errors.New("VPN gateway certificate does not match the trusted fingerprint")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func openInBrowser(target string) error {
|
||||
cmd := exec.Command("xdg-open", target)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
go func() { _ = cmd.Wait() }()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSplitGatewayHostPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
gateway string
|
||||
wantHost string
|
||||
wantPort int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "bare host", gateway: "vpn.example.com", wantHost: "vpn.example.com", wantPort: 443},
|
||||
{name: "host and port", gateway: "vpn.example.com:10443", wantHost: "vpn.example.com", wantPort: 10443},
|
||||
{name: "https url", gateway: "https://vpn.example.com:10443/", wantHost: "vpn.example.com", wantPort: 10443},
|
||||
{name: "url with path", gateway: "https://vpn.example.com/remote/login", wantHost: "vpn.example.com", wantPort: 443},
|
||||
{name: "surrounding space", gateway: " vpn.example.com ", wantHost: "vpn.example.com", wantPort: 443},
|
||||
{name: "ipv6 with port", gateway: "[2001:db8::1]:10443", wantHost: "2001:db8::1", wantPort: 10443},
|
||||
{name: "bracketed ipv6 without port", gateway: "[2001:db8::1]", wantHost: "2001:db8::1", wantPort: 443},
|
||||
{name: "bare ipv6", gateway: "2001:db8::1", wantHost: "2001:db8::1", wantPort: 443},
|
||||
{name: "empty", gateway: "", wantErr: true},
|
||||
{name: "port out of range", gateway: "vpn.example.com:99999", wantErr: true},
|
||||
{name: "non numeric port", gateway: "vpn.example.com:https", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
host, port, err := splitGatewayHostPort(tt.gateway)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantHost, host)
|
||||
assert.Equal(t, tt.wantPort, port)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFortinetSAMLStartURL(t *testing.T) {
|
||||
assert.Equal(t,
|
||||
"https://vpn.example.com:443/remote/saml/start?redirect=1",
|
||||
fortinetSAMLStartURL("vpn.example.com", 443, ""))
|
||||
|
||||
assert.Equal(t,
|
||||
"https://vpn.example.com:10443/remote/saml/start?redirect=1&realm=my+realm%2Fone",
|
||||
fortinetSAMLStartURL("vpn.example.com", 10443, "my realm/one"))
|
||||
}
|
||||
|
||||
func TestFortinetSAMLListenPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data map[string]string
|
||||
want int
|
||||
}{
|
||||
{name: "unset", data: map[string]string{}, want: fortinetSAMLDefaultListenPort},
|
||||
{name: "custom", data: map[string]string{"saml-port": "9000"}, want: 9000},
|
||||
{name: "not a number", data: map[string]string{"saml-port": "abc"}, want: fortinetSAMLDefaultListenPort},
|
||||
{name: "out of range", data: map[string]string{"saml-port": "70000"}, want: fortinetSAMLDefaultListenPort},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, fortinetSAMLListenPort(tt.data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertDigestTrusted(t *testing.T) {
|
||||
digest := "aa11bb22cc33dd44ee55ff6600778899aabbccddeeff00112233445566778899"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
trusted string
|
||||
want bool
|
||||
}{
|
||||
{name: "exact match", trusted: digest, want: true},
|
||||
{name: "case insensitive", trusted: "AA11BB22CC33DD44EE55FF6600778899AABBCCDDEEFF00112233445566778899", want: true},
|
||||
{name: "one of several", trusted: "0000, " + digest + " ,1111", want: true},
|
||||
{name: "different digest", trusted: "0000", want: false},
|
||||
{name: "empty trust list", trusted: "", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, certDigestTrusted(tt.trusted, digest))
|
||||
})
|
||||
}
|
||||
|
||||
assert.False(t, certDigestTrusted(digest, ""), "an empty digest never matches")
|
||||
}
|
||||
|
||||
func TestAppendTrustedCert(t *testing.T) {
|
||||
digest := "aa11bb22cc33dd44ee55ff6600778899aabbccddeeff00112233445566778899"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
trusted string
|
||||
want string
|
||||
}{
|
||||
{name: "empty list", trusted: "", want: digest},
|
||||
{name: "keeps existing entries", trusted: "0000,1111", want: "0000,1111," + digest},
|
||||
{name: "drops blank entries", trusted: " 0000 , ,", want: "0000," + digest},
|
||||
{name: "already trusted", trusted: "0000," + digest, want: "0000," + digest},
|
||||
{name: "already trusted in another case", trusted: strings.ToUpper(digest), want: strings.ToUpper(digest)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, appendTrustedCert(tt.trusted, digest))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForFortinetSAMLSession(t *testing.T) {
|
||||
t.Run("returns the session id from the redirect", func(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer listener.Close()
|
||||
|
||||
go func() {
|
||||
resp, err := http.Get("http://" + listener.Addr().String() + "/?id=session-42")
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, err := waitForFortinetSAMLSession(ctx, listener)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "session-42", id)
|
||||
})
|
||||
|
||||
t.Run("keeps waiting when a request carries no session id", func(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer listener.Close()
|
||||
|
||||
go func() {
|
||||
base := "http://" + listener.Addr().String()
|
||||
if resp, err := http.Get(base + "/favicon.ico"); err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if resp, err := http.Get(base + "/?id=session-42"); err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, err := waitForFortinetSAMLSession(ctx, listener)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "session-42", id)
|
||||
})
|
||||
|
||||
t.Run("accepts the session id on any path", func(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer listener.Close()
|
||||
|
||||
go func() {
|
||||
resp, err := http.Get("http://" + listener.Addr().String() + "/sslvpn?id=session-7")
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, err := waitForFortinetSAMLSession(ctx, listener)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "session-7", id)
|
||||
})
|
||||
|
||||
t.Run("the browser gets the success page before the listener goes away", func(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer listener.Close()
|
||||
|
||||
bodies := make(chan string, 1)
|
||||
go func() {
|
||||
resp, err := http.Get("http://" + listener.Addr().String() + "/?id=session-42")
|
||||
if err != nil {
|
||||
bodies <- "request failed: " + err.Error()
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
bodies <- "read failed: " + err.Error()
|
||||
return
|
||||
}
|
||||
bodies <- string(body)
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, err := waitForFortinetSAMLSession(ctx, listener)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "session-42", id)
|
||||
|
||||
select {
|
||||
case body := <-bodies:
|
||||
assert.Contains(t, body, "VPN login complete.")
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the browser never received a response")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gives up when the browser login never completes", func(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer listener.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err = waitForFortinetSAMLSession(ctx, listener)
|
||||
assert.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildFortinetSettings(t *testing.T) {
|
||||
const service = "org.freedesktop.NetworkManager.openconnect"
|
||||
|
||||
t.Run("builds a fortinet SAML openconnect profile", func(t *testing.T) {
|
||||
settings := buildFortinetSettings(fortinetProfile{
|
||||
Host: "vpn.example.com",
|
||||
Port: 10443,
|
||||
Username: "jdoe",
|
||||
Realm: "contractors",
|
||||
SAML: true,
|
||||
SAMLPort: 9000,
|
||||
TrustedCerts: []string{"aa", "bb"},
|
||||
}, "Work VPN", service)
|
||||
|
||||
assert.Equal(t, "Work VPN", settings["connection"]["id"])
|
||||
assert.Equal(t, "vpn", settings["connection"]["type"])
|
||||
assert.Equal(t, false, settings["connection"]["autoconnect"])
|
||||
assert.Equal(t, service, settings["vpn"]["service-type"])
|
||||
|
||||
data, ok := settings["vpn"]["data"].(map[string]string)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "vpn.example.com:10443", data["gateway"])
|
||||
assert.Equal(t, "fortinet", data["protocol"])
|
||||
assert.Equal(t, "saml", data["authtype"])
|
||||
assert.Equal(t, "jdoe", data["username"])
|
||||
assert.Equal(t, "contractors", data["usergroup"])
|
||||
assert.Equal(t, "9000", data["saml-port"])
|
||||
assert.Equal(t, "aa,bb", data["trusted-cert"])
|
||||
|
||||
// The secret agent supplies these at connect time, so NM must not store them.
|
||||
assert.Equal(t, "2", data["cookie-flags"])
|
||||
assert.Equal(t, "2", data["gateway-flags"])
|
||||
assert.Equal(t, "2", data["gwcert-flags"])
|
||||
})
|
||||
|
||||
t.Run("brackets an ipv6 gateway", func(t *testing.T) {
|
||||
settings := buildFortinetSettings(fortinetProfile{
|
||||
Host: "2001:db8::1",
|
||||
Port: 10443,
|
||||
SAML: true,
|
||||
}, "Work VPN", service)
|
||||
|
||||
data := settings["vpn"]["data"].(map[string]string)
|
||||
assert.Equal(t, "[2001:db8::1]:10443", data["gateway"])
|
||||
|
||||
host, port, err := splitGatewayHostPort(data["gateway"])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "2001:db8::1", host)
|
||||
assert.Equal(t, 10443, port)
|
||||
})
|
||||
|
||||
t.Run("routes the profile through the SAML auth path", func(t *testing.T) {
|
||||
settings := buildFortinetSettings(fortinetProfile{
|
||||
Host: "vpn.example.com",
|
||||
Port: 443,
|
||||
SAML: true,
|
||||
}, "Work VPN", service)
|
||||
|
||||
data := settings["vpn"]["data"].(map[string]string)
|
||||
assert.Equal(t, "fortinet_saml", detectVPNAuthAction(service, data))
|
||||
})
|
||||
|
||||
t.Run("omits optional fields", func(t *testing.T) {
|
||||
settings := buildFortinetSettings(fortinetProfile{
|
||||
Host: "vpn.example.com",
|
||||
Port: 443,
|
||||
SAML: true,
|
||||
}, "Work VPN", service)
|
||||
|
||||
data := settings["vpn"]["data"].(map[string]string)
|
||||
assert.NotContains(t, data, "username")
|
||||
assert.NotContains(t, data, "usergroup")
|
||||
assert.NotContains(t, data, "saml-port")
|
||||
assert.NotContains(t, data, "trusted-cert")
|
||||
assert.Equal(t, fortinetSAMLDefaultListenPort, fortinetSAMLListenPort(data))
|
||||
})
|
||||
|
||||
t.Run("builds a password profile when SAML is not selected", func(t *testing.T) {
|
||||
settings := buildFortinetSettings(fortinetProfile{
|
||||
Host: "vpn.example.com",
|
||||
Port: 443,
|
||||
Username: "jdoe",
|
||||
Password: "hunter2",
|
||||
}, "Work VPN", service)
|
||||
|
||||
data := settings["vpn"]["data"].(map[string]string)
|
||||
assert.Equal(t, "fortinet", data["protocol"])
|
||||
assert.Equal(t, "password", data["authtype"])
|
||||
assert.Equal(t, "jdoe", data["username"])
|
||||
assert.Equal(t, "openconnect_password", detectVPNAuthAction(service, data))
|
||||
|
||||
assert.NotContains(t, data, "saml-port")
|
||||
assert.NotContains(t, data, "cookie-flags")
|
||||
|
||||
// NM only keeps the password when it is not flagged agent-supplied.
|
||||
assert.Equal(t, "0", data["password-flags"])
|
||||
secrets, ok := settings["vpn"]["secrets"].(map[string]string)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "hunter2", secrets["password"])
|
||||
})
|
||||
|
||||
t.Run("leaves the password to the connect prompt when the config had none", func(t *testing.T) {
|
||||
settings := buildFortinetSettings(fortinetProfile{
|
||||
Host: "vpn.example.com",
|
||||
Port: 443,
|
||||
Username: "jdoe",
|
||||
}, "Work VPN", service)
|
||||
|
||||
data := settings["vpn"]["data"].(map[string]string)
|
||||
assert.Equal(t, "jdoe", data["username"])
|
||||
assert.Equal(t, "openconnect_password", detectVPNAuthAction(service, data))
|
||||
|
||||
assert.NotContains(t, data, "password-flags")
|
||||
assert.NotContains(t, settings["vpn"], "secrets")
|
||||
})
|
||||
|
||||
t.Run("prompts for everything when the config carried only a host", func(t *testing.T) {
|
||||
settings := buildFortinetSettings(fortinetProfile{
|
||||
Host: "vpn.example.com",
|
||||
Port: 443,
|
||||
}, "Work VPN", service)
|
||||
|
||||
data := settings["vpn"]["data"].(map[string]string)
|
||||
assert.NotContains(t, data, "username")
|
||||
assert.NotContains(t, settings["vpn"], "secrets")
|
||||
assert.Equal(t, "openconnect_password", detectVPNAuthAction(service, data))
|
||||
})
|
||||
}
|
||||
|
||||
func TestExchangeFortinetSAMLSession(t *testing.T) {
|
||||
newGateway := func(t *testing.T, handler http.HandlerFunc) (string, int, *x509.Certificate) {
|
||||
t.Helper()
|
||||
server := httptest.NewTLSServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
host, portStr, err := net.SplitHostPort(server.Listener.Addr().String())
|
||||
require.NoError(t, err)
|
||||
port, err := strconv.Atoi(portStr)
|
||||
require.NoError(t, err)
|
||||
|
||||
return host, port, server.Certificate()
|
||||
}
|
||||
|
||||
t.Run("returns the SVPNCOOKIE", func(t *testing.T) {
|
||||
var gotPath, gotUserAgent string
|
||||
host, port, cert := newGateway(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path + "?" + r.URL.RawQuery
|
||||
gotUserAgent = r.Header.Get("User-Agent")
|
||||
http.SetCookie(w, &http.Cookie{Name: "SVPNCOOKIE", Value: "cookie-value"})
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
cookie, err := exchangeFortinetSAMLSession(context.Background(), host, port, "session-42", certificatePin(cert))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "cookie-value", cookie)
|
||||
assert.Equal(t, "/remote/saml/auth_id?id=session-42", gotPath)
|
||||
assert.Equal(t, fortinetUserAgent, gotUserAgent)
|
||||
})
|
||||
|
||||
t.Run("fails when the gateway sets no cookie", func(t *testing.T) {
|
||||
host, port, cert := newGateway(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
_, err := exchangeFortinetSAMLSession(context.Background(), host, port, "session-42", certificatePin(cert))
|
||||
assert.ErrorIs(t, err, errFortinetSAMLNoCookie)
|
||||
})
|
||||
|
||||
t.Run("fails when the gateway rejects the session", func(t *testing.T) {
|
||||
host, port, cert := newGateway(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
})
|
||||
|
||||
_, err := exchangeFortinetSAMLSession(context.Background(), host, port, "session-42", certificatePin(cert))
|
||||
assert.ErrorContains(t, err, "HTTP 403")
|
||||
})
|
||||
|
||||
t.Run("refuses a certificate that does not match the pin", func(t *testing.T) {
|
||||
host, port, _ := newGateway(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: "SVPNCOOKIE", Value: "cookie-value"})
|
||||
})
|
||||
|
||||
otherPin := certificatePin(selfSignedCert(t))
|
||||
|
||||
_, err := exchangeFortinetSAMLSession(context.Background(), host, port, "session-42", otherPin)
|
||||
assert.ErrorContains(t, err, "does not match the trusted fingerprint")
|
||||
})
|
||||
}
|
||||
|
||||
func TestProbeGatewayCert(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer server.Close()
|
||||
|
||||
host, portStr, err := net.SplitHostPort(server.Listener.Addr().String())
|
||||
require.NoError(t, err)
|
||||
port, err := strconv.Atoi(portStr)
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := probeGatewayCert(context.Background(), host, port)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, cert.Verified, "the httptest CA is not in the system trust store")
|
||||
assert.Equal(t, certificatePin(server.Certificate()), cert.Pin)
|
||||
assert.Equal(t, certificateDigest(server.Certificate()), cert.Digest)
|
||||
}
|
||||
|
||||
func TestCertificateFingerprintsDifferPerCertificate(t *testing.T) {
|
||||
first := selfSignedCert(t)
|
||||
second := selfSignedCert(t)
|
||||
|
||||
assert.NotEqual(t, certificatePin(first), certificatePin(second))
|
||||
assert.NotEqual(t, certificateDigest(first), certificateDigest(second))
|
||||
assert.Equal(t, certificatePin(first), certificatePin(first))
|
||||
assert.Len(t, certificateDigest(first), 64, "digest is a hex sha256, matching openfortivpn trusted-cert")
|
||||
}
|
||||
|
||||
func selfSignedCert(t *testing.T) *x509.Certificate {
|
||||
t.Helper()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: "vpn.example.com"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
require.NoError(t, err)
|
||||
return cert
|
||||
}
|
||||
|
||||
func TestPinnedTLSConfigWithoutPinVerifiesNormally(t *testing.T) {
|
||||
cfg := pinnedTLSConfig("vpn.example.com", "")
|
||||
|
||||
assert.False(t, cfg.InsecureSkipVerify)
|
||||
assert.Nil(t, cfg.VerifyPeerCertificate)
|
||||
assert.Equal(t, "vpn.example.com", cfg.ServerName)
|
||||
}
|
||||
|
||||
func TestPinnedTLSConfigRejectsUnparsableCertificate(t *testing.T) {
|
||||
cfg := pinnedTLSConfig("vpn.example.com", "pin-sha256:whatever")
|
||||
require.NotNil(t, cfg.VerifyPeerCertificate)
|
||||
|
||||
assert.Error(t, cfg.VerifyPeerCertificate(nil, nil))
|
||||
assert.Error(t, cfg.VerifyPeerCertificate([][]byte{{0x01, 0x02}}, nil))
|
||||
}
|
||||
|
||||
func TestWriteFortinetSAMLPage(t *testing.T) {
|
||||
t.Run("the success page closes itself and degrades to a hint", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
writeFortinetSAMLPage(rec, "VPN login complete.", true)
|
||||
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, "VPN login complete.")
|
||||
assert.Contains(t, body, "window.close()")
|
||||
assert.Contains(t, body, "You can close this tab.")
|
||||
assert.Equal(t, "text/html; charset=utf-8", rec.Header().Get("Content-Type"))
|
||||
})
|
||||
|
||||
t.Run("the holding page does not close itself", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
writeFortinetSAMLPage(rec, "Waiting for the VPN login to complete.", false)
|
||||
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, "Waiting for the VPN login to complete.")
|
||||
assert.NotContains(t, body, "window.close()")
|
||||
})
|
||||
}
|
||||
@@ -350,6 +350,25 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OpenConnect authentication failed: %w", err)
|
||||
}
|
||||
case "fortinet_saml":
|
||||
if err := b.ensureOpenConnectAgentFlags(targetConn, vpnData); err != nil {
|
||||
return fmt.Errorf("failed to prepare Fortinet connection: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("[ConnectVPN] Fortinet SAML/SSO authentication required for %s (gateway=%s)", connName, vpnData["gateway"])
|
||||
|
||||
samlCtx, samlCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
openConnectAuth, err = b.authenticateFortinetSAML(samlCtx, fortinetAuthTarget{
|
||||
ConnName: connName,
|
||||
ConnUUID: targetUUID,
|
||||
ConnectionPath: string(targetConn.GetPath()),
|
||||
VpnService: vpnServiceType,
|
||||
Data: vpnData,
|
||||
})
|
||||
samlCancel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SAML authentication failed for the Fortinet gateway: %w", err)
|
||||
}
|
||||
case "gp_saml":
|
||||
gateway := vpnData["gateway"]
|
||||
protocol := vpnData["protocol"]
|
||||
@@ -446,8 +465,10 @@ func detectVPNAuthAction(serviceType string, data map[string]string) string {
|
||||
switch protocol {
|
||||
case "gp":
|
||||
return "gp_saml"
|
||||
case "fortinet":
|
||||
return "fortinet_saml"
|
||||
default:
|
||||
log.Infof("[VPN] External browser auth detected for protocol '%s' but only GlobalProtect (gp) is currently supported", protocol)
|
||||
log.Infof("[VPN] External browser auth detected for protocol '%s' but only GlobalProtect (gp) and Fortinet are currently supported", protocol)
|
||||
}
|
||||
}
|
||||
if protocol == "fortinet" && data["authtype"] == "password" {
|
||||
@@ -1087,6 +1108,8 @@ func (b *NetworkManagerBackend) saveVPNCredentials(creds *pendingVPNCredentials)
|
||||
log.Infof("[saveVPNCredentials] Saving username")
|
||||
}
|
||||
|
||||
maps.Copy(data, creds.PersistentData)
|
||||
|
||||
secs := map[string]string{}
|
||||
if len(creds.PersistentSecrets) > 0 {
|
||||
var stored map[string]map[string]dbus.Variant
|
||||
@@ -1253,14 +1276,14 @@ func (b *NetworkManagerBackend) ImportVPN(filePath string, name string) (*VPNImp
|
||||
}, nil
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
switch ext {
|
||||
case ".ovpn", ".conf":
|
||||
return b.importVPNWithNmcli(filePath, name)
|
||||
default:
|
||||
return b.importVPNWithNmcli(filePath, name)
|
||||
if cfg := readOpenfortivpnConfig(filePath); cfg != nil {
|
||||
if name == "" {
|
||||
name = vpnNameFromPath(filePath)
|
||||
}
|
||||
return b.importOpenfortivpnConfig(cfg, name)
|
||||
}
|
||||
|
||||
return b.importVPNWithNmcli(filePath, name)
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) importVPNWithNmcli(filePath string, name string) (*VPNImportResult, error) {
|
||||
|
||||
@@ -141,7 +141,7 @@ func TestNetworkManagerBackend_UpdateVPNConnectionState_EmptyUUID(t *testing.T)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectVPNAuthAction_FortinetPasswordOnly(t *testing.T) {
|
||||
func TestDetectVPNAuthAction_Fortinet(t *testing.T) {
|
||||
service := "org.freedesktop.NetworkManager.openconnect"
|
||||
|
||||
assert.Equal(t, "openconnect_password", detectVPNAuthAction(service, map[string]string{
|
||||
@@ -152,10 +152,14 @@ func TestDetectVPNAuthAction_FortinetPasswordOnly(t *testing.T) {
|
||||
"protocol": "anyconnect",
|
||||
"authtype": "password",
|
||||
}))
|
||||
assert.Empty(t, detectVPNAuthAction(service, map[string]string{
|
||||
assert.Equal(t, "fortinet_saml", detectVPNAuthAction(service, map[string]string{
|
||||
"protocol": "fortinet",
|
||||
"authtype": "saml",
|
||||
}))
|
||||
assert.Equal(t, "fortinet_saml", detectVPNAuthAction(service, map[string]string{
|
||||
"protocol": "fortinet",
|
||||
"saml-auth-method": "REDIRECT",
|
||||
}))
|
||||
}
|
||||
|
||||
func TestEnsureOpenConnectAgentFlags(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/Wifx/gonetworkmanager/v2"
|
||||
)
|
||||
|
||||
// openfortivpnConfig holds the parts of an openfortivpn config file that map
|
||||
// onto a NetworkManager openconnect profile. openfortivpn options that only
|
||||
// affect its own ppp handling are ignored.
|
||||
type openfortivpnConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
Realm string
|
||||
TrustedCerts []string
|
||||
SAML bool
|
||||
SAMLPort int
|
||||
}
|
||||
|
||||
func parseOpenfortivpnConfig(r io.Reader) (*openfortivpnConfig, error) {
|
||||
cfg := &openfortivpnConfig{Port: fortinetDefaultGatewayPort}
|
||||
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") {
|
||||
return nil, fmt.Errorf("not an openfortivpn config: section header %q", line)
|
||||
}
|
||||
|
||||
key, value, found := strings.Cut(line, "=")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
switch key {
|
||||
case "host":
|
||||
cfg.Host = value
|
||||
case "port":
|
||||
port, err := strconv.Atoi(value)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return nil, fmt.Errorf("invalid port in openfortivpn config: %q", value)
|
||||
}
|
||||
cfg.Port = port
|
||||
case "username":
|
||||
cfg.Username = value
|
||||
case "password":
|
||||
cfg.Password = value
|
||||
case "realm":
|
||||
cfg.Realm = value
|
||||
case "trusted-cert":
|
||||
if !isCertDigest(value) {
|
||||
log.Warnf("[openfortivpn] Ignoring malformed trusted-cert digest: %q", value)
|
||||
continue
|
||||
}
|
||||
cfg.TrustedCerts = append(cfg.TrustedCerts, strings.ToLower(value))
|
||||
case "saml-login":
|
||||
port, err := strconv.Atoi(value)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return nil, fmt.Errorf("invalid saml-login port in openfortivpn config: %q", value)
|
||||
}
|
||||
cfg.SAML = true
|
||||
cfg.SAMLPort = port
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.Host == "" {
|
||||
return nil, fmt.Errorf("not an openfortivpn config: no host")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func isCertDigest(value string) bool {
|
||||
if len(value) != 64 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// profile maps the config onto the settings a Fortinet profile is built from.
|
||||
// A "saml-login" line selects the browser login, and its password is dropped
|
||||
// because that path never reads one. openfortivpn options that only affect its
|
||||
// own ppp handling have no equivalent and are dropped too.
|
||||
func (c *openfortivpnConfig) profile() fortinetProfile {
|
||||
p := fortinetProfile{
|
||||
Host: c.Host,
|
||||
Port: c.Port,
|
||||
Username: c.Username,
|
||||
Realm: c.Realm,
|
||||
SAML: c.SAML,
|
||||
SAMLPort: c.SAMLPort,
|
||||
TrustedCerts: c.TrustedCerts,
|
||||
}
|
||||
if c.SAML {
|
||||
return p
|
||||
}
|
||||
|
||||
p.Password = c.Password
|
||||
return p
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) importOpenfortivpnConfig(cfg *openfortivpnConfig, name string) (*VPNImportResult, error) {
|
||||
serviceType, err := b.openConnectServiceType()
|
||||
if err != nil {
|
||||
return &VPNImportResult{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
s := b.settings
|
||||
if s == nil {
|
||||
s, err = gonetworkmanager.NewSettings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get settings: %w", err)
|
||||
}
|
||||
b.settings = s
|
||||
}
|
||||
|
||||
settingsMgr := s.(gonetworkmanager.Settings)
|
||||
conn, err := settingsMgr.AddConnection(buildFortinetSettings(cfg.profile(), name, serviceType))
|
||||
if err != nil {
|
||||
return &VPNImportResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create VPN profile: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
connUUID := ""
|
||||
if created, err := conn.GetSettings(); err == nil {
|
||||
if connMeta, ok := created["connection"]; ok {
|
||||
connUUID, _ = connMeta["uuid"].(string)
|
||||
}
|
||||
}
|
||||
|
||||
b.ListVPNProfiles()
|
||||
if b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
|
||||
return &VPNImportResult{
|
||||
Success: true,
|
||||
UUID: connUUID,
|
||||
Name: name,
|
||||
ServiceType: serviceType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// openConnectServiceType returns the installed openconnect plugin's service
|
||||
// type, since a Fortinet SAML profile is useless without it.
|
||||
func (b *NetworkManagerBackend) openConnectServiceType() (string, error) {
|
||||
plugins, err := b.ListVPNPlugins()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list VPN plugins: %w", err)
|
||||
}
|
||||
|
||||
for _, plugin := range plugins {
|
||||
if strings.Contains(plugin.ServiceType, "openconnect") {
|
||||
return plugin.ServiceType, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("importing an openfortivpn config requires the NetworkManager openconnect plugin")
|
||||
}
|
||||
|
||||
// readOpenfortivpnConfig returns nil when the file is not an openfortivpn
|
||||
// config, so the caller can fall back to the NetworkManager importers.
|
||||
func readOpenfortivpnConfig(filePath string) *openfortivpnConfig {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
cfg, err := parseOpenfortivpnConfig(file)
|
||||
if err != nil {
|
||||
log.Debugf("[openfortivpn] %s is not an openfortivpn config: %v", filePath, err)
|
||||
return nil
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func vpnNameFromPath(filePath string) string {
|
||||
base := filepath.Base(filePath)
|
||||
return strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseOpenfortivpnConfig(t *testing.T) {
|
||||
const digest = "aa11bb22cc33dd44ee55ff6600778899aabbccddeeff00112233445566778899"
|
||||
|
||||
t.Run("reads the fields that map onto a profile", func(t *testing.T) {
|
||||
cfg, err := parseOpenfortivpnConfig(strings.NewReader(`
|
||||
# work VPN
|
||||
host = vpn.example.com
|
||||
port = 10443
|
||||
username = jdoe
|
||||
realm = contractors
|
||||
trusted-cert = ` + digest + `
|
||||
saml-login = 9000
|
||||
set-dns = 0
|
||||
pppd-use-peerdns = 1
|
||||
`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "vpn.example.com", cfg.Host)
|
||||
assert.Equal(t, 10443, cfg.Port)
|
||||
assert.Equal(t, "jdoe", cfg.Username)
|
||||
assert.Equal(t, "contractors", cfg.Realm)
|
||||
assert.Equal(t, []string{digest}, cfg.TrustedCerts)
|
||||
assert.True(t, cfg.SAML)
|
||||
assert.Equal(t, 9000, cfg.SAMLPort)
|
||||
})
|
||||
|
||||
t.Run("reads the credentials the shipped template carries", func(t *testing.T) {
|
||||
cfg, err := parseOpenfortivpnConfig(strings.NewReader(`
|
||||
host = vpn.example.org
|
||||
port = 443
|
||||
username = vpnuser
|
||||
password = VPNpassw0rd
|
||||
`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "vpnuser", cfg.Username)
|
||||
assert.Equal(t, "VPNpassw0rd", cfg.Password)
|
||||
assert.False(t, cfg.SAML)
|
||||
})
|
||||
|
||||
t.Run("defaults the gateway port", func(t *testing.T) {
|
||||
cfg, err := parseOpenfortivpnConfig(strings.NewReader("host=vpn.example.com\n"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fortinetDefaultGatewayPort, cfg.Port)
|
||||
assert.False(t, cfg.SAML)
|
||||
assert.Zero(t, cfg.SAMLPort)
|
||||
})
|
||||
|
||||
t.Run("collects several trusted certs and lowercases them", func(t *testing.T) {
|
||||
other := strings.Repeat("bb", 32)
|
||||
cfg, err := parseOpenfortivpnConfig(strings.NewReader(
|
||||
"host = vpn.example.com\ntrusted-cert = " + strings.ToUpper(digest) + "\ntrusted-cert = " + other + "\n"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{digest, other}, cfg.TrustedCerts)
|
||||
})
|
||||
|
||||
t.Run("skips a malformed trusted cert", func(t *testing.T) {
|
||||
cfg, err := parseOpenfortivpnConfig(strings.NewReader("host = vpn.example.com\ntrusted-cert = nope\n"))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, cfg.TrustedCerts)
|
||||
})
|
||||
|
||||
t.Run("rejects files that are not openfortivpn configs", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{name: "wireguard", input: "[Interface]\nPrivateKey = abc\nAddress = 10.0.0.2/32\n"},
|
||||
{name: "openvpn", input: "client\ndev tun\nremote vpn.example.com 1194\n"},
|
||||
{name: "no host", input: "port = 443\nusername = jdoe\n"},
|
||||
{name: "empty", input: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := parseOpenfortivpnConfig(strings.NewReader(tt.input))
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects out of range ports", func(t *testing.T) {
|
||||
_, err := parseOpenfortivpnConfig(strings.NewReader("host = vpn.example.com\nport = 70000\n"))
|
||||
assert.ErrorContains(t, err, "invalid port")
|
||||
|
||||
_, err = parseOpenfortivpnConfig(strings.NewReader("host = vpn.example.com\nsaml-login = 0\n"))
|
||||
assert.ErrorContains(t, err, "invalid saml-login port")
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenfortivpnConfigProfile(t *testing.T) {
|
||||
t.Run("a saml-login config selects SAML and drops the password", func(t *testing.T) {
|
||||
cfg := &openfortivpnConfig{
|
||||
Host: "vpn.example.com",
|
||||
Port: 10443,
|
||||
Username: "jdoe",
|
||||
Password: "hunter2",
|
||||
Realm: "contractors",
|
||||
SAML: true,
|
||||
SAMLPort: 9000,
|
||||
TrustedCerts: []string{"aa", "bb"},
|
||||
}
|
||||
|
||||
assert.Equal(t, fortinetProfile{
|
||||
Host: "vpn.example.com",
|
||||
Port: 10443,
|
||||
Username: "jdoe",
|
||||
Realm: "contractors",
|
||||
SAML: true,
|
||||
SAMLPort: 9000,
|
||||
TrustedCerts: []string{"aa", "bb"},
|
||||
}, cfg.profile())
|
||||
})
|
||||
|
||||
t.Run("a config without saml-login carries its credentials", func(t *testing.T) {
|
||||
cfg := &openfortivpnConfig{
|
||||
Host: "vpn.example.com",
|
||||
Port: 443,
|
||||
Username: "jdoe",
|
||||
Password: "hunter2",
|
||||
}
|
||||
|
||||
assert.Equal(t, fortinetProfile{
|
||||
Host: "vpn.example.com",
|
||||
Port: 443,
|
||||
Username: "jdoe",
|
||||
Password: "hunter2",
|
||||
}, cfg.profile())
|
||||
})
|
||||
}
|
||||
|
||||
func TestReadOpenfortivpnConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
fortinet := filepath.Join(dir, "myvpn")
|
||||
require.NoError(t, os.WriteFile(fortinet, []byte("host = vpn.example.com\nport = 10443\n"), 0o600))
|
||||
|
||||
wireguard := filepath.Join(dir, "wg0.conf")
|
||||
require.NoError(t, os.WriteFile(wireguard, []byte("[Interface]\nPrivateKey = abc\n"), 0o600))
|
||||
|
||||
cfg := readOpenfortivpnConfig(fortinet)
|
||||
require.NotNil(t, cfg)
|
||||
assert.Equal(t, "vpn.example.com", cfg.Host)
|
||||
|
||||
assert.Nil(t, readOpenfortivpnConfig(wireguard))
|
||||
assert.Nil(t, readOpenfortivpnConfig(filepath.Join(dir, "missing")))
|
||||
}
|
||||
|
||||
func TestVPNNameFromPath(t *testing.T) {
|
||||
assert.Equal(t, "myvpn", vpnNameFromPath("/home/user/.config/openfortivpn/myvpn"))
|
||||
assert.Equal(t, "work", vpnNameFromPath("/home/user/work.conf"))
|
||||
}
|
||||
@@ -189,10 +189,12 @@ Singleton {
|
||||
}
|
||||
|
||||
function getFileFilter() {
|
||||
// openfortivpn configs are conventionally extensionless, so the browser
|
||||
// has to offer every file; core sniffs the contents on import.
|
||||
if (allExtensions.length === 0) {
|
||||
return ["*.ovpn", "*.conf"];
|
||||
return ["*.ovpn", "*.conf", "*"];
|
||||
}
|
||||
return allExtensions.map(e => "*" + e);
|
||||
return allExtensions.map(e => "*" + e).concat(["*"]);
|
||||
}
|
||||
|
||||
function getExtensionsForPlugin(serviceType) {
|
||||
|
||||
Reference in New Issue
Block a user