diff --git a/core/internal/server/network/agent_networkmanager.go b/core/internal/server/network/agent_networkmanager.go index 6c508be96..9597d84c6 100644 --- a/core/internal/server/network/agent_networkmanager.go +++ b/core/internal/server/network/agent_networkmanager.go @@ -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": diff --git a/core/internal/server/network/agent_networkmanager_test.go b/core/internal/server/network/agent_networkmanager_test.go index 897ffe1d6..c5f47be9d 100644 --- a/core/internal/server/network/agent_networkmanager_test.go +++ b/core/internal/server/network/agent_networkmanager_test.go @@ -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 { diff --git a/core/internal/server/network/backend_networkmanager.go b/core/internal/server/network/backend_networkmanager.go index bdc7c5f98..efba5dd6d 100644 --- a/core/internal/server/network/backend_networkmanager.go +++ b/core/internal/server/network/backend_networkmanager.go @@ -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 { diff --git a/core/internal/server/network/backend_networkmanager_fortinet_saml.go b/core/internal/server/network/backend_networkmanager_fortinet_saml.go new file mode 100644 index 000000000..bd9806f39 --- /dev/null +++ b/core/internal/server/network/backend_networkmanager_fortinet_saml.go @@ -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 = `` + + `DMS VPN` + + `

%s

%s` + +const fortinetSAMLCloseScript = `` + +// 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 +} diff --git a/core/internal/server/network/backend_networkmanager_fortinet_saml_test.go b/core/internal/server/network/backend_networkmanager_fortinet_saml_test.go new file mode 100644 index 000000000..ccd449d5d --- /dev/null +++ b/core/internal/server/network/backend_networkmanager_fortinet_saml_test.go @@ -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()") + }) +} diff --git a/core/internal/server/network/backend_networkmanager_vpn.go b/core/internal/server/network/backend_networkmanager_vpn.go index 843968299..947f655de 100644 --- a/core/internal/server/network/backend_networkmanager_vpn.go +++ b/core/internal/server/network/backend_networkmanager_vpn.go @@ -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) { diff --git a/core/internal/server/network/backend_networkmanager_vpn_test.go b/core/internal/server/network/backend_networkmanager_vpn_test.go index daf9cc9f4..a13ef3622 100644 --- a/core/internal/server/network/backend_networkmanager_vpn_test.go +++ b/core/internal/server/network/backend_networkmanager_vpn_test.go @@ -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) { diff --git a/core/internal/server/network/openfortivpn_import.go b/core/internal/server/network/openfortivpn_import.go new file mode 100644 index 000000000..5a760957f --- /dev/null +++ b/core/internal/server/network/openfortivpn_import.go @@ -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)) +} diff --git a/core/internal/server/network/openfortivpn_import_test.go b/core/internal/server/network/openfortivpn_import_test.go new file mode 100644 index 000000000..3870e282f --- /dev/null +++ b/core/internal/server/network/openfortivpn_import_test.go @@ -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")) +} diff --git a/quickshell/Services/VPNService.qml b/quickshell/Services/VPNService.qml index 6d1321169..150beaa46 100644 --- a/quickshell/Services/VPNService.qml +++ b/quickshell/Services/VPNService.qml @@ -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) {