mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-01 19:18:28 -04:00
fix(network): authenticate Fortinet VPNs natively (#2959)
* fix(network): authenticate Fortinet VPNs natively * cleanup and add test --------- Co-authored-by: Grant Mosha <grant.mosha@nida.go.tz> Co-authored-by: bbedward <bbedward@gmail.com>
This commit is contained in:
@@ -302,17 +302,17 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
}
|
}
|
||||||
a.backend.cachedVPNCredsMu.Unlock()
|
a.backend.cachedVPNCredsMu.Unlock()
|
||||||
|
|
||||||
a.backend.cachedGPSamlMu.Lock()
|
a.backend.cachedOpenConnectMu.Lock()
|
||||||
cachedGPSaml := a.backend.cachedGPSamlCookie
|
cachedOpenConnect := a.backend.cachedOpenConnectAuth
|
||||||
if cachedGPSaml != nil && cachedGPSaml.ConnectionUUID == connUuid {
|
if cachedOpenConnect != nil && cachedOpenConnect.ConnectionUUID == connUuid {
|
||||||
a.backend.cachedGPSamlCookie = nil
|
a.backend.cachedOpenConnectAuth = nil
|
||||||
a.backend.cachedGPSamlMu.Unlock()
|
a.backend.cachedOpenConnectMu.Unlock()
|
||||||
|
|
||||||
log.Infof("[SecretAgent] Using cached GlobalProtect SAML cookie for %s", connUuid)
|
log.Infof("[SecretAgent] Using cached OpenConnect authentication for %s", connUuid)
|
||||||
|
|
||||||
return buildGPSamlSecretsResponse(settingName, cachedGPSaml.Cookie, cachedGPSaml.Host, cachedGPSaml.Fingerprint), nil
|
return buildOpenConnectSecretsResponse(settingName, cachedOpenConnect.Cookie, cachedOpenConnect.Host, cachedOpenConnect.Fingerprint), nil
|
||||||
}
|
}
|
||||||
a.backend.cachedGPSamlMu.Unlock()
|
a.backend.cachedOpenConnectMu.Unlock()
|
||||||
|
|
||||||
if len(fields) == 1 && fields[0] == "gp-saml" {
|
if len(fields) == 1 && fields[0] == "gp-saml" {
|
||||||
gateway := ""
|
gateway := ""
|
||||||
@@ -347,17 +347,17 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
|
|
||||||
log.Infof("[SecretAgent] GlobalProtect SAML authentication successful, returning cookie to NetworkManager")
|
log.Infof("[SecretAgent] GlobalProtect SAML authentication successful, returning cookie to NetworkManager")
|
||||||
|
|
||||||
a.backend.cachedGPSamlMu.Lock()
|
a.backend.cachedOpenConnectMu.Lock()
|
||||||
a.backend.cachedGPSamlCookie = &cachedGPSamlCookie{
|
a.backend.cachedOpenConnectAuth = &cachedOpenConnectAuth{
|
||||||
ConnectionUUID: connUuid,
|
ConnectionUUID: connUuid,
|
||||||
Cookie: authResult.Cookie,
|
Cookie: authResult.Cookie,
|
||||||
Host: authResult.Host,
|
Host: authResult.Host,
|
||||||
User: authResult.User,
|
User: authResult.User,
|
||||||
Fingerprint: authResult.Fingerprint,
|
Fingerprint: authResult.Fingerprint,
|
||||||
}
|
}
|
||||||
a.backend.cachedGPSamlMu.Unlock()
|
a.backend.cachedOpenConnectMu.Unlock()
|
||||||
|
|
||||||
return buildGPSamlSecretsResponse(settingName, authResult.Cookie, authResult.Host, authResult.Fingerprint), nil
|
return buildOpenConnectSecretsResponse(settingName, authResult.Cookie, authResult.Host, authResult.Fingerprint), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -987,7 +987,7 @@ func buildWiFiSecretsResponse(settingName string, secrets map[string]string) nmS
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildGPSamlSecretsResponse(settingName, cookie, host, fingerprint string) nmSettingMap {
|
func buildOpenConnectSecretsResponse(settingName, cookie, host, fingerprint string) nmSettingMap {
|
||||||
out := nmSettingMap{}
|
out := nmSettingMap{}
|
||||||
vpnSec := nmVariantMap{}
|
vpnSec := nmVariantMap{}
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ func TestNeedsExternalBrowserAuth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildGPSamlSecretsResponse(t *testing.T) {
|
func TestBuildOpenConnectSecretsResponse(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
settingName string
|
settingName string
|
||||||
@@ -155,7 +155,7 @@ func TestBuildGPSamlSecretsResponse(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := buildGPSamlSecretsResponse(tt.settingName, tt.cookie, tt.host, tt.fingerprint)
|
result := buildOpenConnectSecretsResponse(tt.settingName, tt.cookie, tt.host, tt.fingerprint)
|
||||||
|
|
||||||
assert.NotNil(t, result)
|
assert.NotNil(t, result)
|
||||||
assert.Contains(t, result, tt.settingName)
|
assert.Contains(t, result, tt.settingName)
|
||||||
|
|||||||
@@ -80,16 +80,16 @@ type NetworkManagerBackend struct {
|
|||||||
|
|
||||||
hotspotPendingDevice string
|
hotspotPendingDevice string
|
||||||
|
|
||||||
pendingVPNSave *pendingVPNCredentials
|
pendingVPNSave *pendingVPNCredentials
|
||||||
pendingVPNSaveMu sync.Mutex
|
pendingVPNSaveMu sync.Mutex
|
||||||
cachedVPNCreds *cachedVPNCredentials
|
cachedVPNCreds *cachedVPNCredentials
|
||||||
cachedVPNCredsMu sync.Mutex
|
cachedVPNCredsMu sync.Mutex
|
||||||
cachedPKCS11PIN *cachedPKCS11PIN
|
cachedPKCS11PIN *cachedPKCS11PIN
|
||||||
cachedPKCS11Mu sync.Mutex
|
cachedPKCS11Mu sync.Mutex
|
||||||
cachedGPSamlCookie *cachedGPSamlCookie
|
cachedOpenConnectAuth *cachedOpenConnectAuth
|
||||||
cachedGPSamlMu sync.Mutex
|
cachedOpenConnectMu sync.Mutex
|
||||||
cachedWiFiSecret *cachedWiFiSecret
|
cachedWiFiSecret *cachedWiFiSecret
|
||||||
cachedWiFiSecretMu sync.Mutex
|
cachedWiFiSecretMu sync.Mutex
|
||||||
|
|
||||||
onStateChange func()
|
onStateChange func()
|
||||||
}
|
}
|
||||||
@@ -100,8 +100,9 @@ type pendingVPNCredentials struct {
|
|||||||
Password string
|
Password string
|
||||||
// Secrets holds all VPN secret fields keyed by name (e.g. "cert-pass");
|
// Secrets holds all VPN secret fields keyed by name (e.g. "cert-pass");
|
||||||
// falls back to Password under the "password" key when empty.
|
// falls back to Password under the "password" key when empty.
|
||||||
Secrets map[string]string
|
Secrets map[string]string
|
||||||
SavePassword bool
|
PersistentSecrets map[string]string
|
||||||
|
SavePassword bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type cachedVPNCredentials struct {
|
type cachedVPNCredentials struct {
|
||||||
@@ -124,7 +125,7 @@ type cachedWiFiSecret struct {
|
|||||||
Secrets map[string]string
|
Secrets map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
type cachedGPSamlCookie struct {
|
type cachedOpenConnectAuth struct {
|
||||||
ConnectionUUID string
|
ConnectionUUID string
|
||||||
Cookie string
|
Cookie string
|
||||||
Host string
|
Host string
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package network
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -10,16 +11,29 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type gpSamlAuthResult struct {
|
type openConnectAuthResult struct {
|
||||||
Cookie string
|
Cookie string
|
||||||
Host string
|
Host string
|
||||||
User string
|
User string
|
||||||
Fingerprint string
|
Fingerprint string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type openConnectAuthError struct {
|
||||||
|
cause error
|
||||||
|
serverCert string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *openConnectAuthError) Error() string {
|
||||||
|
return fmt.Sprintf("openconnect --authenticate failed: %v", e.cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *openConnectAuthError) Unwrap() error {
|
||||||
|
return e.cause
|
||||||
|
}
|
||||||
|
|
||||||
// runGlobalProtectSAMLAuth handles GlobalProtect SAML/SSO authentication using gp-saml-gui.
|
// runGlobalProtectSAMLAuth handles GlobalProtect SAML/SSO authentication using gp-saml-gui.
|
||||||
// Only supports protocol=gp. Other protocols need their own implementations.
|
// Only supports protocol=gp. Other protocols need their own implementations.
|
||||||
func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, gateway, protocol string) (*gpSamlAuthResult, error) {
|
func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, gateway, protocol string) (*openConnectAuthResult, error) {
|
||||||
if gateway == "" {
|
if gateway == "" {
|
||||||
return nil, fmt.Errorf("GP SAML auth: gateway is empty")
|
return nil, fmt.Errorf("GP SAML auth: gateway is empty")
|
||||||
}
|
}
|
||||||
@@ -63,7 +77,7 @@ func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, ga
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
result := &gpSamlAuthResult{Host: gateway}
|
result := &openConnectAuthResult{Host: gateway}
|
||||||
var allOutput []string
|
var allOutput []string
|
||||||
|
|
||||||
scanner := bufio.NewScanner(stdout)
|
scanner := bufio.NewScanner(stdout)
|
||||||
@@ -117,13 +131,8 @@ func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, ga
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user string) (*gpSamlAuthResult, error) {
|
func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user string) (*openConnectAuthResult, error) {
|
||||||
ocPath, err := exec.LookPath("openconnect")
|
return runOpenConnectAuthenticate(ctx, []string{
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("openconnect not found: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
args := []string{
|
|
||||||
"--protocol=gp",
|
"--protocol=gp",
|
||||||
"--usergroup=gateway:prelogin-cookie",
|
"--usergroup=gateway:prelogin-cookie",
|
||||||
"--user=" + user,
|
"--user=" + user,
|
||||||
@@ -131,18 +140,83 @@ func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user
|
|||||||
"--allow-insecure-crypto",
|
"--allow-insecure-crypto",
|
||||||
"--authenticate",
|
"--authenticate",
|
||||||
gateway,
|
gateway,
|
||||||
|
}, preloginCookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runOpenConnectPasswordAuth(
|
||||||
|
ctx context.Context,
|
||||||
|
data map[string]string,
|
||||||
|
username, password, serverCert string,
|
||||||
|
) (*openConnectAuthResult, error) {
|
||||||
|
if data["protocol"] != "fortinet" {
|
||||||
|
return nil, fmt.Errorf("only Fortinet password authentication is supported")
|
||||||
|
}
|
||||||
|
gateway := data["gateway"]
|
||||||
|
if gateway == "" {
|
||||||
|
return nil, fmt.Errorf("OpenConnect gateway is empty")
|
||||||
|
}
|
||||||
|
if username == "" || password == "" {
|
||||||
|
return nil, fmt.Errorf("OpenConnect username and password are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"--protocol=fortinet",
|
||||||
|
"--user=" + username,
|
||||||
|
"--passwd-on-stdin",
|
||||||
|
"--non-inter",
|
||||||
|
}
|
||||||
|
if usergroup := data["usergroup"]; usergroup != "" {
|
||||||
|
args = append(args, "--usergroup="+usergroup)
|
||||||
|
}
|
||||||
|
if serverCert != "" {
|
||||||
|
args = append(args, "--servercert="+serverCert)
|
||||||
|
}
|
||||||
|
args = append(args, "--authenticate", gateway)
|
||||||
|
|
||||||
|
result, err := runOpenConnectAuthenticate(ctx, args, password)
|
||||||
|
if err == nil {
|
||||||
|
result.Host = gateway
|
||||||
|
if result.Fingerprint == "" {
|
||||||
|
result.Fingerprint = serverCert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func runOpenConnectAuthenticate(ctx context.Context, args []string, secret string) (*openConnectAuthResult, error) {
|
||||||
|
ocPath, err := exec.LookPath("openconnect")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("openconnect not found: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, ocPath, args...)
|
cmd := exec.CommandContext(ctx, ocPath, args...)
|
||||||
cmd.Stdin = strings.NewReader(preloginCookie)
|
cmd.Stdin = strings.NewReader(secret + "\n")
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
|
result := parseOpenConnectAuthenticateOutput(string(output))
|
||||||
|
serverCert := suggestedOpenConnectServerCert(string(output))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("openconnect --authenticate failed: %w\noutput: %s", err, string(output))
|
if ctx.Err() != nil {
|
||||||
|
return nil, fmt.Errorf("openconnect authentication timed out or was cancelled: %w", ctx.Err())
|
||||||
|
}
|
||||||
|
return nil, &openConnectAuthError{cause: err, serverCert: serverCert}
|
||||||
|
}
|
||||||
|
if result.Cookie == "" {
|
||||||
|
return nil, &openConnectAuthError{
|
||||||
|
cause: errors.New("no COOKIE in command output"),
|
||||||
|
serverCert: serverCert,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &gpSamlAuthResult{}
|
log.Infof("[OpenConnect] Authentication successful: cookie_len=%d, host=%s, has_fingerprint=%v",
|
||||||
for _, line := range strings.Split(string(output), "\n") {
|
len(result.Cookie), result.Host, result.Fingerprint != "")
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOpenConnectAuthenticateOutput(output string) *openConnectAuthResult {
|
||||||
|
result := &openConnectAuthResult{}
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(line, "COOKIE="):
|
case strings.HasPrefix(line, "COOKIE="):
|
||||||
@@ -158,15 +232,7 @@ func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return result
|
||||||
if result.Cookie == "" {
|
|
||||||
return nil, fmt.Errorf("no COOKIE in openconnect --authenticate output: %s", string(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("[GP-SAML] openconnect --authenticate: cookie_len=%d, host=%s, fingerprint=%s",
|
|
||||||
len(result.Cookie), result.Host, result.Fingerprint)
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func unshellQuote(s string) string {
|
func unshellQuote(s string) string {
|
||||||
@@ -179,7 +245,7 @@ func unshellQuote(s string) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseGPSamlFromCommandLine(line string, result *gpSamlAuthResult) {
|
func parseGPSamlFromCommandLine(line string, result *openConnectAuthResult) {
|
||||||
if !strings.Contains(line, "openconnect") {
|
if !strings.Contains(line, "openconnect") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package network
|
package network
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -71,7 +75,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
line string
|
line string
|
||||||
initialResult *gpSamlAuthResult
|
initialResult *openConnectAuthResult
|
||||||
expectedCookie string
|
expectedCookie string
|
||||||
expectedUser string
|
expectedUser string
|
||||||
expectedFP string
|
expectedFP string
|
||||||
@@ -79,7 +83,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "full openconnect command",
|
name: "full openconnect command",
|
||||||
line: "openconnect --protocol=gp --cookie=AUTH123 --servercert=pin-sha256:ABC --user=john",
|
line: "openconnect --protocol=gp --cookie=AUTH123 --servercert=pin-sha256:ABC --user=john",
|
||||||
initialResult: &gpSamlAuthResult{},
|
initialResult: &openConnectAuthResult{},
|
||||||
expectedCookie: "AUTH123",
|
expectedCookie: "AUTH123",
|
||||||
expectedUser: "john",
|
expectedUser: "john",
|
||||||
expectedFP: "pin-sha256:ABC",
|
expectedFP: "pin-sha256:ABC",
|
||||||
@@ -87,7 +91,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "with equals signs in cookie",
|
name: "with equals signs in cookie",
|
||||||
line: "openconnect --cookie=authcookie=xyz123&portal=GATE --user=jane",
|
line: "openconnect --cookie=authcookie=xyz123&portal=GATE --user=jane",
|
||||||
initialResult: &gpSamlAuthResult{},
|
initialResult: &openConnectAuthResult{},
|
||||||
expectedCookie: "authcookie=xyz123&portal=GATE",
|
expectedCookie: "authcookie=xyz123&portal=GATE",
|
||||||
expectedUser: "jane",
|
expectedUser: "jane",
|
||||||
expectedFP: "",
|
expectedFP: "",
|
||||||
@@ -95,7 +99,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "non-openconnect line",
|
name: "non-openconnect line",
|
||||||
line: "some other output",
|
line: "some other output",
|
||||||
initialResult: &gpSamlAuthResult{},
|
initialResult: &openConnectAuthResult{},
|
||||||
expectedCookie: "",
|
expectedCookie: "",
|
||||||
expectedUser: "",
|
expectedUser: "",
|
||||||
expectedFP: "",
|
expectedFP: "",
|
||||||
@@ -103,7 +107,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "preserves existing values",
|
name: "preserves existing values",
|
||||||
line: "openconnect --user=newuser",
|
line: "openconnect --user=newuser",
|
||||||
initialResult: &gpSamlAuthResult{Cookie: "existing", Fingerprint: "existing-fp"},
|
initialResult: &openConnectAuthResult{Cookie: "existing", Fingerprint: "existing-fp"},
|
||||||
expectedCookie: "existing",
|
expectedCookie: "existing",
|
||||||
expectedUser: "newuser",
|
expectedUser: "newuser",
|
||||||
expectedFP: "existing-fp",
|
expectedFP: "existing-fp",
|
||||||
@@ -111,7 +115,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "only updates empty fields",
|
name: "only updates empty fields",
|
||||||
line: "openconnect --cookie=NEW --user=NEW",
|
line: "openconnect --cookie=NEW --user=NEW",
|
||||||
initialResult: &gpSamlAuthResult{Cookie: "OLD"},
|
initialResult: &openConnectAuthResult{Cookie: "OLD"},
|
||||||
expectedCookie: "OLD",
|
expectedCookie: "OLD",
|
||||||
expectedUser: "NEW",
|
expectedUser: "NEW",
|
||||||
expectedFP: "",
|
expectedFP: "",
|
||||||
@@ -119,7 +123,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "real gp-saml-gui output",
|
name: "real gp-saml-gui output",
|
||||||
line: "openconnect --protocol=gp --user=john.doe@example.com --os=linux-64 --usergroup=gateway:prelogin-cookie --passwd-on-stdin",
|
line: "openconnect --protocol=gp --user=john.doe@example.com --os=linux-64 --usergroup=gateway:prelogin-cookie --passwd-on-stdin",
|
||||||
initialResult: &gpSamlAuthResult{},
|
initialResult: &openConnectAuthResult{},
|
||||||
expectedCookie: "",
|
expectedCookie: "",
|
||||||
expectedUser: "john.doe@example.com",
|
expectedUser: "john.doe@example.com",
|
||||||
expectedFP: "",
|
expectedFP: "",
|
||||||
@@ -127,7 +131,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "with server cert flag",
|
name: "with server cert flag",
|
||||||
line: "openconnect --servercert=pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE= vpn.example.com",
|
line: "openconnect --servercert=pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE= vpn.example.com",
|
||||||
initialResult: &gpSamlAuthResult{},
|
initialResult: &openConnectAuthResult{},
|
||||||
expectedCookie: "",
|
expectedCookie: "",
|
||||||
expectedUser: "",
|
expectedUser: "",
|
||||||
expectedFP: "pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE=",
|
expectedFP: "pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE=",
|
||||||
@@ -158,7 +162,7 @@ func TestParseGPSamlFromCommandLine_MultipleLines(t *testing.T) {
|
|||||||
"",
|
"",
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &gpSamlAuthResult{}
|
result := &openConnectAuthResult{}
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
parseGPSamlFromCommandLine(line, result)
|
parseGPSamlFromCommandLine(line, result)
|
||||||
}
|
}
|
||||||
@@ -167,3 +171,19 @@ func TestParseGPSamlFromCommandLine_MultipleLines(t *testing.T) {
|
|||||||
assert.Empty(t, result.Cookie, "cookie should not be parsed from command line")
|
assert.Empty(t, result.Cookie, "cookie should not be parsed from command line")
|
||||||
assert.Empty(t, result.Fingerprint)
|
assert.Empty(t, result.Fingerprint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunOpenConnectAuthenticateSanitizesFailure(t *testing.T) {
|
||||||
|
binDir := t.TempDir()
|
||||||
|
openConnectPath := filepath.Join(binDir, "openconnect")
|
||||||
|
script := "#!/bin/sh\nprintf '%s\\n' 'Cookie: should-not-leak' 'Add --servercert pin-sha256:TEST-FINGERPRINT' >&2\nexit 1\n"
|
||||||
|
assert.NoError(t, os.WriteFile(openConnectPath, []byte(script), 0o755))
|
||||||
|
t.Setenv("PATH", binDir)
|
||||||
|
|
||||||
|
_, err := runOpenConnectAuthenticate(context.Background(), []string{"--authenticate", "vpn.example.test"}, "password")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.NotContains(t, err.Error(), "should-not-leak")
|
||||||
|
|
||||||
|
var authErr *openConnectAuthError
|
||||||
|
assert.True(t, errors.As(err, &authErr))
|
||||||
|
assert.Equal(t, "pin-sha256:TEST-FINGERPRINT", authErr.serverCert)
|
||||||
|
}
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
|
|||||||
}
|
}
|
||||||
|
|
||||||
authAction := detectVPNAuthAction(vpnServiceType, vpnData)
|
authAction := detectVPNAuthAction(vpnServiceType, vpnData)
|
||||||
|
var openConnectAuth *openConnectAuthResult
|
||||||
|
|
||||||
switch authAction {
|
switch authAction {
|
||||||
case "openvpn_username":
|
case "openvpn_username":
|
||||||
@@ -335,6 +336,19 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
|
|||||||
if err := b.handleOpenVPNUsernameAuth(targetConn, connName, targetUUID, vpnServiceType); err != nil {
|
if err := b.handleOpenVPNUsernameAuth(targetConn, connName, targetUUID, vpnServiceType); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
case "openconnect_password":
|
||||||
|
if err := b.ensureOpenConnectAgentFlags(targetConn, vpnData); err != nil {
|
||||||
|
return fmt.Errorf("failed to prepare OpenConnect connection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authCtx, authCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
openConnectAuth, err = b.handleOpenConnectPasswordAuth(
|
||||||
|
authCtx, targetConn, connName, targetUUID, vpnServiceType, vpnData,
|
||||||
|
)
|
||||||
|
authCancel()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("OpenConnect authentication failed: %w", err)
|
||||||
|
}
|
||||||
case "gp_saml":
|
case "gp_saml":
|
||||||
gateway := vpnData["gateway"]
|
gateway := vpnData["gateway"]
|
||||||
protocol := vpnData["protocol"]
|
protocol := vpnData["protocol"]
|
||||||
@@ -345,7 +359,7 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
|
|||||||
log.Infof("[ConnectVPN] GlobalProtect SAML/SSO authentication required for %s (gateway=%s)", connName, gateway)
|
log.Infof("[ConnectVPN] GlobalProtect SAML/SSO authentication required for %s (gateway=%s)", connName, gateway)
|
||||||
|
|
||||||
samlCtx, samlCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
samlCtx, samlCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
authResult, err := b.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol)
|
openConnectAuth, err = b.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol)
|
||||||
samlCancel()
|
samlCancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg := err.Error()
|
errMsg := err.Error()
|
||||||
@@ -363,16 +377,6 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
b.cachedGPSamlMu.Lock()
|
|
||||||
b.cachedGPSamlCookie = &cachedGPSamlCookie{
|
|
||||||
ConnectionUUID: targetUUID,
|
|
||||||
Cookie: authResult.Cookie,
|
|
||||||
Host: authResult.Host,
|
|
||||||
User: authResult.User,
|
|
||||||
Fingerprint: authResult.Fingerprint,
|
|
||||||
}
|
|
||||||
b.cachedGPSamlMu.Unlock()
|
|
||||||
|
|
||||||
if err := targetConn.ClearSecrets(); err != nil {
|
if err := targetConn.ClearSecrets(); err != nil {
|
||||||
log.Warnf("[ConnectVPN] ClearSecrets failed (non-fatal): %v", err)
|
log.Warnf("[ConnectVPN] ClearSecrets failed (non-fatal): %v", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -382,6 +386,19 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
|
|||||||
log.Infof("[ConnectVPN] GlobalProtect SAML cookie cached for %s, proceeding with activation", connName)
|
log.Infof("[ConnectVPN] GlobalProtect SAML cookie cached for %s, proceeding with activation", connName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if openConnectAuth != nil {
|
||||||
|
b.cachedOpenConnectMu.Lock()
|
||||||
|
b.cachedOpenConnectAuth = &cachedOpenConnectAuth{
|
||||||
|
ConnectionUUID: targetUUID,
|
||||||
|
Cookie: openConnectAuth.Cookie,
|
||||||
|
Host: openConnectAuth.Host,
|
||||||
|
User: openConnectAuth.User,
|
||||||
|
Fingerprint: openConnectAuth.Fingerprint,
|
||||||
|
}
|
||||||
|
b.cachedOpenConnectMu.Unlock()
|
||||||
|
log.Infof("[ConnectVPN] OpenConnect authentication cached for %s, proceeding with activation", connName)
|
||||||
|
}
|
||||||
|
|
||||||
b.stateMutex.Lock()
|
b.stateMutex.Lock()
|
||||||
b.state.IsConnectingVPN = true
|
b.state.IsConnectingVPN = true
|
||||||
b.state.ConnectingVPNUUID = targetUUID
|
b.state.ConnectingVPNUUID = targetUUID
|
||||||
@@ -394,6 +411,13 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
|
|||||||
nm := b.nmConn.(gonetworkmanager.NetworkManager)
|
nm := b.nmConn.(gonetworkmanager.NetworkManager)
|
||||||
_, err = nm.ActivateConnection(targetConn, nil, nil)
|
_, err = nm.ActivateConnection(targetConn, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
b.cachedOpenConnectMu.Lock()
|
||||||
|
b.cachedOpenConnectAuth = nil
|
||||||
|
b.cachedOpenConnectMu.Unlock()
|
||||||
|
b.pendingVPNSaveMu.Lock()
|
||||||
|
b.pendingVPNSave = nil
|
||||||
|
b.pendingVPNSaveMu.Unlock()
|
||||||
|
|
||||||
b.stateMutex.Lock()
|
b.stateMutex.Lock()
|
||||||
b.state.IsConnectingVPN = false
|
b.state.IsConnectingVPN = false
|
||||||
b.state.ConnectingVPNUUID = ""
|
b.state.ConnectingVPNUUID = ""
|
||||||
@@ -425,6 +449,9 @@ func detectVPNAuthAction(serviceType string, data map[string]string) string {
|
|||||||
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) is currently supported", protocol)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if protocol == "fortinet" && data["authtype"] == "password" {
|
||||||
|
return "openconnect_password"
|
||||||
|
}
|
||||||
case strings.Contains(serviceType, "openvpn"):
|
case strings.Contains(serviceType, "openvpn"):
|
||||||
connType := data["connection-type"]
|
connType := data["connection-type"]
|
||||||
username := data["username"]
|
username := data["username"]
|
||||||
@@ -435,6 +462,200 @@ func detectVPNAuthAction(serviceType string, data map[string]string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setOpenConnectAgentFlags(data map[string]string) bool {
|
||||||
|
changed := false
|
||||||
|
for _, field := range []string{"cookie", "gateway", "gwcert"} {
|
||||||
|
key := field + "-flags"
|
||||||
|
if data[key] != "2" {
|
||||||
|
data[key] = "2"
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *NetworkManagerBackend) ensureOpenConnectAgentFlags(conn gonetworkmanager.Connection, data map[string]string) error {
|
||||||
|
if !setOpenConnectAgentFlags(data) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if b.dbusConn == nil {
|
||||||
|
return fmt.Errorf("NetworkManager D-Bus connection is unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
connObj := b.dbusConn.Object("org.freedesktop.NetworkManager", conn.GetPath())
|
||||||
|
var existingSettings map[string]map[string]dbus.Variant
|
||||||
|
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existingSettings); err != nil {
|
||||||
|
return fmt.Errorf("failed to get connection settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vpn, ok := existingSettings["vpn"]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("VPN settings are missing")
|
||||||
|
}
|
||||||
|
vpn["data"] = dbus.MakeVariant(data)
|
||||||
|
|
||||||
|
var stored map[string]map[string]dbus.Variant
|
||||||
|
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSecrets", 0, "vpn").Store(&stored); err != nil {
|
||||||
|
return fmt.Errorf("failed to preserve VPN secrets: %w", err)
|
||||||
|
}
|
||||||
|
if storedVPN, ok := stored["vpn"]; ok {
|
||||||
|
if secrets, ok := storedVPN["secrets"]; ok {
|
||||||
|
vpn["secrets"] = secrets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := map[string]map[string]dbus.Variant{"vpn": vpn}
|
||||||
|
if connection, ok := existingSettings["connection"]; ok {
|
||||||
|
settings["connection"] = connection
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]dbus.Variant
|
||||||
|
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Update2", 0,
|
||||||
|
settings, uint32(0x1), map[string]dbus.Variant{}).Store(&result); err != nil {
|
||||||
|
return fmt.Errorf("failed to set NetworkManager secret-agent flags: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *NetworkManagerBackend) handleOpenConnectPasswordAuth(
|
||||||
|
ctx context.Context,
|
||||||
|
targetConn gonetworkmanager.Connection,
|
||||||
|
connName, targetUUID, vpnServiceType string,
|
||||||
|
data map[string]string,
|
||||||
|
) (*openConnectAuthResult, error) {
|
||||||
|
username := data["username"]
|
||||||
|
secrets := map[string]string{}
|
||||||
|
if stored, err := targetConn.GetSecrets("vpn"); err == nil {
|
||||||
|
if vpn, ok := stored["vpn"]; ok {
|
||||||
|
if saved, ok := vpn["secrets"].(map[string]string); ok {
|
||||||
|
secrets = saved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
password := secrets["password"]
|
||||||
|
serverCert := secrets["certificate:"+data["gateway"]]
|
||||||
|
if serverCert == "" {
|
||||||
|
serverCert = secrets["gwcert"]
|
||||||
|
}
|
||||||
|
|
||||||
|
var reply PromptReply
|
||||||
|
if username == "" || password == "" {
|
||||||
|
if b.promptBroker == nil {
|
||||||
|
return nil, fmt.Errorf("password authentication requires an interactive prompt")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := []string{}
|
||||||
|
fieldsInfo := []FieldInfo{}
|
||||||
|
if username == "" {
|
||||||
|
fields = append(fields, "username")
|
||||||
|
fieldsInfo = append(fieldsInfo, FieldInfo{Name: "username", Label: "Username", IsSecret: false})
|
||||||
|
}
|
||||||
|
if password == "" {
|
||||||
|
fields = append(fields, "password")
|
||||||
|
fieldsInfo = append(fieldsInfo, FieldInfo{Name: "password", Label: "Password", IsSecret: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := b.promptBroker.Ask(ctx, PromptRequest{
|
||||||
|
Name: connName,
|
||||||
|
ConnType: "vpn",
|
||||||
|
VpnService: vpnServiceType,
|
||||||
|
SettingName: "vpn",
|
||||||
|
Fields: fields,
|
||||||
|
FieldsInfo: fieldsInfo,
|
||||||
|
Reason: "required",
|
||||||
|
ConnectionId: connName,
|
||||||
|
ConnectionUuid: targetUUID,
|
||||||
|
ConnectionPath: string(targetConn.GetPath()),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to request credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, err = b.promptBroker.Wait(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("credentials prompt failed: %w", err)
|
||||||
|
}
|
||||||
|
if username == "" {
|
||||||
|
username = reply.Secrets["username"]
|
||||||
|
}
|
||||||
|
if password == "" {
|
||||||
|
password = reply.Secrets["password"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auth, err := runOpenConnectPasswordAuth(ctx, data, username, password, serverCert)
|
||||||
|
persistentSecrets := map[string]string{}
|
||||||
|
var authErr *openConnectAuthError
|
||||||
|
if err != nil && errors.As(err, &authErr) && authErr.serverCert != "" && authErr.serverCert != serverCert {
|
||||||
|
if b.promptBroker == nil {
|
||||||
|
return nil, fmt.Errorf("VPN server certificate is untrusted: %s", authErr.serverCert)
|
||||||
|
}
|
||||||
|
|
||||||
|
reason := "server-certificate"
|
||||||
|
if serverCert != "" {
|
||||||
|
reason = "server-certificate-changed"
|
||||||
|
}
|
||||||
|
|
||||||
|
token, promptErr := b.promptBroker.Ask(ctx, PromptRequest{
|
||||||
|
Name: connName,
|
||||||
|
ConnType: "vpn",
|
||||||
|
VpnService: vpnServiceType,
|
||||||
|
SettingName: "vpn",
|
||||||
|
Hints: []string{authErr.serverCert},
|
||||||
|
Reason: reason,
|
||||||
|
ConnectionId: connName,
|
||||||
|
ConnectionUuid: targetUUID,
|
||||||
|
ConnectionPath: string(targetConn.GetPath()),
|
||||||
|
})
|
||||||
|
if promptErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to request certificate confirmation: %w", promptErr)
|
||||||
|
}
|
||||||
|
if _, promptErr = b.promptBroker.Wait(ctx, token); promptErr != nil {
|
||||||
|
return nil, fmt.Errorf("certificate confirmation failed: %w", promptErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
auth, err = runOpenConnectPasswordAuth(ctx, data, username, password, authErr.serverCert)
|
||||||
|
if err == nil {
|
||||||
|
persistentSecrets["certificate:"+data["gateway"]] = authErr.serverCert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reply.Secrets) > 0 || len(persistentSecrets) > 0 {
|
||||||
|
creds := &pendingVPNCredentials{
|
||||||
|
ConnectionPath: string(targetConn.GetPath()),
|
||||||
|
PersistentSecrets: persistentSecrets,
|
||||||
|
}
|
||||||
|
if _, ok := reply.Secrets["username"]; ok {
|
||||||
|
creds.Username = username
|
||||||
|
}
|
||||||
|
if reply.Save {
|
||||||
|
creds.Username = username
|
||||||
|
creds.Password = password
|
||||||
|
creds.Secrets = map[string]string{"password": password}
|
||||||
|
creds.SavePassword = true
|
||||||
|
}
|
||||||
|
b.pendingVPNSaveMu.Lock()
|
||||||
|
b.pendingVPNSave = creds
|
||||||
|
b.pendingVPNSaveMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
return auth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func suggestedOpenConnectServerCert(output string) string {
|
||||||
|
for _, field := range strings.Fields(output) {
|
||||||
|
field = strings.Trim(field, "'\".,")
|
||||||
|
if strings.HasPrefix(field, "pin-sha256:") {
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (b *NetworkManagerBackend) handleOpenVPNUsernameAuth(targetConn gonetworkmanager.Connection, connName, targetUUID, vpnServiceType string) error {
|
func (b *NetworkManagerBackend) handleOpenVPNUsernameAuth(targetConn gonetworkmanager.Connection, connName, targetUUID, vpnServiceType string) error {
|
||||||
log.Infof("[ConnectVPN] OpenVPN requires username in vpn.data - prompting before activation")
|
log.Infof("[ConnectVPN] OpenVPN requires username in vpn.data - prompting before activation")
|
||||||
|
|
||||||
@@ -758,13 +979,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
|
|||||||
b.state.VPNErrorUuid = ""
|
b.state.VPNErrorUuid = ""
|
||||||
b.stateMutex.Unlock()
|
b.stateMutex.Unlock()
|
||||||
|
|
||||||
// Clear cached PKCS11 PIN and SAML cookie on success
|
// Clear cached one-shot authentication values on success.
|
||||||
b.cachedPKCS11Mu.Lock()
|
b.cachedPKCS11Mu.Lock()
|
||||||
b.cachedPKCS11PIN = nil
|
b.cachedPKCS11PIN = nil
|
||||||
b.cachedPKCS11Mu.Unlock()
|
b.cachedPKCS11Mu.Unlock()
|
||||||
b.cachedGPSamlMu.Lock()
|
b.cachedOpenConnectMu.Lock()
|
||||||
b.cachedGPSamlCookie = nil
|
b.cachedOpenConnectAuth = nil
|
||||||
b.cachedGPSamlMu.Unlock()
|
b.cachedOpenConnectMu.Unlock()
|
||||||
|
|
||||||
b.pendingVPNSaveMu.Lock()
|
b.pendingVPNSaveMu.Lock()
|
||||||
pending := b.pendingVPNSave
|
pending := b.pendingVPNSave
|
||||||
@@ -787,13 +1008,16 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
|
|||||||
b.state.VPNErrorUuid = connectingVPNUUID
|
b.state.VPNErrorUuid = connectingVPNUUID
|
||||||
b.stateMutex.Unlock()
|
b.stateMutex.Unlock()
|
||||||
|
|
||||||
// Clear cached PKCS11 PIN and SAML cookie on failure
|
// Clear cached one-shot authentication values on failure.
|
||||||
b.cachedPKCS11Mu.Lock()
|
b.cachedPKCS11Mu.Lock()
|
||||||
b.cachedPKCS11PIN = nil
|
b.cachedPKCS11PIN = nil
|
||||||
b.cachedPKCS11Mu.Unlock()
|
b.cachedPKCS11Mu.Unlock()
|
||||||
b.cachedGPSamlMu.Lock()
|
b.cachedOpenConnectMu.Lock()
|
||||||
b.cachedGPSamlCookie = nil
|
b.cachedOpenConnectAuth = nil
|
||||||
b.cachedGPSamlMu.Unlock()
|
b.cachedOpenConnectMu.Unlock()
|
||||||
|
b.pendingVPNSaveMu.Lock()
|
||||||
|
b.pendingVPNSave = nil
|
||||||
|
b.pendingVPNSaveMu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -811,13 +1035,16 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
|
|||||||
b.state.VPNErrorUuid = connectingVPNUUID
|
b.state.VPNErrorUuid = connectingVPNUUID
|
||||||
b.stateMutex.Unlock()
|
b.stateMutex.Unlock()
|
||||||
|
|
||||||
// Clear cached PKCS11 PIN and SAML cookie
|
// Clear cached one-shot authentication values.
|
||||||
b.cachedPKCS11Mu.Lock()
|
b.cachedPKCS11Mu.Lock()
|
||||||
b.cachedPKCS11PIN = nil
|
b.cachedPKCS11PIN = nil
|
||||||
b.cachedPKCS11Mu.Unlock()
|
b.cachedPKCS11Mu.Unlock()
|
||||||
b.cachedGPSamlMu.Lock()
|
b.cachedOpenConnectMu.Lock()
|
||||||
b.cachedGPSamlCookie = nil
|
b.cachedOpenConnectAuth = nil
|
||||||
b.cachedGPSamlMu.Unlock()
|
b.cachedOpenConnectMu.Unlock()
|
||||||
|
b.pendingVPNSaveMu.Lock()
|
||||||
|
b.pendingVPNSave = nil
|
||||||
|
b.pendingVPNSaveMu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -863,17 +1090,40 @@ func (b *NetworkManagerBackend) saveVPNCredentials(creds *pendingVPNCredentials)
|
|||||||
log.Infof("[saveVPNCredentials] Saving username")
|
log.Infof("[saveVPNCredentials] Saving username")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save secrets if requested
|
secs := map[string]string{}
|
||||||
if creds.SavePassword {
|
if len(creds.PersistentSecrets) > 0 {
|
||||||
secs := creds.Secrets
|
var stored map[string]map[string]dbus.Variant
|
||||||
if len(secs) == 0 {
|
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSecrets", 0, "vpn").Store(&stored); err != nil {
|
||||||
secs = map[string]string{"password": creds.Password}
|
log.Warnf("[saveVPNCredentials] GetSecrets failed: %v", err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
for field := range secs {
|
if storedVPN, ok := stored["vpn"]; ok {
|
||||||
|
if storedSecrets, ok := storedVPN["secrets"]; ok {
|
||||||
|
saved, _ := storedSecrets.Value().(map[string]string)
|
||||||
|
for field, value := range saved {
|
||||||
|
secs[field] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for field, value := range creds.PersistentSecrets {
|
||||||
|
secs[field] = value
|
||||||
data[field+"-flags"] = "0"
|
data[field+"-flags"] = "0"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if creds.SavePassword {
|
||||||
|
toSave := creds.Secrets
|
||||||
|
if len(toSave) == 0 {
|
||||||
|
toSave = map[string]string{"password": creds.Password}
|
||||||
|
}
|
||||||
|
for field, value := range toSave {
|
||||||
|
secs[field] = value
|
||||||
|
data[field+"-flags"] = "0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(secs) > 0 {
|
||||||
vpn["secrets"] = dbus.MakeVariant(secs)
|
vpn["secrets"] = dbus.MakeVariant(secs)
|
||||||
log.Infof("[saveVPNCredentials] Saving %d secret field(s) with flags=0", len(secs))
|
log.Infof("[saveVPNCredentials] Saving %d secret field(s)", len(secs))
|
||||||
}
|
}
|
||||||
|
|
||||||
vpn["data"] = dbus.MakeVariant(data)
|
vpn["data"] = dbus.MakeVariant(data)
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package network
|
package network
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
|
mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
|
||||||
"github.com/Wifx/gonetworkmanager/v2"
|
"github.com/Wifx/gonetworkmanager/v2"
|
||||||
|
"github.com/godbus/dbus/v5"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -136,3 +140,137 @@ func TestNetworkManagerBackend_UpdateVPNConnectionState_EmptyUUID(t *testing.T)
|
|||||||
backend.updateVPNConnectionState()
|
backend.updateVPNConnectionState()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDetectVPNAuthAction_FortinetPasswordOnly(t *testing.T) {
|
||||||
|
service := "org.freedesktop.NetworkManager.openconnect"
|
||||||
|
|
||||||
|
assert.Equal(t, "openconnect_password", detectVPNAuthAction(service, map[string]string{
|
||||||
|
"protocol": "fortinet",
|
||||||
|
"authtype": "password",
|
||||||
|
}))
|
||||||
|
assert.Empty(t, detectVPNAuthAction(service, map[string]string{
|
||||||
|
"protocol": "anyconnect",
|
||||||
|
"authtype": "password",
|
||||||
|
}))
|
||||||
|
assert.Empty(t, detectVPNAuthAction(service, map[string]string{
|
||||||
|
"protocol": "fortinet",
|
||||||
|
"authtype": "saml",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureOpenConnectAgentFlags(t *testing.T) {
|
||||||
|
data := map[string]string{"protocol": "fortinet"}
|
||||||
|
assert.True(t, setOpenConnectAgentFlags(data))
|
||||||
|
assert.Equal(t, "2", data["cookie-flags"])
|
||||||
|
assert.Equal(t, "2", data["gateway-flags"])
|
||||||
|
assert.Equal(t, "2", data["gwcert-flags"])
|
||||||
|
assert.False(t, setOpenConnectAgentFlags(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenConnectCertificateConfirmation(t *testing.T) {
|
||||||
|
binDir := t.TempDir()
|
||||||
|
openConnectPath := filepath.Join(binDir, "openconnect")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
case "$*" in
|
||||||
|
*--servercert=pin-sha256:TEST-FINGERPRINT*)
|
||||||
|
printf '%s\n' "COOKIE='SVPNCOOKIE=test'" "HOST='vpn.example.test'" "FINGERPRINT='pin-sha256:TEST-FINGERPRINT'"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf '%s\n' 'Add --servercert pin-sha256:TEST-FINGERPRINT' >&2
|
||||||
|
exit 1
|
||||||
|
`
|
||||||
|
assert.NoError(t, os.WriteFile(openConnectPath, []byte(script), 0o755))
|
||||||
|
t.Setenv("PATH", binDir)
|
||||||
|
|
||||||
|
conn := mock_gonetworkmanager.NewMockConnection(t)
|
||||||
|
connPath := dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings/999")
|
||||||
|
conn.EXPECT().GetSecrets("vpn").Return(gonetworkmanager.ConnectionSettings{
|
||||||
|
"vpn": {"secrets": map[string]string{"password": "test-password"}},
|
||||||
|
}, nil)
|
||||||
|
conn.EXPECT().GetPath().Return(connPath).Twice()
|
||||||
|
|
||||||
|
broker := &fakePromptBroker{
|
||||||
|
asked: make(chan PromptRequest, 1),
|
||||||
|
reply: PromptReply{},
|
||||||
|
}
|
||||||
|
backend := &NetworkManagerBackend{promptBroker: broker}
|
||||||
|
data := map[string]string{
|
||||||
|
"gateway": "vpn.example.test:443",
|
||||||
|
"protocol": "fortinet",
|
||||||
|
"authtype": "password",
|
||||||
|
"username": "test-user",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := backend.handleOpenConnectPasswordAuth(
|
||||||
|
context.Background(), conn, "Test VPN", "test-uuid",
|
||||||
|
"org.freedesktop.NetworkManager.openconnect", data,
|
||||||
|
)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "SVPNCOOKIE=test", result.Cookie)
|
||||||
|
assert.Equal(t, "vpn.example.test:443", result.Host)
|
||||||
|
|
||||||
|
prompt := <-broker.asked
|
||||||
|
assert.Equal(t, "server-certificate", prompt.Reason)
|
||||||
|
assert.Equal(t, []string{"pin-sha256:TEST-FINGERPRINT"}, prompt.Hints)
|
||||||
|
|
||||||
|
assert.Equal(t, map[string]string{
|
||||||
|
"certificate:vpn.example.test:443": "pin-sha256:TEST-FINGERPRINT",
|
||||||
|
}, backend.pendingVPNSave.PersistentSecrets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenConnectCertificateRotationReprompts(t *testing.T) {
|
||||||
|
binDir := t.TempDir()
|
||||||
|
openConnectPath := filepath.Join(binDir, "openconnect")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
case "$*" in
|
||||||
|
*--servercert=pin-sha256:NEW-FINGERPRINT*)
|
||||||
|
printf '%s\n' "COOKIE='SVPNCOOKIE=test'" "HOST='vpn.example.test'" "FINGERPRINT='pin-sha256:NEW-FINGERPRINT'"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf '%s\n' 'Add --servercert pin-sha256:NEW-FINGERPRINT' >&2
|
||||||
|
exit 1
|
||||||
|
`
|
||||||
|
assert.NoError(t, os.WriteFile(openConnectPath, []byte(script), 0o755))
|
||||||
|
t.Setenv("PATH", binDir)
|
||||||
|
|
||||||
|
conn := mock_gonetworkmanager.NewMockConnection(t)
|
||||||
|
connPath := dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings/999")
|
||||||
|
conn.EXPECT().GetSecrets("vpn").Return(gonetworkmanager.ConnectionSettings{
|
||||||
|
"vpn": {"secrets": map[string]string{
|
||||||
|
"password": "test-password",
|
||||||
|
"certificate:vpn.example.test:443": "pin-sha256:OLD-FINGERPRINT",
|
||||||
|
}},
|
||||||
|
}, nil)
|
||||||
|
conn.EXPECT().GetPath().Return(connPath).Twice()
|
||||||
|
|
||||||
|
broker := &fakePromptBroker{
|
||||||
|
asked: make(chan PromptRequest, 1),
|
||||||
|
reply: PromptReply{},
|
||||||
|
}
|
||||||
|
backend := &NetworkManagerBackend{promptBroker: broker}
|
||||||
|
data := map[string]string{
|
||||||
|
"gateway": "vpn.example.test:443",
|
||||||
|
"protocol": "fortinet",
|
||||||
|
"authtype": "password",
|
||||||
|
"username": "test-user",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := backend.handleOpenConnectPasswordAuth(
|
||||||
|
context.Background(), conn, "Test VPN", "test-uuid",
|
||||||
|
"org.freedesktop.NetworkManager.openconnect", data,
|
||||||
|
)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "SVPNCOOKIE=test", result.Cookie)
|
||||||
|
|
||||||
|
prompt := <-broker.asked
|
||||||
|
assert.Equal(t, "server-certificate-changed", prompt.Reason)
|
||||||
|
assert.Equal(t, []string{"pin-sha256:NEW-FINGERPRINT"}, prompt.Hints)
|
||||||
|
|
||||||
|
assert.Equal(t, map[string]string{
|
||||||
|
"certificate:vpn.example.test:443": "pin-sha256:NEW-FINGERPRINT",
|
||||||
|
}, backend.pendingVPNSave.PersistentSecrets)
|
||||||
|
assert.False(t, backend.pendingVPNSave.SavePassword)
|
||||||
|
assert.Empty(t, backend.pendingVPNSave.Secrets)
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ DankModal {
|
|||||||
property string promptToken: ""
|
property string promptToken: ""
|
||||||
property string promptReason: ""
|
property string promptReason: ""
|
||||||
property var promptFields: []
|
property var promptFields: []
|
||||||
|
property var promptHints: []
|
||||||
property string promptSetting: ""
|
property string promptSetting: ""
|
||||||
|
|
||||||
property bool isVpnPrompt: false
|
property bool isVpnPrompt: false
|
||||||
@@ -40,17 +41,21 @@ DankModal {
|
|||||||
property var fieldsInfo: []
|
property var fieldsInfo: []
|
||||||
property var secretValues: ({})
|
property var secretValues: ({})
|
||||||
|
|
||||||
|
readonly property bool isCertificateChangedPrompt: promptReason === "server-certificate-changed"
|
||||||
|
readonly property bool isCertificatePrompt: promptReason === "server-certificate" || isCertificateChangedPrompt
|
||||||
|
readonly property string serverCertificateFingerprint: promptHints.length > 0 ? promptHints[0] : ""
|
||||||
readonly property bool showUsernameField: requiresEnterprise && !isVpnPrompt && fieldsInfo.length === 0
|
readonly property bool showUsernameField: requiresEnterprise && !isVpnPrompt && fieldsInfo.length === 0
|
||||||
readonly property bool showPasswordField: fieldsInfo.length === 0
|
readonly property bool showPasswordField: fieldsInfo.length === 0 && !isCertificatePrompt
|
||||||
readonly property bool showAnonField: requiresEnterprise && !isVpnPrompt
|
readonly property bool showAnonField: requiresEnterprise && !isVpnPrompt
|
||||||
readonly property bool showDomainField: requiresEnterprise && !isVpnPrompt
|
readonly property bool showDomainField: requiresEnterprise && !isVpnPrompt
|
||||||
readonly property bool showSavePasswordCheckbox: (isVpnPrompt || fieldsInfo.length > 0) && promptReason !== "pkcs11"
|
readonly property bool showSavePasswordCheckbox: (isVpnPrompt || fieldsInfo.length > 0) && promptReason !== "pkcs11" && !isCertificatePrompt
|
||||||
|
|
||||||
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
|
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
|
||||||
readonly property int inputFieldWithSpacing: inputFieldHeight + Theme.spacingM
|
readonly property int inputFieldWithSpacing: inputFieldHeight + Theme.spacingM
|
||||||
readonly property int checkboxRowHeight: Theme.fontSizeMedium + Theme.spacingS
|
readonly property int checkboxRowHeight: Theme.fontSizeMedium + Theme.spacingS
|
||||||
readonly property int headerHeight: Theme.fontSizeLarge + Theme.fontSizeMedium + Theme.spacingM * 2
|
readonly property int headerHeight: Theme.fontSizeLarge + Theme.fontSizeMedium + Theme.spacingM * 2
|
||||||
readonly property int buttonRowHeight: 36 + Theme.spacingM
|
readonly property int buttonRowHeight: 36 + Theme.spacingM
|
||||||
|
readonly property int certificateWarningHeight: certificateWarningColumn.implicitHeight + Theme.spacingM * 2
|
||||||
|
|
||||||
property int calculatedHeight: {
|
property int calculatedHeight: {
|
||||||
let h = headerHeight + buttonRowHeight + Theme.spacingL * 2;
|
let h = headerHeight + buttonRowHeight + Theme.spacingL * 2;
|
||||||
@@ -67,10 +72,16 @@ DankModal {
|
|||||||
h += inputFieldWithSpacing;
|
h += inputFieldWithSpacing;
|
||||||
if (showSavePasswordCheckbox)
|
if (showSavePasswordCheckbox)
|
||||||
h += checkboxRowHeight;
|
h += checkboxRowHeight;
|
||||||
|
if (isCertificatePrompt)
|
||||||
|
h += certificateWarningHeight + Theme.spacingM;
|
||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusFirstField() {
|
function focusFirstField() {
|
||||||
|
if (isCertificatePrompt) {
|
||||||
|
connectButton.forceActiveFocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (fieldsInfo.length > 0) {
|
if (fieldsInfo.length > 0) {
|
||||||
if (dynamicFieldsRepeater.count > 0) {
|
if (dynamicFieldsRepeater.count > 0) {
|
||||||
const firstItem = dynamicFieldsRepeater.itemAt(0);
|
const firstItem = dynamicFieldsRepeater.itemAt(0);
|
||||||
@@ -101,6 +112,7 @@ DankModal {
|
|||||||
promptToken = "";
|
promptToken = "";
|
||||||
promptReason = "";
|
promptReason = "";
|
||||||
promptFields = [];
|
promptFields = [];
|
||||||
|
promptHints = [];
|
||||||
promptSetting = "";
|
promptSetting = "";
|
||||||
isVpnPrompt = false;
|
isVpnPrompt = false;
|
||||||
connectionName = "";
|
connectionName = "";
|
||||||
@@ -127,6 +139,7 @@ DankModal {
|
|||||||
promptToken = "";
|
promptToken = "";
|
||||||
promptReason = "";
|
promptReason = "";
|
||||||
promptFields = [];
|
promptFields = [];
|
||||||
|
promptHints = [];
|
||||||
promptSetting = "";
|
promptSetting = "";
|
||||||
isVpnPrompt = false;
|
isVpnPrompt = false;
|
||||||
connectionName = "";
|
connectionName = "";
|
||||||
@@ -145,6 +158,7 @@ DankModal {
|
|||||||
promptToken = token;
|
promptToken = token;
|
||||||
promptReason = reason;
|
promptReason = reason;
|
||||||
promptFields = fields || [];
|
promptFields = fields || [];
|
||||||
|
promptHints = hints || [];
|
||||||
promptSetting = setting || "802-11-wireless-security";
|
promptSetting = setting || "802-11-wireless-security";
|
||||||
connectionType = connType || "802-11-wireless";
|
connectionType = connType || "802-11-wireless";
|
||||||
connectionName = connName || ssid || "";
|
connectionName = connName || ssid || "";
|
||||||
@@ -324,6 +338,8 @@ DankModal {
|
|||||||
text: {
|
text: {
|
||||||
if (promptReason === "pkcs11")
|
if (promptReason === "pkcs11")
|
||||||
return I18n.tr("Smartcard Authentication");
|
return I18n.tr("Smartcard Authentication");
|
||||||
|
if (isCertificatePrompt)
|
||||||
|
return I18n.tr("Untrusted VPN certificate", "Title for VPN server certificate trust confirmation");
|
||||||
if (isVpnPrompt)
|
if (isVpnPrompt)
|
||||||
return I18n.tr("Connect to VPN");
|
return I18n.tr("Connect to VPN");
|
||||||
if (isHiddenNetwork)
|
if (isHiddenNetwork)
|
||||||
@@ -343,6 +359,8 @@ DankModal {
|
|||||||
text: {
|
text: {
|
||||||
if (promptReason === "pkcs11")
|
if (promptReason === "pkcs11")
|
||||||
return I18n.tr("Enter PIN for ") + wifiPasswordSSID;
|
return I18n.tr("Enter PIN for ") + wifiPasswordSSID;
|
||||||
|
if (isCertificatePrompt)
|
||||||
|
return wifiPasswordSSID;
|
||||||
if (fieldsInfo.length > 0)
|
if (fieldsInfo.length > 0)
|
||||||
return I18n.tr("Enter credentials for ") + wifiPasswordSSID;
|
return I18n.tr("Enter credentials for ") + wifiPasswordSSID;
|
||||||
if (isVpnPrompt)
|
if (isVpnPrompt)
|
||||||
@@ -383,6 +401,45 @@ DankModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: certificateWarningBox
|
||||||
|
|
||||||
|
readonly property color warningTone: isCertificateChangedPrompt ? Theme.error : Theme.warning
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
height: certificateWarningHeight
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.withAlpha(warningTone, 0.12)
|
||||||
|
border.color: Theme.withAlpha(warningTone, 0.5)
|
||||||
|
border.width: 1
|
||||||
|
visible: isCertificatePrompt
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: certificateWarningColumn
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.spacingM
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: isCertificateChangedPrompt ? I18n.tr("The server certificate has changed since it was last trusted. Only continue if you recognize the new fingerprint.", "Warning shown when a trusted VPN server certificate no longer matches") : I18n.tr("Only continue if you recognize this server certificate fingerprint.", "Warning shown before trusting an unverified VPN server certificate")
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceText
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: serverCertificateFingerprint
|
||||||
|
wrapMode: Text.WrapAnywhere
|
||||||
|
font.family: SettingsData.monoFontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: certificateWarningBox.warningTone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: inputFieldHeight
|
height: inputFieldHeight
|
||||||
@@ -690,10 +747,15 @@ DankModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
|
id: connectButton
|
||||||
|
|
||||||
width: Math.max(80, connectText.contentWidth + Theme.spacingM * 2)
|
width: Math.max(80, connectText.contentWidth + Theme.spacingM * 2)
|
||||||
height: 36
|
height: 36
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: connectArea.containsMouse ? Qt.darker(Theme.primary, 1.1) : Theme.primary
|
color: connectArea.containsMouse ? Qt.darker(Theme.primary, 1.1) : Theme.primary
|
||||||
|
border.color: activeFocus ? Theme.surfaceText : "transparent"
|
||||||
|
border.width: activeFocus ? 2 : 0
|
||||||
|
activeFocusOnTab: true
|
||||||
enabled: {
|
enabled: {
|
||||||
if (fieldsInfo.length > 0) {
|
if (fieldsInfo.length > 0) {
|
||||||
for (var i = 0; i < fieldsInfo.length; i++) {
|
for (var i = 0; i < fieldsInfo.length; i++) {
|
||||||
@@ -705,6 +767,8 @@ DankModal {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (isCertificatePrompt)
|
||||||
|
return serverCertificateFingerprint.length > 0;
|
||||||
if (isVpnPrompt)
|
if (isVpnPrompt)
|
||||||
return passwordInput.text.length > 0;
|
return passwordInput.text.length > 0;
|
||||||
if (isHiddenNetwork)
|
if (isHiddenNetwork)
|
||||||
@@ -716,7 +780,7 @@ DankModal {
|
|||||||
StyledText {
|
StyledText {
|
||||||
id: connectText
|
id: connectText
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: I18n.tr("Connect")
|
text: isCertificatePrompt ? I18n.tr("Trust", "Button that approves a VPN server certificate fingerprint") : I18n.tr("Connect")
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
color: Theme.background
|
color: Theme.background
|
||||||
font.weight: Font.Medium
|
font.weight: Font.Medium
|
||||||
@@ -731,6 +795,22 @@ DankModal {
|
|||||||
onClicked: submitCredentialsAndClose()
|
onClicked: submitCredentialsAndClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Keys.onReturnPressed: event => {
|
||||||
|
if (enabled)
|
||||||
|
submitCredentialsAndClose();
|
||||||
|
event.accepted = true;
|
||||||
|
}
|
||||||
|
Keys.onEnterPressed: event => {
|
||||||
|
if (enabled)
|
||||||
|
submitCredentialsAndClose();
|
||||||
|
event.accepted = true;
|
||||||
|
}
|
||||||
|
Keys.onSpacePressed: event => {
|
||||||
|
if (enabled)
|
||||||
|
submitCredentialsAndClose();
|
||||||
|
event.accepted = true;
|
||||||
|
}
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
ColorAnimation {
|
ColorAnimation {
|
||||||
duration: Theme.shortDuration
|
duration: Theme.shortDuration
|
||||||
|
|||||||
Reference in New Issue
Block a user