1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-05 21:15:38 -05:00

network: big feature enrichment

- Dedicated view in settings
- VPN profile management
- Ethernet disconnection
- Turn prompts into floating windows
This commit is contained in:
bbedward
2025-11-29 10:00:05 -05:00
parent 9c887fbe63
commit 1d3fe81ff7
51 changed files with 9807 additions and 2500 deletions

View File

@@ -328,6 +328,52 @@ func (_c *MockBackend_ConnectWiFi_Call) RunAndReturn(run func(network.Connection
return _c
}
// DeleteVPN provides a mock function with given fields: uuidOrName
func (_m *MockBackend) DeleteVPN(uuidOrName string) error {
ret := _m.Called(uuidOrName)
if len(ret) == 0 {
panic("no return value specified for DeleteVPN")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(uuidOrName)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockBackend_DeleteVPN_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteVPN'
type MockBackend_DeleteVPN_Call struct {
*mock.Call
}
// DeleteVPN is a helper method to define mock.On call
// - uuidOrName string
func (_e *MockBackend_Expecter) DeleteVPN(uuidOrName interface{}) *MockBackend_DeleteVPN_Call {
return &MockBackend_DeleteVPN_Call{Call: _e.mock.On("DeleteVPN", uuidOrName)}
}
func (_c *MockBackend_DeleteVPN_Call) Run(run func(uuidOrName string)) *MockBackend_DeleteVPN_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockBackend_DeleteVPN_Call) Return(_a0 error) *MockBackend_DeleteVPN_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockBackend_DeleteVPN_Call) RunAndReturn(run func(string) error) *MockBackend_DeleteVPN_Call {
_c.Call.Return(run)
return _c
}
// DisconnectAllVPN provides a mock function with no fields
func (_m *MockBackend) DisconnectAllVPN() error {
ret := _m.Called()
@@ -418,6 +464,52 @@ func (_c *MockBackend_DisconnectEthernet_Call) RunAndReturn(run func() error) *M
return _c
}
// DisconnectEthernetDevice provides a mock function with given fields: device
func (_m *MockBackend) DisconnectEthernetDevice(device string) error {
ret := _m.Called(device)
if len(ret) == 0 {
panic("no return value specified for DisconnectEthernetDevice")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(device)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockBackend_DisconnectEthernetDevice_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisconnectEthernetDevice'
type MockBackend_DisconnectEthernetDevice_Call struct {
*mock.Call
}
// DisconnectEthernetDevice is a helper method to define mock.On call
// - device string
func (_e *MockBackend_Expecter) DisconnectEthernetDevice(device interface{}) *MockBackend_DisconnectEthernetDevice_Call {
return &MockBackend_DisconnectEthernetDevice_Call{Call: _e.mock.On("DisconnectEthernetDevice", device)}
}
func (_c *MockBackend_DisconnectEthernetDevice_Call) Run(run func(device string)) *MockBackend_DisconnectEthernetDevice_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockBackend_DisconnectEthernetDevice_Call) Return(_a0 error) *MockBackend_DisconnectEthernetDevice_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockBackend_DisconnectEthernetDevice_Call) RunAndReturn(run func(string) error) *MockBackend_DisconnectEthernetDevice_Call {
_c.Call.Return(run)
return _c
}
// DisconnectVPN provides a mock function with given fields: uuidOrName
func (_m *MockBackend) DisconnectVPN(uuidOrName string) error {
ret := _m.Called(uuidOrName)
@@ -658,6 +750,53 @@ func (_c *MockBackend_GetCurrentState_Call) RunAndReturn(run func() (*network.Ba
return _c
}
// GetEthernetDevices provides a mock function with no fields
func (_m *MockBackend) GetEthernetDevices() []network.EthernetDevice {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetEthernetDevices")
}
var r0 []network.EthernetDevice
if rf, ok := ret.Get(0).(func() []network.EthernetDevice); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]network.EthernetDevice)
}
}
return r0
}
// MockBackend_GetEthernetDevices_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetEthernetDevices'
type MockBackend_GetEthernetDevices_Call struct {
*mock.Call
}
// GetEthernetDevices is a helper method to define mock.On call
func (_e *MockBackend_Expecter) GetEthernetDevices() *MockBackend_GetEthernetDevices_Call {
return &MockBackend_GetEthernetDevices_Call{Call: _e.mock.On("GetEthernetDevices")}
}
func (_c *MockBackend_GetEthernetDevices_Call) Run(run func()) *MockBackend_GetEthernetDevices_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockBackend_GetEthernetDevices_Call) Return(_a0 []network.EthernetDevice) *MockBackend_GetEthernetDevices_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockBackend_GetEthernetDevices_Call) RunAndReturn(run func() []network.EthernetDevice) *MockBackend_GetEthernetDevices_Call {
_c.Call.Return(run)
return _c
}
// GetPromptBroker provides a mock function with no fields
func (_m *MockBackend) GetPromptBroker() network.PromptBroker {
ret := _m.Called()
@@ -705,6 +844,64 @@ func (_c *MockBackend_GetPromptBroker_Call) RunAndReturn(run func() network.Prom
return _c
}
// GetVPNConfig provides a mock function with given fields: uuidOrName
func (_m *MockBackend) GetVPNConfig(uuidOrName string) (*network.VPNConfig, error) {
ret := _m.Called(uuidOrName)
if len(ret) == 0 {
panic("no return value specified for GetVPNConfig")
}
var r0 *network.VPNConfig
var r1 error
if rf, ok := ret.Get(0).(func(string) (*network.VPNConfig, error)); ok {
return rf(uuidOrName)
}
if rf, ok := ret.Get(0).(func(string) *network.VPNConfig); ok {
r0 = rf(uuidOrName)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*network.VPNConfig)
}
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(uuidOrName)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockBackend_GetVPNConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetVPNConfig'
type MockBackend_GetVPNConfig_Call struct {
*mock.Call
}
// GetVPNConfig is a helper method to define mock.On call
// - uuidOrName string
func (_e *MockBackend_Expecter) GetVPNConfig(uuidOrName interface{}) *MockBackend_GetVPNConfig_Call {
return &MockBackend_GetVPNConfig_Call{Call: _e.mock.On("GetVPNConfig", uuidOrName)}
}
func (_c *MockBackend_GetVPNConfig_Call) Run(run func(uuidOrName string)) *MockBackend_GetVPNConfig_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockBackend_GetVPNConfig_Call) Return(_a0 *network.VPNConfig, _a1 error) *MockBackend_GetVPNConfig_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockBackend_GetVPNConfig_Call) RunAndReturn(run func(string) (*network.VPNConfig, error)) *MockBackend_GetVPNConfig_Call {
_c.Call.Return(run)
return _c
}
// GetWiFiDevices provides a mock function with no fields
func (_m *MockBackend) GetWiFiDevices() []network.WiFiDevice {
ret := _m.Called()
@@ -980,6 +1177,65 @@ func (_c *MockBackend_GetWiredNetworkDetails_Call) RunAndReturn(run func(string)
return _c
}
// ImportVPN provides a mock function with given fields: filePath, name
func (_m *MockBackend) ImportVPN(filePath string, name string) (*network.VPNImportResult, error) {
ret := _m.Called(filePath, name)
if len(ret) == 0 {
panic("no return value specified for ImportVPN")
}
var r0 *network.VPNImportResult
var r1 error
if rf, ok := ret.Get(0).(func(string, string) (*network.VPNImportResult, error)); ok {
return rf(filePath, name)
}
if rf, ok := ret.Get(0).(func(string, string) *network.VPNImportResult); ok {
r0 = rf(filePath, name)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*network.VPNImportResult)
}
}
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(filePath, name)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockBackend_ImportVPN_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ImportVPN'
type MockBackend_ImportVPN_Call struct {
*mock.Call
}
// ImportVPN is a helper method to define mock.On call
// - filePath string
// - name string
func (_e *MockBackend_Expecter) ImportVPN(filePath interface{}, name interface{}) *MockBackend_ImportVPN_Call {
return &MockBackend_ImportVPN_Call{Call: _e.mock.On("ImportVPN", filePath, name)}
}
func (_c *MockBackend_ImportVPN_Call) Run(run func(filePath string, name string)) *MockBackend_ImportVPN_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string), args[1].(string))
})
return _c
}
func (_c *MockBackend_ImportVPN_Call) Return(_a0 *network.VPNImportResult, _a1 error) *MockBackend_ImportVPN_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockBackend_ImportVPN_Call) RunAndReturn(run func(string, string) (*network.VPNImportResult, error)) *MockBackend_ImportVPN_Call {
_c.Call.Return(run)
return _c
}
// Initialize provides a mock function with no fields
func (_m *MockBackend) Initialize() error {
ret := _m.Called()
@@ -1082,6 +1338,63 @@ func (_c *MockBackend_ListActiveVPN_Call) RunAndReturn(run func() ([]network.VPN
return _c
}
// ListVPNPlugins provides a mock function with no fields
func (_m *MockBackend) ListVPNPlugins() ([]network.VPNPlugin, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for ListVPNPlugins")
}
var r0 []network.VPNPlugin
var r1 error
if rf, ok := ret.Get(0).(func() ([]network.VPNPlugin, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []network.VPNPlugin); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]network.VPNPlugin)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockBackend_ListVPNPlugins_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListVPNPlugins'
type MockBackend_ListVPNPlugins_Call struct {
*mock.Call
}
// ListVPNPlugins is a helper method to define mock.On call
func (_e *MockBackend_Expecter) ListVPNPlugins() *MockBackend_ListVPNPlugins_Call {
return &MockBackend_ListVPNPlugins_Call{Call: _e.mock.On("ListVPNPlugins")}
}
func (_c *MockBackend_ListVPNPlugins_Call) Run(run func()) *MockBackend_ListVPNPlugins_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockBackend_ListVPNPlugins_Call) Return(_a0 []network.VPNPlugin, _a1 error) *MockBackend_ListVPNPlugins_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockBackend_ListVPNPlugins_Call) RunAndReturn(run func() ([]network.VPNPlugin, error)) *MockBackend_ListVPNPlugins_Call {
_c.Call.Return(run)
return _c
}
// ListVPNProfiles provides a mock function with no fields
func (_m *MockBackend) ListVPNProfiles() ([]network.VPNProfile, error) {
ret := _m.Called()
@@ -1276,6 +1589,55 @@ func (_c *MockBackend_SetPromptBroker_Call) RunAndReturn(run func(network.Prompt
return _c
}
// SetVPNCredentials provides a mock function with given fields: uuid, username, password, save
func (_m *MockBackend) SetVPNCredentials(uuid string, username string, password string, save bool) error {
ret := _m.Called(uuid, username, password, save)
if len(ret) == 0 {
panic("no return value specified for SetVPNCredentials")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, bool) error); ok {
r0 = rf(uuid, username, password, save)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockBackend_SetVPNCredentials_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetVPNCredentials'
type MockBackend_SetVPNCredentials_Call struct {
*mock.Call
}
// SetVPNCredentials is a helper method to define mock.On call
// - uuid string
// - username string
// - password string
// - save bool
func (_e *MockBackend_Expecter) SetVPNCredentials(uuid interface{}, username interface{}, password interface{}, save interface{}) *MockBackend_SetVPNCredentials_Call {
return &MockBackend_SetVPNCredentials_Call{Call: _e.mock.On("SetVPNCredentials", uuid, username, password, save)}
}
func (_c *MockBackend_SetVPNCredentials_Call) Run(run func(uuid string, username string, password string, save bool)) *MockBackend_SetVPNCredentials_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string), args[1].(string), args[2].(string), args[3].(bool))
})
return _c
}
func (_c *MockBackend_SetVPNCredentials_Call) Return(_a0 error) *MockBackend_SetVPNCredentials_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockBackend_SetVPNCredentials_Call) RunAndReturn(run func(string, string, string, bool) error) *MockBackend_SetVPNCredentials_Call {
_c.Call.Return(run)
return _c
}
// SetWiFiAutoconnect provides a mock function with given fields: ssid, autoconnect
func (_m *MockBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error {
ret := _m.Called(ssid, autoconnect)
@@ -1495,6 +1857,53 @@ func (_c *MockBackend_SubmitCredentials_Call) RunAndReturn(run func(string, map[
return _c
}
// UpdateVPNConfig provides a mock function with given fields: uuid, updates
func (_m *MockBackend) UpdateVPNConfig(uuid string, updates map[string]interface{}) error {
ret := _m.Called(uuid, updates)
if len(ret) == 0 {
panic("no return value specified for UpdateVPNConfig")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, map[string]interface{}) error); ok {
r0 = rf(uuid, updates)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockBackend_UpdateVPNConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateVPNConfig'
type MockBackend_UpdateVPNConfig_Call struct {
*mock.Call
}
// UpdateVPNConfig is a helper method to define mock.On call
// - uuid string
// - updates map[string]interface{}
func (_e *MockBackend_Expecter) UpdateVPNConfig(uuid interface{}, updates interface{}) *MockBackend_UpdateVPNConfig_Call {
return &MockBackend_UpdateVPNConfig_Call{Call: _e.mock.On("UpdateVPNConfig", uuid, updates)}
}
func (_c *MockBackend_UpdateVPNConfig_Call) Run(run func(uuid string, updates map[string]interface{})) *MockBackend_UpdateVPNConfig_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string), args[1].(map[string]interface{}))
})
return _c
}
func (_c *MockBackend_UpdateVPNConfig_Call) Return(_a0 error) *MockBackend_UpdateVPNConfig_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockBackend_UpdateVPNConfig_Call) RunAndReturn(run func(string, map[string]interface{}) error) *MockBackend_UpdateVPNConfig_Call {
_c.Call.Return(run)
return _c
}
// NewMockBackend creates a new instance of MockBackend. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockBackend(t interface {

View File

@@ -287,7 +287,7 @@ func TestNewManager(t *testing.T) {
} else {
assert.NotNil(t, manager)
assert.NotNil(t, manager.state)
assert.NotNil(t, manager.subscribers)
assert.NotNil(t, &manager.subscribers)
assert.NotNil(t, manager.stopChan)
manager.Close()

View File

@@ -17,25 +17,12 @@ func TestEventType_Constants(t *testing.T) {
func TestSessionState_Struct(t *testing.T) {
state := SessionState{
SessionID: "1",
SessionPath: "/org/freedesktop/login1/session/_31",
Locked: false,
Active: true,
IdleHint: false,
IdleSinceHint: 0,
LockedHint: false,
SessionType: "wayland",
SessionClass: "user",
User: 1000,
UserName: "testuser",
RemoteHost: "",
Service: "gdm-password",
TTY: "tty2",
Display: ":1",
Remote: false,
Seat: "seat0",
VTNr: 2,
PreparingForSleep: false,
SessionID: "1",
Locked: false,
Active: true,
SessionType: "wayland",
User: 1000,
UserName: "testuser",
}
assert.Equal(t, "1", state.SessionID)

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
@@ -176,8 +177,8 @@ func (a *SecretAgent) GetSecrets(
return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
}
log.Infof("[SecretAgent] VPN with empty hints but we're connecting - prompting for password")
fields = []string{"password"}
fields = inferVPNFields(conn, vpnSvc)
log.Infof("[SecretAgent] VPN with empty hints but we're connecting - inferred fields: %v", fields)
} else {
log.Infof("[SecretAgent] VPN with empty hints - deferring to other agents for %s", vpnSvc)
return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
@@ -251,6 +252,35 @@ func (a *SecretAgent) GetSecrets(
}
}
if settingName == "vpn" && a.backend != nil {
a.backend.cachedVPNCredsMu.Lock()
cached := a.backend.cachedVPNCreds
if cached != nil && cached.ConnectionUUID == connUuid {
a.backend.cachedVPNCreds = nil
a.backend.cachedVPNCredsMu.Unlock()
log.Infof("[SecretAgent] Using cached password from pre-activation prompt")
out := nmSettingMap{}
sec := nmVariantMap{}
sec["password"] = dbus.MakeVariant(cached.Password)
out[settingName] = sec
if cached.SavePassword {
a.backend.pendingVPNSaveMu.Lock()
a.backend.pendingVPNSave = &pendingVPNCredentials{
ConnectionPath: string(path),
Password: cached.Password,
SavePassword: true,
}
a.backend.pendingVPNSaveMu.Unlock()
}
return out, nil
}
a.backend.cachedVPNCredsMu.Unlock()
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
@@ -261,6 +291,7 @@ func (a *SecretAgent) GetSecrets(
VpnService: vpnSvc,
SettingName: settingName,
Fields: fields,
FieldsInfo: buildFieldsInfo(settingName, fields, vpnSvc),
Hints: hints,
Reason: reason,
ConnectionId: connId,
@@ -283,6 +314,7 @@ func (a *SecretAgent) GetSecrets(
wasConnecting := a.backend.state.IsConnecting
wasConnectingVPN := a.backend.state.IsConnectingVPN
cancelledSSID := a.backend.state.ConnectingSSID
cancelledVPNUUID := a.backend.state.ConnectingVPNUUID
if wasConnecting || wasConnectingVPN {
log.Infof("[SecretAgent] Clearing connecting state due to cancelled prompt")
a.backend.state.IsConnecting = false
@@ -301,6 +333,14 @@ func (a *SecretAgent) GetSecrets(
}
}
// If this was a VPN connection that was cancelled, deactivate it
if wasConnectingVPN && cancelledVPNUUID != "" {
log.Infof("[SecretAgent] Deactivating cancelled VPN connection: %s", cancelledVPNUUID)
if err := a.backend.DisconnectVPN(cancelledVPNUUID); err != nil {
log.Warnf("[SecretAgent] Failed to deactivate cancelled VPN: %v", err)
}
}
if (wasConnecting || wasConnectingVPN) && a.backend.onStateChange != nil {
a.backend.onStateChange()
}
@@ -320,7 +360,12 @@ func (a *SecretAgent) GetSecrets(
out := nmSettingMap{}
sec := nmVariantMap{}
var vpnUsername string
for k, v := range reply.Secrets {
if settingName == "vpn" && k == "username" {
vpnUsername = v
}
sec[k] = dbus.MakeVariant(v)
}
out[settingName] = sec
@@ -332,13 +377,22 @@ func (a *SecretAgent) GetSecrets(
log.Infof("[SecretAgent] Returning VPN secrets with %d fields for %s", len(sec), vpnSvc)
}
// If save=true, persist secrets in background after returning to NetworkManager
// This MUST happen after we return secrets, in a goroutine
if reply.Save {
if settingName == "vpn" && a.backend != nil && (vpnUsername != "" || reply.Save) {
pw, _ := reply.Secrets["password"]
a.backend.pendingVPNSaveMu.Lock()
a.backend.pendingVPNSave = &pendingVPNCredentials{
ConnectionPath: string(path),
Username: vpnUsername,
Password: pw,
SavePassword: reply.Save,
}
a.backend.pendingVPNSaveMu.Unlock()
log.Infof("[SecretAgent] Queued credentials persist for after connection succeeds")
} else if reply.Save && settingName != "vpn" {
// Non-VPN save logic
go func() {
log.Infof("[SecretAgent] Persisting secrets with Update2: path=%s, setting=%s", path, settingName)
// Get existing connection settings
connObj := a.conn.Object("org.freedesktop.NetworkManager", path)
var existingSettings map[string]map[string]dbus.Variant
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existingSettings); err != nil {
@@ -346,49 +400,12 @@ func (a *SecretAgent) GetSecrets(
return
}
// Build minimal settings with ONLY the section we're updating
// This avoids D-Bus type serialization issues with complex types like IPv6 addresses
settings := make(map[string]map[string]dbus.Variant)
// Copy connection section (required for Update2)
if connSection, ok := existingSettings["connection"]; ok {
settings["connection"] = connSection
}
// Update settings based on type
switch settingName {
case "vpn":
vpn, ok := existingSettings["vpn"]
if !ok {
vpn = make(map[string]dbus.Variant)
}
var data map[string]string
if dataVariant, ok := vpn["data"]; ok {
if dm, ok := dataVariant.Value().(map[string]string); ok {
data = make(map[string]string)
for k, v := range dm {
data[k] = v
}
} else {
data = make(map[string]string)
}
} else {
data = make(map[string]string)
}
data["password-flags"] = "0"
vpn["data"] = dbus.MakeVariant(data)
secs := make(map[string]string)
for k, v := range reply.Secrets {
secs[k] = v
}
vpn["secrets"] = dbus.MakeVariant(secs)
settings["vpn"] = vpn
log.Infof("[SecretAgent] Updated VPN settings: password-flags=0, secrets with %d fields", len(secs))
case "802-11-wireless-security":
wifiSec, ok := existingSettings["802-11-wireless-security"]
if !ok {
@@ -514,6 +531,102 @@ func fieldsNeeded(setting string, hints []string) []string {
}
}
func buildFieldsInfo(setting string, fields []string, vpnService string) []FieldInfo {
result := make([]FieldInfo, 0, len(fields))
for _, f := range fields {
info := FieldInfo{Name: f}
switch setting {
case "802-11-wireless-security":
info.Label = "Password"
info.IsSecret = true
case "802-1x":
switch f {
case "identity":
info.Label = "Username"
info.IsSecret = false
case "password":
info.Label = "Password"
info.IsSecret = true
default:
info.Label = f
info.IsSecret = true
}
case "vpn":
info.Label, info.IsSecret = vpnFieldMeta(f, vpnService)
default:
info.Label = f
info.IsSecret = true
}
result = append(result, info)
}
return result
}
func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
fields := []string{"password"}
vpnSettings, ok := conn["vpn"]
if !ok {
return fields
}
dataVariant, ok := vpnSettings["data"]
if !ok {
return fields
}
dataMap, ok := dataVariant.Value().(map[string]string)
if !ok {
return fields
}
connType := dataMap["connection-type"]
switch {
case strings.Contains(vpnService, "openvpn"):
if connType == "password" || connType == "password-tls" {
if dataMap["username"] == "" {
fields = []string{"username", "password"}
}
}
case strings.Contains(vpnService, "vpnc"), strings.Contains(vpnService, "l2tp"),
strings.Contains(vpnService, "pptp"), strings.Contains(vpnService, "openconnect"):
if dataMap["username"] == "" {
fields = []string{"username", "password"}
}
}
return fields
}
func vpnFieldMeta(field, vpnService string) (label string, isSecret bool) {
switch field {
case "password":
return "Password", true
case "Xauth password":
return "IPSec Password", true
case "IPSec secret":
return "IPSec Pre-Shared Key", true
case "cert-pass":
return "Certificate Password", true
case "http-proxy-password":
return "HTTP Proxy Password", true
case "username":
return "Username", false
case "Xauth username":
return "IPSec Username", false
case "proxy-password":
return "Proxy Password", true
case "private-key-password":
return "Private Key Password", true
}
if strings.HasSuffix(field, "password") || strings.HasSuffix(field, "secret") ||
strings.HasSuffix(field, "pass") || strings.HasSuffix(field, "psk") {
return strings.Title(strings.ReplaceAll(field, "-", " ")), true
}
return strings.Title(strings.ReplaceAll(field, "-", " ")), false
}
func readVPNPasswordFlags(conn map[string]nmVariantMap, settingName string) uint32 {
if settingName != "vpn" {
return 0xFFFF

View File

@@ -18,10 +18,12 @@ type Backend interface {
ForgetWiFiNetwork(ssid string) error
SetWiFiAutoconnect(ssid string, autoconnect bool) error
GetEthernetDevices() []EthernetDevice
GetWiredConnections() ([]WiredConnection, error)
GetWiredNetworkDetails(uuid string) (*WiredNetworkInfoResponse, error)
ConnectEthernet() error
DisconnectEthernet() error
DisconnectEthernetDevice(device string) error
ActivateWiredConnection(uuid string) error
ListVPNProfiles() ([]VPNProfile, error)
@@ -30,6 +32,12 @@ type Backend interface {
DisconnectVPN(uuidOrName string) error
DisconnectAllVPN() error
ClearVPNCredentials(uuidOrName string) error
ListVPNPlugins() ([]VPNPlugin, error)
ImportVPN(filePath string, name string) (*VPNImportResult, error)
GetVPNConfig(uuidOrName string) (*VPNConfig, error)
UpdateVPNConfig(uuid string, updates map[string]interface{}) error
SetVPNCredentials(uuid string, username string, password string, save bool) error
DeleteVPN(uuidOrName string) error
GetCurrentState() (*BackendState, error)
@@ -49,6 +57,7 @@ type BackendState struct {
EthernetDevice string
EthernetConnected bool
EthernetConnectionUuid string
EthernetDevices []EthernetDevice
WiFiIP string
WiFiDevice string
WiFiConnected bool

View File

@@ -84,6 +84,7 @@ func (b *HybridIwdNetworkdBackend) GetCurrentState() (*BackendState, error) {
merged.EthernetDevice = ls.EthernetDevice
merged.EthernetConnectionUuid = ls.EthernetConnectionUuid
merged.WiredConnections = ls.WiredConnections
merged.EthernetDevices = ls.EthernetDevices
if ls.EthernetConnected && ls.EthernetIP != "" {
merged.NetworkStatus = StatusEthernet
@@ -149,6 +150,14 @@ func (b *HybridIwdNetworkdBackend) DisconnectEthernet() error {
return b.l3.DisconnectEthernet()
}
func (b *HybridIwdNetworkdBackend) DisconnectEthernetDevice(device string) error {
return b.l3.DisconnectEthernetDevice(device)
}
func (b *HybridIwdNetworkdBackend) GetEthernetDevices() []EthernetDevice {
return b.l3.GetEthernetDevices()
}
func (b *HybridIwdNetworkdBackend) ActivateWiredConnection(uuid string) error {
return b.l3.ActivateWiredConnection(uuid)
}
@@ -177,6 +186,26 @@ func (b *HybridIwdNetworkdBackend) ClearVPNCredentials(uuidOrName string) error
return fmt.Errorf("VPN not supported in hybrid mode")
}
func (b *HybridIwdNetworkdBackend) ListVPNPlugins() ([]VPNPlugin, error) {
return []VPNPlugin{}, nil
}
func (b *HybridIwdNetworkdBackend) ImportVPN(filePath string, name string) (*VPNImportResult, error) {
return nil, fmt.Errorf("VPN not supported in hybrid mode")
}
func (b *HybridIwdNetworkdBackend) GetVPNConfig(uuidOrName string) (*VPNConfig, error) {
return nil, fmt.Errorf("VPN not supported in hybrid mode")
}
func (b *HybridIwdNetworkdBackend) UpdateVPNConfig(uuid string, updates map[string]interface{}) error {
return fmt.Errorf("VPN not supported in hybrid mode")
}
func (b *HybridIwdNetworkdBackend) DeleteVPN(uuidOrName string) error {
return fmt.Errorf("VPN not supported in hybrid mode")
}
func (b *HybridIwdNetworkdBackend) GetPromptBroker() PromptBroker {
return b.wifi.GetPromptBroker()
}
@@ -208,3 +237,7 @@ func (b *HybridIwdNetworkdBackend) DisconnectWiFiDevice(device string) error {
func (b *HybridIwdNetworkdBackend) GetWiFiDevices() []WiFiDevice {
return b.wifi.GetWiFiDevices()
}
func (b *HybridIwdNetworkdBackend) SetVPNCredentials(uuid, username, password string, save bool) error {
return fmt.Errorf("VPN not supported in hybrid mode")
}

View File

@@ -18,6 +18,14 @@ func (b *IWDBackend) DisconnectEthernet() error {
return fmt.Errorf("wired connections not supported by iwd")
}
func (b *IWDBackend) DisconnectEthernetDevice(device string) error {
return fmt.Errorf("wired connections not supported by iwd")
}
func (b *IWDBackend) GetEthernetDevices() []EthernetDevice {
return []EthernetDevice{}
}
func (b *IWDBackend) ActivateWiredConnection(uuid string) error {
return fmt.Errorf("wired connections not supported by iwd")
}
@@ -46,6 +54,30 @@ func (b *IWDBackend) ClearVPNCredentials(uuidOrName string) error {
return fmt.Errorf("VPN not supported by iwd backend")
}
func (b *IWDBackend) ListVPNPlugins() ([]VPNPlugin, error) {
return nil, fmt.Errorf("VPN not supported by iwd backend")
}
func (b *IWDBackend) ImportVPN(filePath string, name string) (*VPNImportResult, error) {
return nil, fmt.Errorf("VPN not supported by iwd backend")
}
func (b *IWDBackend) GetVPNConfig(uuidOrName string) (*VPNConfig, error) {
return nil, fmt.Errorf("VPN not supported by iwd backend")
}
func (b *IWDBackend) UpdateVPNConfig(uuid string, updates map[string]interface{}) error {
return fmt.Errorf("VPN not supported by iwd backend")
}
func (b *IWDBackend) DeleteVPN(uuidOrName string) error {
return fmt.Errorf("VPN not supported by iwd backend")
}
func (b *IWDBackend) SetVPNCredentials(uuid, username, password string, save bool) error {
return fmt.Errorf("VPN not supported by iwd backend")
}
func (b *IWDBackend) ScanWiFiDevice(device string) error {
return b.ScanWiFi()
}

View File

@@ -138,6 +138,7 @@ func (b *SystemdNetworkdBackend) updateState() error {
}
var wiredConns []WiredConnection
var ethernetDevices []EthernetDevice
for name, link := range b.links {
if b.isVirtualInterface(name) || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
continue
@@ -151,6 +152,37 @@ func (b *SystemdNetworkdBackend) updateState() error {
Type: "ethernet",
IsActive: active,
})
var ip string
var hwAddr string
if iface, err := net.InterfaceByName(name); err == nil {
hwAddr = iface.HardwareAddr.String()
if addrs := b.getAddresses(name); len(addrs) > 0 {
ip = addrs[0]
}
}
stateStr := "disconnected"
switch link.opState {
case "routable":
stateStr = "routable"
case "carrier":
stateStr = "carrier"
case "degraded":
stateStr = "degraded"
case "no-carrier":
stateStr = "no-carrier"
case "off":
stateStr = "off"
}
ethernetDevices = append(ethernetDevices, EthernetDevice{
Name: name,
HwAddress: hwAddr,
State: stateStr,
Connected: active,
IP: ip,
})
}
b.stateMutex.Lock()
@@ -162,6 +194,7 @@ func (b *SystemdNetworkdBackend) updateState() error {
b.state.WiFiConnected = false
b.state.WiFiIP = ""
b.state.WiredConnections = wiredConns
b.state.EthernetDevices = ethernetDevices
if wiredIface != nil {
b.state.EthernetDevice = wiredIface.name

View File

@@ -108,3 +108,13 @@ func (b *SystemdNetworkdBackend) ActivateWiredConnection(id string) error {
linkObj := b.conn.Object(networkdBusName, link.path)
return linkObj.Call(networkdLinkIface+".Reconfigure", 0).Err
}
func (b *SystemdNetworkdBackend) GetEthernetDevices() []EthernetDevice {
b.stateMutex.RLock()
defer b.stateMutex.RUnlock()
return append([]EthernetDevice(nil), b.state.EthernetDevices...)
}
func (b *SystemdNetworkdBackend) DisconnectEthernetDevice(device string) error {
return fmt.Errorf("not supported by networkd backend")
}

View File

@@ -123,3 +123,25 @@ func TestSystemdNetworkdBackend_DisconnectEthernet(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "not supported")
}
func TestSystemdNetworkdBackend_GetEthernetDevices(t *testing.T) {
backend, _ := NewSystemdNetworkdBackend()
backend.state.EthernetDevices = []EthernetDevice{
{Name: "enp0s3", State: "routable", Connected: true},
{Name: "enp0s8", State: "no-carrier", Connected: false},
}
devices := backend.GetEthernetDevices()
assert.Len(t, devices, 2)
assert.Equal(t, "enp0s3", devices[0].Name)
assert.True(t, devices[0].Connected)
}
func TestSystemdNetworkdBackend_DisconnectEthernetDevice(t *testing.T) {
backend, _ := NewSystemdNetworkdBackend()
err := backend.DisconnectEthernetDevice("enp0s3")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not supported")
}

View File

@@ -54,6 +54,30 @@ func (b *SystemdNetworkdBackend) ClearVPNCredentials(uuidOrName string) error {
return fmt.Errorf("VPN not supported by networkd backend")
}
func (b *SystemdNetworkdBackend) ListVPNPlugins() ([]VPNPlugin, error) {
return []VPNPlugin{}, nil
}
func (b *SystemdNetworkdBackend) ImportVPN(filePath string, name string) (*VPNImportResult, error) {
return nil, fmt.Errorf("VPN not supported by networkd backend")
}
func (b *SystemdNetworkdBackend) GetVPNConfig(uuidOrName string) (*VPNConfig, error) {
return nil, fmt.Errorf("VPN not supported by networkd backend")
}
func (b *SystemdNetworkdBackend) UpdateVPNConfig(uuid string, updates map[string]interface{}) error {
return fmt.Errorf("VPN not supported by networkd backend")
}
func (b *SystemdNetworkdBackend) DeleteVPN(uuidOrName string) error {
return fmt.Errorf("VPN not supported by networkd backend")
}
func (b *SystemdNetworkdBackend) SetVPNCredentials(uuid, username, password string, save bool) error {
return fmt.Errorf("VPN not supported by networkd backend")
}
func (b *SystemdNetworkdBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error {
return fmt.Errorf("WiFi autoconnect not supported by networkd backend")
}

View File

@@ -37,13 +37,21 @@ type wifiDeviceInfo struct {
hwAddress string
}
type ethernetDeviceInfo struct {
device gonetworkmanager.Device
wired gonetworkmanager.DeviceWired
name string
hwAddress string
}
type NetworkManagerBackend struct {
nmConn interface{}
ethernetDevice interface{}
wifiDevice interface{}
settings interface{}
wifiDev interface{}
wifiDevices map[string]*wifiDeviceInfo
nmConn interface{}
ethernetDevice interface{}
ethernetDevices map[string]*ethernetDeviceInfo
wifiDevice interface{}
settings interface{}
wifiDev interface{}
wifiDevices map[string]*wifiDeviceInfo
dbusConn *dbus.Conn
signals chan *dbus.Signal
@@ -60,9 +68,27 @@ type NetworkManagerBackend struct {
lastFailedTime int64
failedMutex sync.RWMutex
pendingVPNSave *pendingVPNCredentials
pendingVPNSaveMu sync.Mutex
cachedVPNCreds *cachedVPNCredentials
cachedVPNCredsMu sync.Mutex
onStateChange func()
}
type pendingVPNCredentials struct {
ConnectionPath string
Username string
Password string
SavePassword bool
}
type cachedVPNCredentials struct {
ConnectionUUID string
Password string
SavePassword bool
}
func NewNetworkManagerBackend(nmConn ...gonetworkmanager.NetworkManager) (*NetworkManagerBackend, error) {
var nm gonetworkmanager.NetworkManager
var err error
@@ -79,9 +105,10 @@ func NewNetworkManagerBackend(nmConn ...gonetworkmanager.NetworkManager) (*Netwo
}
backend := &NetworkManagerBackend{
nmConn: nm,
stopChan: make(chan struct{}),
wifiDevices: make(map[string]*wifiDeviceInfo),
nmConn: nm,
stopChan: make(chan struct{}),
ethernetDevices: make(map[string]*ethernetDeviceInfo),
wifiDevices: make(map[string]*wifiDeviceInfo),
state: &BackendState{
Backend: "networkmanager",
},
@@ -113,11 +140,30 @@ func (b *NetworkManagerBackend) Initialize() error {
if managed, _ := dev.GetPropertyManaged(); !managed {
continue
}
b.ethernetDevice = dev
iface, err := dev.GetPropertyInterface()
if err != nil {
continue
}
w, err := gonetworkmanager.NewDeviceWired(dev.GetPath())
if err != nil {
continue
}
hwAddr, _ := w.GetPropertyHwAddress()
b.ethernetDevices[iface] = &ethernetDeviceInfo{
device: dev,
wired: w,
name: iface,
hwAddress: hwAddr,
}
if b.ethernetDevice == nil {
b.ethernetDevice = dev
}
if err := b.updateEthernetState(); err != nil {
continue
}
_, err := b.listEthernetConnections()
_, err = b.listEthernetConnections()
if err != nil {
return fmt.Errorf("failed to get wired configurations: %w", err)
}
@@ -165,6 +211,8 @@ func (b *NetworkManagerBackend) Initialize() error {
b.updateAllWiFiDevices()
}
b.updateAllEthernetDevices()
if err := b.updatePrimaryConnection(); err != nil {
return err
}
@@ -197,6 +245,7 @@ func (b *NetworkManagerBackend) GetCurrentState() (*BackendState, error) {
state.WiFiNetworks = append([]WiFiNetwork(nil), b.state.WiFiNetworks...)
state.WiFiDevices = append([]WiFiDevice(nil), b.state.WiFiDevices...)
state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...)
state.EthernetDevices = append([]EthernetDevice(nil), b.state.EthernetDevices...)
state.VPNProfiles = append([]VPNProfile(nil), b.state.VPNProfiles...)
state.VPNActive = append([]VPNActive(nil), b.state.VPNActive...)

View File

@@ -315,3 +315,88 @@ func (b *NetworkManagerBackend) listEthernetConnections() ([]WiredConnection, er
return wiredConfigs, nil
}
func (b *NetworkManagerBackend) GetEthernetDevices() []EthernetDevice {
b.stateMutex.RLock()
defer b.stateMutex.RUnlock()
return append([]EthernetDevice(nil), b.state.EthernetDevices...)
}
func (b *NetworkManagerBackend) DisconnectEthernetDevice(device string) error {
info, ok := b.ethernetDevices[device]
if !ok {
return fmt.Errorf("ethernet device %s not found", device)
}
if err := info.device.Disconnect(); err != nil {
return fmt.Errorf("failed to disconnect %s: %w", device, err)
}
b.updateAllEthernetDevices()
b.updateEthernetState()
b.listEthernetConnections()
b.updatePrimaryConnection()
if b.onStateChange != nil {
b.onStateChange()
}
return nil
}
func (b *NetworkManagerBackend) updateAllEthernetDevices() {
devices := make([]EthernetDevice, 0, len(b.ethernetDevices))
for name, info := range b.ethernetDevices {
state, _ := info.device.GetPropertyState()
connected := state == gonetworkmanager.NmDeviceStateActivated
driver, _ := info.device.GetPropertyDriver()
var ip string
var speed uint32 = 0
if connected {
ip = b.getDeviceIP(info.device)
}
if info.wired != nil {
speed, _ = info.wired.GetPropertySpeed()
}
stateStr := "disconnected"
switch state {
case gonetworkmanager.NmDeviceStateActivated:
stateStr = "activated"
case gonetworkmanager.NmDeviceStatePrepare:
stateStr = "preparing"
case gonetworkmanager.NmDeviceStateConfig:
stateStr = "configuring"
case gonetworkmanager.NmDeviceStateIpConfig:
stateStr = "ip-config"
case gonetworkmanager.NmDeviceStateIpCheck:
stateStr = "ip-check"
case gonetworkmanager.NmDeviceStateSecondaries:
stateStr = "secondaries"
case gonetworkmanager.NmDeviceStateDeactivating:
stateStr = "deactivating"
case gonetworkmanager.NmDeviceStateFailed:
stateStr = "failed"
case gonetworkmanager.NmDeviceStateUnavailable:
stateStr = "unavailable"
case gonetworkmanager.NmDeviceStateUnmanaged:
stateStr = "unmanaged"
}
devices = append(devices, EthernetDevice{
Name: name,
HwAddress: info.hwAddress,
State: stateStr,
Connected: connected,
IP: ip,
Speed: speed,
Driver: driver,
})
}
b.stateMutex.Lock()
b.state.EthernetDevices = devices
b.stateMutex.Unlock()
}

View File

@@ -82,3 +82,53 @@ func TestNetworkManagerBackend_ListEthernetConnections_NoDevice(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "no ethernet device available")
}
func TestNetworkManagerBackend_GetEthernetDevices_Empty(t *testing.T) {
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
backend, err := NewNetworkManagerBackend(mockNM)
assert.NoError(t, err)
devices := backend.GetEthernetDevices()
assert.Empty(t, devices)
}
func TestNetworkManagerBackend_GetEthernetDevices_WithState(t *testing.T) {
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
backend, err := NewNetworkManagerBackend(mockNM)
assert.NoError(t, err)
backend.state.EthernetDevices = []EthernetDevice{
{Name: "enp0s3", HwAddress: "00:11:22:33:44:55", State: "activated", Connected: true, IP: "192.168.1.100"},
{Name: "enp0s8", HwAddress: "00:11:22:33:44:66", State: "disconnected", Connected: false},
}
devices := backend.GetEthernetDevices()
assert.Len(t, devices, 2)
assert.Equal(t, "enp0s3", devices[0].Name)
assert.True(t, devices[0].Connected)
assert.Equal(t, "enp0s8", devices[1].Name)
assert.False(t, devices[1].Connected)
}
func TestNetworkManagerBackend_DisconnectEthernetDevice_NotFound(t *testing.T) {
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
backend, err := NewNetworkManagerBackend(mockNM)
assert.NoError(t, err)
err = backend.DisconnectEthernetDevice("nonexistent")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestNetworkManagerBackend_UpdateAllEthernetDevices_Empty(t *testing.T) {
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
backend, err := NewNetworkManagerBackend(mockNM)
assert.NoError(t, err)
backend.updateAllEthernetDevices()
assert.Empty(t, backend.state.EthernetDevices)
}

View File

@@ -61,6 +61,26 @@ func (b *NetworkManagerBackend) startSignalPump() error {
return err
}
if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
dbus.WithMatchInterface(dbusNMInterface),
dbus.WithMatchMember("DeviceAdded"),
); err != nil {
conn.RemoveSignal(signals)
conn.Close()
return err
}
if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
dbus.WithMatchInterface(dbusNMInterface),
dbus.WithMatchMember("DeviceRemoved"),
); err != nil {
conn.RemoveSignal(signals)
conn.Close()
return err
}
if b.wifiDevice != nil {
dev := b.wifiDevice.(gonetworkmanager.Device)
if err := conn.AddMatchSignal(
@@ -175,6 +195,24 @@ func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) {
return
}
if sig.Name == "org.freedesktop.NetworkManager.DeviceAdded" {
if len(sig.Body) >= 1 {
if devicePath, ok := sig.Body[0].(dbus.ObjectPath); ok {
b.handleDeviceAdded(devicePath)
}
}
return
}
if sig.Name == "org.freedesktop.NetworkManager.DeviceRemoved" {
if len(sig.Body) >= 1 {
if devicePath, ok := sig.Body[0].(dbus.ObjectPath); ok {
b.handleDeviceRemoved(devicePath)
}
}
return
}
if len(sig.Body) < 2 {
return
}
@@ -319,3 +357,156 @@ func (b *NetworkManagerBackend) handleAccessPointChange(changes map[string]dbus.
}
}
}
func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
dev, err := gonetworkmanager.NewDevice(devicePath)
if err != nil {
return
}
devType, err := dev.GetPropertyDeviceType()
if err != nil {
return
}
managed, _ := dev.GetPropertyManaged()
if !managed {
return
}
iface, err := dev.GetPropertyInterface()
if err != nil {
return
}
switch devType {
case gonetworkmanager.NmDeviceTypeEthernet:
w, err := gonetworkmanager.NewDeviceWired(devicePath)
if err != nil {
return
}
hwAddr, _ := w.GetPropertyHwAddress()
b.ethernetDevices[iface] = &ethernetDeviceInfo{
device: dev,
wired: w,
name: iface,
hwAddress: hwAddr,
}
if b.ethernetDevice == nil {
b.ethernetDevice = dev
}
if b.dbusConn != nil {
b.dbusConn.AddMatchSignal(
dbus.WithMatchObjectPath(devicePath),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
}
b.updateAllEthernetDevices()
b.updateEthernetState()
b.listEthernetConnections()
b.updatePrimaryConnection()
case gonetworkmanager.NmDeviceTypeWifi:
w, err := gonetworkmanager.NewDeviceWireless(devicePath)
if err != nil {
return
}
hwAddr, _ := w.GetPropertyHwAddress()
b.wifiDevices[iface] = &wifiDeviceInfo{
device: dev,
wireless: w,
name: iface,
hwAddress: hwAddr,
}
if b.wifiDevice == nil {
b.wifiDevice = dev
b.wifiDev = w
}
if b.dbusConn != nil {
b.dbusConn.AddMatchSignal(
dbus.WithMatchObjectPath(devicePath),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
}
b.updateAllWiFiDevices()
b.updateWiFiState()
}
if b.onStateChange != nil {
b.onStateChange()
}
}
func (b *NetworkManagerBackend) handleDeviceRemoved(devicePath dbus.ObjectPath) {
if b.dbusConn != nil {
b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(devicePath),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
}
for iface, info := range b.ethernetDevices {
if info.device.GetPath() == devicePath {
delete(b.ethernetDevices, iface)
if b.ethernetDevice != nil {
dev := b.ethernetDevice.(gonetworkmanager.Device)
if dev.GetPath() == devicePath {
b.ethernetDevice = nil
for _, remaining := range b.ethernetDevices {
b.ethernetDevice = remaining.device
break
}
}
}
b.updateAllEthernetDevices()
b.updateEthernetState()
b.listEthernetConnections()
b.updatePrimaryConnection()
if b.onStateChange != nil {
b.onStateChange()
}
return
}
}
for iface, info := range b.wifiDevices {
if info.device.GetPath() == devicePath {
delete(b.wifiDevices, iface)
if b.wifiDevice != nil {
dev := b.wifiDevice.(gonetworkmanager.Device)
if dev.GetPath() == devicePath {
b.wifiDevice = nil
b.wifiDev = nil
for _, remaining := range b.wifiDevices {
b.wifiDevice = remaining.device
b.wifiDev = remaining.wireless
break
}
}
}
b.updateAllWiFiDevices()
b.updateWiFiState()
if b.onStateChange != nil {
b.onStateChange()
}
return
}
}
}

View File

@@ -72,33 +72,34 @@ func (b *NetworkManagerBackend) updatePrimaryConnection() error {
}
func (b *NetworkManagerBackend) updateEthernetState() error {
if b.ethernetDevice == nil {
return nil
var connectedDevice string
var connectedIP string
var anyConnected bool
for name, info := range b.ethernetDevices {
state, err := info.device.GetPropertyState()
if err != nil {
continue
}
if state == gonetworkmanager.NmDeviceStateActivated {
anyConnected = true
connectedDevice = name
connectedIP = b.getDeviceIP(info.device)
break
}
}
dev := b.ethernetDevice.(gonetworkmanager.Device)
iface, err := dev.GetPropertyInterface()
if err != nil {
return err
}
state, err := dev.GetPropertyState()
if err != nil {
return err
}
connected := state == gonetworkmanager.NmDeviceStateActivated
var ip string
if connected {
ip = b.getDeviceIP(dev)
if !anyConnected && b.ethernetDevice != nil {
dev := b.ethernetDevice.(gonetworkmanager.Device)
iface, _ := dev.GetPropertyInterface()
connectedDevice = iface
}
b.stateMutex.Lock()
b.state.EthernetDevice = iface
b.state.EthernetConnected = connected
b.state.EthernetIP = ip
b.state.EthernetDevice = connectedDevice
b.state.EthernetConnected = anyConnected
b.state.EthernetIP = connectedIP
b.stateMutex.Unlock()
return nil

View File

@@ -1,13 +1,19 @@
package network
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/Wifx/gonetworkmanager/v2"
"github.com/godbus/dbus/v5"
)
func (b *NetworkManagerBackend) ListVPNProfiles() ([]VPNProfile, error) {
@@ -46,11 +52,13 @@ func (b *NetworkManagerBackend) ListVPNProfiles() ([]VPNProfile, error) {
connID, _ := connMeta["id"].(string)
connUUID, _ := connMeta["uuid"].(string)
autoconnect, _ := connMeta["autoconnect"].(bool)
profile := VPNProfile{
Name: connID,
UUID: connUUID,
Type: connType,
Name: connID,
UUID: connUUID,
Type: connType,
Autoconnect: autoconnect,
}
if connType == "vpn" {
@@ -58,6 +66,16 @@ func (b *NetworkManagerBackend) ListVPNProfiles() ([]VPNProfile, error) {
if svcType, ok := vpnSettings["service-type"].(string); ok {
profile.ServiceType = svcType
}
// Get full data map
if data, ok := vpnSettings["data"].(map[string]string); ok {
profile.Data = data
if remote, ok := data["remote"]; ok {
profile.RemoteHost = remote
}
if username, ok := data["username"]; ok {
profile.Username = username
}
}
}
}
@@ -120,6 +138,31 @@ func (b *NetworkManagerBackend) ListActiveVPN() ([]VPNActive, error) {
Plugin: "",
}
// Get VPN device
devices, _ := activeConn.GetPropertyDevices()
if len(devices) > 0 {
if iface, err := devices[0].GetPropertyInterface(); err == nil {
vpnActive.Device = iface
}
}
// Get VPN IP from IP4Config
if ip4Config, err := activeConn.GetPropertyIP4Config(); err == nil && ip4Config != nil {
if addrData, err := ip4Config.GetPropertyAddressData(); err == nil && len(addrData) > 0 {
vpnActive.IP = addrData[0].Address
}
if gw, err := ip4Config.GetPropertyGateway(); err == nil {
vpnActive.Gateway = gw
}
}
// Get MTU from device
if len(devices) > 0 {
if mtu, err := devices[0].GetPropertyMtu(); err == nil {
vpnActive.MTU = mtu
}
}
if connType == "vpn" {
conn, _ := activeConn.GetPropertyConnection()
if conn != nil {
@@ -129,6 +172,16 @@ func (b *NetworkManagerBackend) ListActiveVPN() ([]VPNActive, error) {
if svcType, ok := vpnSettings["service-type"].(string); ok {
vpnActive.Plugin = svcType
}
// Get full data map
if data, ok := vpnSettings["data"].(map[string]string); ok {
vpnActive.Data = data
if remote, ok := data["remote"]; ok {
vpnActive.RemoteHost = remote
}
if username, ok := data["username"]; ok {
vpnActive.Username = username
}
}
}
}
}
@@ -219,10 +272,122 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
}
var targetUUID string
var connName string
if connMeta, ok := targetSettings["connection"]; ok {
if uuid, ok := connMeta["uuid"].(string); ok {
targetUUID = uuid
}
if id, ok := connMeta["id"].(string); ok {
connName = id
}
}
needsUsernamePrePrompt := false
var vpnServiceType string
if vpnSettings, ok := targetSettings["vpn"]; ok {
if svc, ok := vpnSettings["service-type"].(string); ok {
vpnServiceType = svc
}
if data, ok := vpnSettings["data"].(map[string]string); ok {
connType := data["connection-type"]
username := data["username"]
// OpenVPN password auth needs username in vpn.data
if strings.Contains(vpnServiceType, "openvpn") &&
(connType == "password" || connType == "password-tls") &&
username == "" {
needsUsernamePrePrompt = true
}
}
}
// If username is needed but missing, prompt for it before activating
if needsUsernamePrePrompt && b.promptBroker != nil {
log.Infof("[ConnectVPN] OpenVPN requires username in vpn.data - prompting before activation")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
token, err := b.promptBroker.Ask(ctx, PromptRequest{
Name: connName,
ConnType: "vpn",
VpnService: vpnServiceType,
SettingName: "vpn",
Fields: []string{"username", "password"},
FieldsInfo: []FieldInfo{{Name: "username", Label: "Username", IsSecret: false}, {Name: "password", Label: "Password", IsSecret: true}},
Reason: "required",
ConnectionId: connName,
ConnectionUuid: targetUUID,
ConnectionPath: string(targetConn.GetPath()),
})
if err != nil {
return fmt.Errorf("failed to request credentials: %w", err)
}
reply, err := b.promptBroker.Wait(ctx, token)
if err != nil {
return fmt.Errorf("credentials prompt failed: %w", err)
}
username := reply.Secrets["username"]
password := reply.Secrets["password"]
if username != "" {
connObj := b.dbusConn.Object("org.freedesktop.NetworkManager", targetConn.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 settings for username save: %w", err)
}
settings := make(map[string]map[string]dbus.Variant)
if connSection, ok := existingSettings["connection"]; ok {
settings["connection"] = connSection
}
vpn := existingSettings["vpn"]
var data map[string]string
if dataVariant, ok := vpn["data"]; ok {
if dm, ok := dataVariant.Value().(map[string]string); ok {
data = make(map[string]string)
for k, v := range dm {
data[k] = v
}
} else {
data = make(map[string]string)
}
} else {
data = make(map[string]string)
}
data["username"] = username
if reply.Save && password != "" {
data["password-flags"] = "0"
secs := make(map[string]string)
secs["password"] = password
vpn["secrets"] = dbus.MakeVariant(secs)
log.Infof("[ConnectVPN] Saving username and password to vpn.data")
} else {
log.Infof("[ConnectVPN] Saving username to vpn.data (password will be prompted)")
}
vpn["data"] = dbus.MakeVariant(data)
settings["vpn"] = vpn
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 save username: %w", err)
}
log.Infof("[ConnectVPN] Username saved to connection, now activating")
if password != "" && !reply.Save {
b.cachedVPNCredsMu.Lock()
b.cachedVPNCreds = &cachedVPNCredentials{
ConnectionUUID: targetUUID,
Password: password,
SavePassword: reply.Save,
}
b.cachedVPNCredsMu.Unlock()
log.Infof("[ConnectVPN] Cached password for GetSecrets")
}
}
}
b.stateMutex.Lock()
@@ -470,7 +635,7 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
continue
}
uuid, err := activeConn.GetPropertyUUID()
connUUID, err := activeConn.GetPropertyUUID()
if err != nil {
continue
}
@@ -478,20 +643,29 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
state, _ := activeConn.GetPropertyState()
stateReason, _ := activeConn.GetPropertyStateFlags()
if uuid == connectingVPNUUID {
if connUUID == connectingVPNUUID {
foundConnection = true
switch state {
case 2:
log.Infof("[updateVPNConnectionState] VPN connection successful: %s", uuid)
log.Infof("[updateVPNConnectionState] VPN connection successful: %s", connUUID)
b.stateMutex.Lock()
b.state.IsConnectingVPN = false
b.state.ConnectingVPNUUID = ""
b.state.LastError = ""
b.stateMutex.Unlock()
b.pendingVPNSaveMu.Lock()
pending := b.pendingVPNSave
b.pendingVPNSave = nil
b.pendingVPNSaveMu.Unlock()
if pending != nil {
go b.saveVPNCredentials(pending)
}
return
case 4:
log.Warnf("[updateVPNConnectionState] VPN connection failed/deactivated: %s (state=%d, flags=%d)", uuid, state, stateReason)
log.Warnf("[updateVPNConnectionState] VPN connection failed/deactivated: %s (state=%d, flags=%d)", connUUID, state, stateReason)
b.stateMutex.Lock()
b.state.IsConnectingVPN = false
b.state.ConnectingVPNUUID = ""
@@ -511,3 +685,622 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
b.stateMutex.Unlock()
}
}
func (b *NetworkManagerBackend) saveVPNCredentials(creds *pendingVPNCredentials) {
log.Infof("[saveVPNCredentials] Saving credentials for %s (username=%v, savePassword=%v)",
creds.ConnectionPath, creds.Username != "", creds.SavePassword)
connObj := b.dbusConn.Object("org.freedesktop.NetworkManager", dbus.ObjectPath(creds.ConnectionPath))
var existingSettings map[string]map[string]dbus.Variant
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existingSettings); err != nil {
log.Warnf("[saveVPNCredentials] GetSettings failed: %v", err)
return
}
settings := make(map[string]map[string]dbus.Variant)
if connSection, ok := existingSettings["connection"]; ok {
settings["connection"] = connSection
}
vpn, ok := existingSettings["vpn"]
if !ok {
vpn = make(map[string]dbus.Variant)
}
// Get existing data map
var data map[string]string
if dataVariant, ok := vpn["data"]; ok {
if dm, ok := dataVariant.Value().(map[string]string); ok {
data = make(map[string]string)
for k, v := range dm {
data[k] = v
}
} else {
data = make(map[string]string)
}
} else {
data = make(map[string]string)
}
// Always save username if provided
if creds.Username != "" {
data["username"] = creds.Username
log.Infof("[saveVPNCredentials] Saving username")
}
// Save password if requested
if creds.SavePassword {
data["password-flags"] = "0"
secs := make(map[string]string)
secs["password"] = creds.Password
vpn["secrets"] = dbus.MakeVariant(secs)
log.Infof("[saveVPNCredentials] Saving password with password-flags=0")
}
vpn["data"] = dbus.MakeVariant(data)
settings["vpn"] = vpn
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 {
log.Warnf("[saveVPNCredentials] Update2 failed: %v", err)
} else {
log.Infof("[saveVPNCredentials] Successfully saved credentials")
}
}
func (b *NetworkManagerBackend) ListVPNPlugins() ([]VPNPlugin, error) {
plugins := []VPNPlugin{}
pluginDirs := []string{
"/usr/lib/NetworkManager/VPN",
"/usr/lib64/NetworkManager/VPN",
"/etc/NetworkManager/VPN",
}
seen := make(map[string]bool)
for _, dir := range pluginDirs {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, entry := range entries {
if !strings.HasSuffix(entry.Name(), ".name") {
continue
}
filePath := filepath.Join(dir, entry.Name())
plugin, err := parseVPNPluginFile(filePath)
if err != nil {
log.Debugf("Failed to parse VPN plugin file %s: %v", filePath, err)
continue
}
if seen[plugin.ServiceType] {
continue
}
seen[plugin.ServiceType] = true
plugins = append(plugins, *plugin)
}
}
sort.Slice(plugins, func(i, j int) bool {
return strings.ToLower(plugins[i].Name) < strings.ToLower(plugins[j].Name)
})
return plugins, nil
}
func parseVPNPluginFile(path string) (*VPNPlugin, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
plugin := &VPNPlugin{}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "[") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "name":
plugin.Name = value
case "service":
plugin.ServiceType = value
case "program":
plugin.Program = value
case "supports":
plugin.Supports = strings.Split(value, ",")
for i := range plugin.Supports {
plugin.Supports[i] = strings.TrimSpace(plugin.Supports[i])
}
}
}
if plugin.ServiceType == "" {
return nil, fmt.Errorf("plugin file missing service type")
}
plugin.FileExtensions = getVPNFileExtensions(plugin.ServiceType)
return plugin, nil
}
func getVPNFileExtensions(serviceType string) []string {
switch {
case strings.Contains(serviceType, "openvpn"):
return []string{".ovpn", ".conf"}
case strings.Contains(serviceType, "wireguard"):
return []string{".conf"}
case strings.Contains(serviceType, "vpnc"), strings.Contains(serviceType, "cisco"):
return []string{".pcf", ".conf"}
case strings.Contains(serviceType, "openconnect"):
return []string{".conf"}
case strings.Contains(serviceType, "pptp"):
return []string{".conf"}
case strings.Contains(serviceType, "l2tp"):
return []string{".conf"}
case strings.Contains(serviceType, "strongswan"), strings.Contains(serviceType, "ipsec"):
return []string{".conf", ".sswan"}
default:
return []string{".conf"}
}
}
func (b *NetworkManagerBackend) ImportVPN(filePath string, name string) (*VPNImportResult, error) {
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return &VPNImportResult{
Success: false,
Error: fmt.Sprintf("file not found: %s", filePath),
}, nil
}
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".ovpn", ".conf":
return b.importVPNWithNmcli(filePath, name)
default:
return b.importVPNWithNmcli(filePath, name)
}
}
func (b *NetworkManagerBackend) importVPNWithNmcli(filePath string, name string) (*VPNImportResult, error) {
args := []string{"connection", "import", "type", "openvpn", "file", filePath}
cmd := exec.Command("nmcli", args...)
output, err := cmd.CombinedOutput()
if err != nil {
outputStr := string(output)
if strings.Contains(outputStr, "vpnc") || strings.Contains(outputStr, "unknown connection type") {
for _, vpnType := range []string{"vpnc", "pptp", "l2tp", "openconnect", "strongswan", "wireguard"} {
args = []string{"connection", "import", "type", vpnType, "file", filePath}
cmd = exec.Command("nmcli", args...)
output, err = cmd.CombinedOutput()
if err == nil {
break
}
}
}
if err != nil {
return &VPNImportResult{
Success: false,
Error: fmt.Sprintf("import failed: %s", strings.TrimSpace(string(output))),
}, nil
}
}
outputStr := string(output)
var connUUID, connName string
lines := strings.Split(outputStr, "\n")
for _, line := range lines {
if strings.Contains(line, "successfully added") {
parts := strings.Fields(line)
for i, part := range parts {
if part == "(" && i+1 < len(parts) {
connUUID = strings.TrimSuffix(parts[i+1], ")")
break
}
}
}
}
if name != "" && connUUID != "" {
renameCmd := exec.Command("nmcli", "connection", "modify", connUUID, "connection.id", name)
if err := renameCmd.Run(); err != nil {
log.Warnf("Failed to rename imported VPN: %v", err)
} else {
connName = name
}
}
if connUUID == "" {
s := b.settings
if s == nil {
var settingsErr error
s, settingsErr = gonetworkmanager.NewSettings()
if settingsErr == nil {
b.settings = s
}
}
if s != nil {
settingsMgr := s.(gonetworkmanager.Settings)
connections, _ := settingsMgr.ListConnections()
baseName := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
for _, conn := range connections {
settings, err := conn.GetSettings()
if err != nil {
continue
}
connMeta, ok := settings["connection"]
if !ok {
continue
}
connType, _ := connMeta["type"].(string)
if connType != "vpn" && connType != "wireguard" {
continue
}
connID, _ := connMeta["id"].(string)
if strings.Contains(connID, baseName) || (name != "" && connID == name) {
connUUID, _ = connMeta["uuid"].(string)
connName = connID
break
}
}
}
}
b.ListVPNProfiles()
if b.onStateChange != nil {
b.onStateChange()
}
return &VPNImportResult{
Success: true,
UUID: connUUID,
Name: connName,
}, nil
}
func (b *NetworkManagerBackend) GetVPNConfig(uuidOrName string) (*VPNConfig, error) {
s := b.settings
if s == nil {
var err error
s, err = gonetworkmanager.NewSettings()
if err != nil {
return nil, fmt.Errorf("failed to get settings: %w", err)
}
b.settings = s
}
settingsMgr := s.(gonetworkmanager.Settings)
connections, err := settingsMgr.ListConnections()
if err != nil {
return nil, fmt.Errorf("failed to get connections: %w", err)
}
for _, conn := range connections {
settings, err := conn.GetSettings()
if err != nil {
continue
}
connMeta, ok := settings["connection"]
if !ok {
continue
}
connType, _ := connMeta["type"].(string)
if connType != "vpn" && connType != "wireguard" {
continue
}
connID, _ := connMeta["id"].(string)
connUUID, _ := connMeta["uuid"].(string)
if connUUID != uuidOrName && connID != uuidOrName {
continue
}
autoconnect := true
if ac, ok := connMeta["autoconnect"].(bool); ok {
autoconnect = ac
}
config := &VPNConfig{
UUID: connUUID,
Name: connID,
Type: connType,
Autoconnect: autoconnect,
Data: make(map[string]string),
}
if connType == "vpn" {
if vpnSettings, ok := settings["vpn"]; ok {
if svcType, ok := vpnSettings["service-type"].(string); ok {
config.ServiceType = svcType
}
if dataMap, ok := vpnSettings["data"].(map[string]string); ok {
for k, v := range dataMap {
if !strings.Contains(strings.ToLower(k), "password") &&
!strings.Contains(strings.ToLower(k), "secret") &&
!strings.Contains(strings.ToLower(k), "key") {
config.Data[k] = v
}
}
}
}
}
return config, nil
}
return nil, fmt.Errorf("VPN connection not found: %s", uuidOrName)
}
func (b *NetworkManagerBackend) UpdateVPNConfig(connUUID string, updates map[string]interface{}) error {
s := b.settings
if s == nil {
var err error
s, err = gonetworkmanager.NewSettings()
if err != nil {
return fmt.Errorf("failed to get settings: %w", err)
}
b.settings = s
}
settingsMgr := s.(gonetworkmanager.Settings)
connections, err := settingsMgr.ListConnections()
if err != nil {
return fmt.Errorf("failed to get connections: %w", err)
}
for _, conn := range connections {
settings, err := conn.GetSettings()
if err != nil {
continue
}
connMeta, ok := settings["connection"]
if !ok {
continue
}
connType, _ := connMeta["type"].(string)
if connType != "vpn" && connType != "wireguard" {
continue
}
existingUUID, _ := connMeta["uuid"].(string)
if existingUUID != connUUID {
continue
}
if name, ok := updates["name"].(string); ok && name != "" {
connMeta["id"] = name
}
if autoconnect, ok := updates["autoconnect"].(bool); ok {
connMeta["autoconnect"] = autoconnect
}
if data, ok := updates["data"].(map[string]interface{}); ok {
if vpnSettings, ok := settings["vpn"]; ok {
existingData, _ := vpnSettings["data"].(map[string]string)
if existingData == nil {
existingData = make(map[string]string)
}
for k, v := range data {
if strVal, ok := v.(string); ok {
existingData[k] = strVal
}
}
vpnSettings["data"] = existingData
}
}
if ipv4, ok := settings["ipv4"]; ok {
delete(ipv4, "addresses")
delete(ipv4, "routes")
delete(ipv4, "dns")
}
if ipv6, ok := settings["ipv6"]; ok {
delete(ipv6, "addresses")
delete(ipv6, "routes")
delete(ipv6, "dns")
}
if err := conn.Update(settings); err != nil {
return fmt.Errorf("failed to update connection: %w", err)
}
b.ListVPNProfiles()
if b.onStateChange != nil {
b.onStateChange()
}
return nil
}
return fmt.Errorf("VPN connection not found: %s", connUUID)
}
func (b *NetworkManagerBackend) SetVPNCredentials(connUUID string, username string, password string, saveToKeyring bool) error {
s := b.settings
if s == nil {
var err error
s, err = gonetworkmanager.NewSettings()
if err != nil {
return fmt.Errorf("failed to get settings: %w", err)
}
b.settings = s
}
settingsMgr := s.(gonetworkmanager.Settings)
connections, err := settingsMgr.ListConnections()
if err != nil {
return fmt.Errorf("failed to get connections: %w", err)
}
for _, conn := range connections {
settings, err := conn.GetSettings()
if err != nil {
continue
}
connMeta, ok := settings["connection"]
if !ok {
continue
}
connType, _ := connMeta["type"].(string)
if connType != "vpn" && connType != "wireguard" {
continue
}
existingUUID, _ := connMeta["uuid"].(string)
if existingUUID != connUUID {
continue
}
vpnSettings, ok := settings["vpn"]
if !ok {
vpnSettings = make(map[string]interface{})
settings["vpn"] = vpnSettings
}
existingData, _ := vpnSettings["data"].(map[string]string)
if existingData == nil {
existingData = make(map[string]string)
}
if username != "" {
existingData["username"] = username
}
if saveToKeyring {
existingData["password-flags"] = "0"
} else {
existingData["password-flags"] = "2"
}
vpnSettings["data"] = existingData
if password != "" {
secrets := make(map[string]string)
secrets["password"] = password
vpnSettings["secrets"] = secrets
}
if ipv4, ok := settings["ipv4"]; ok {
delete(ipv4, "addresses")
delete(ipv4, "routes")
delete(ipv4, "dns")
}
if ipv6, ok := settings["ipv6"]; ok {
delete(ipv6, "addresses")
delete(ipv6, "routes")
delete(ipv6, "dns")
}
if err := conn.Update(settings); err != nil {
return fmt.Errorf("failed to update connection: %w", err)
}
log.Infof("Updated VPN credentials for %s (save=%v)", connUUID, saveToKeyring)
if b.onStateChange != nil {
b.onStateChange()
}
return nil
}
return fmt.Errorf("VPN connection not found: %s", connUUID)
}
func (b *NetworkManagerBackend) DeleteVPN(uuidOrName string) error {
active, _ := b.ListActiveVPN()
for _, vpn := range active {
if vpn.UUID == uuidOrName || vpn.Name == uuidOrName {
if err := b.DisconnectVPN(uuidOrName); err != nil {
log.Warnf("Failed to disconnect VPN before deletion: %v", err)
}
time.Sleep(200 * time.Millisecond)
break
}
}
s := b.settings
if s == nil {
var err error
s, err = gonetworkmanager.NewSettings()
if err != nil {
return fmt.Errorf("failed to get settings: %w", err)
}
b.settings = s
}
settingsMgr := s.(gonetworkmanager.Settings)
connections, err := settingsMgr.ListConnections()
if err != nil {
return fmt.Errorf("failed to get connections: %w", err)
}
for _, conn := range connections {
settings, err := conn.GetSettings()
if err != nil {
continue
}
connMeta, ok := settings["connection"]
if !ok {
continue
}
connType, _ := connMeta["type"].(string)
if connType != "vpn" && connType != "wireguard" {
continue
}
connID, _ := connMeta["id"].(string)
connUUID, _ := connMeta["uuid"].(string)
if connUUID == uuidOrName || connID == uuidOrName {
if err := conn.Delete(); err != nil {
return fmt.Errorf("failed to delete VPN: %w", err)
}
b.ListVPNProfiles()
if b.onStateChange != nil {
b.onStateChange()
}
log.Infof("Deleted VPN connection: %s (%s)", connID, connUUID)
return nil
}
}
return fmt.Errorf("VPN connection not found: %s", uuidOrName)
}

View File

@@ -579,31 +579,59 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
"key-mgmt": "wpa-eap",
}
eapMethod := "peap"
if req.EAPMethod != "" {
eapMethod = req.EAPMethod
}
phase2Auth := "mschapv2"
if req.Phase2Auth != "" {
phase2Auth = req.Phase2Auth
}
useSystemCACerts := false
if req.UseSystemCACerts != nil {
useSystemCACerts = *req.UseSystemCACerts
}
x := map[string]interface{}{
"eap": []string{"peap"},
"phase2-auth": "mschapv2",
"system-ca-certs": false,
"eap": []string{eapMethod},
"system-ca-certs": useSystemCACerts,
"password-flags": uint32(0),
}
switch eapMethod {
case "peap", "ttls":
x["phase2-auth"] = phase2Auth
case "tls":
if req.ClientCertPath != "" {
x["client-cert"] = []byte("file://" + req.ClientCertPath)
}
if req.PrivateKeyPath != "" {
x["private-key"] = []byte("file://" + req.PrivateKeyPath)
}
}
if req.Username != "" {
x["identity"] = req.Username
}
if req.Password != "" {
x["password"] = req.Password
}
if req.AnonymousIdentity != "" {
x["anonymous-identity"] = req.AnonymousIdentity
}
if req.DomainSuffixMatch != "" {
x["domain-suffix-match"] = req.DomainSuffixMatch
}
if req.CACertPath != "" {
x["ca-cert"] = []byte("file://" + req.CACertPath)
}
settings["802-1x"] = x
log.Infof("[createAndConnectWiFi] WPA-EAP settings: eap=peap, phase2-auth=mschapv2, identity=%s, interactive=%v, system-ca-certs=%v, domain-suffix-match=%q",
req.Username, req.Interactive, x["system-ca-certs"], req.DomainSuffixMatch)
log.Infof("[createAndConnectWiFi] WPA-EAP settings: eap=%s, phase2-auth=%s, identity=%s, interactive=%v, system-ca-certs=%v, domain-suffix-match=%q",
eapMethod, phase2Auth, req.Username, req.Interactive, useSystemCACerts, req.DomainSuffixMatch)
case isPsk:
sec := map[string]interface{}{

View File

@@ -70,6 +70,18 @@ func HandleRequest(conn net.Conn, req Request, manager *Manager) {
handleDisconnectAllVPN(conn, req, manager)
case "network.vpn.clearCredentials":
handleClearVPNCredentials(conn, req, manager)
case "network.vpn.plugins":
handleListVPNPlugins(conn, req, manager)
case "network.vpn.import":
handleImportVPN(conn, req, manager)
case "network.vpn.getConfig":
handleGetVPNConfig(conn, req, manager)
case "network.vpn.updateConfig":
handleUpdateVPNConfig(conn, req, manager)
case "network.vpn.delete":
handleDeleteVPN(conn, req, manager)
case "network.vpn.setCredentials":
handleSetVPNCredentials(conn, req, manager)
case "network.wifi.setAutoconnect":
handleSetWiFiAutoconnect(conn, req, manager)
default:
@@ -200,6 +212,24 @@ func handleConnectWiFi(conn net.Conn, req Request, manager *Manager) {
if domainSuffixMatch, ok := req.Params["domainSuffixMatch"].(string); ok {
connReq.DomainSuffixMatch = domainSuffixMatch
}
if eapMethod, ok := req.Params["eapMethod"].(string); ok {
connReq.EAPMethod = eapMethod
}
if phase2Auth, ok := req.Params["phase2Auth"].(string); ok {
connReq.Phase2Auth = phase2Auth
}
if caCertPath, ok := req.Params["caCertPath"].(string); ok {
connReq.CACertPath = caCertPath
}
if clientCertPath, ok := req.Params["clientCertPath"].(string); ok {
connReq.ClientCertPath = clientCertPath
}
if privateKeyPath, ok := req.Params["privateKeyPath"].(string); ok {
connReq.PrivateKeyPath = privateKeyPath
}
if useSystemCACerts, ok := req.Params["useSystemCACerts"].(bool); ok {
connReq.UseSystemCACerts = &useSystemCACerts
}
if err := manager.ConnectWiFi(connReq); err != nil {
models.RespondError(conn, req.ID, err.Error())
@@ -287,7 +317,14 @@ func handleConnectEthernet(conn net.Conn, req Request, manager *Manager) {
}
func handleDisconnectEthernet(conn net.Conn, req Request, manager *Manager) {
if err := manager.DisconnectEthernet(); err != nil {
device, _ := req.Params["device"].(string)
var err error
if device != "" {
err = manager.DisconnectEthernetDevice(device)
} else {
err = manager.DisconnectEthernet()
}
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
@@ -502,3 +539,138 @@ func handleSetWiFiAutoconnect(conn net.Conn, req Request, manager *Manager) {
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "autoconnect updated"})
}
func handleListVPNPlugins(conn net.Conn, req Request, manager *Manager) {
plugins, err := manager.ListVPNPlugins()
if err != nil {
log.Warnf("handleListVPNPlugins: failed to list plugins: %v", err)
models.RespondError(conn, req.ID, fmt.Sprintf("failed to list VPN plugins: %v", err))
return
}
models.Respond(conn, req.ID, plugins)
}
func handleImportVPN(conn net.Conn, req Request, manager *Manager) {
filePath, ok := req.Params["file"].(string)
if !ok {
filePath, ok = req.Params["path"].(string)
}
if !ok {
models.RespondError(conn, req.ID, "missing 'file' or 'path' parameter")
return
}
name, _ := req.Params["name"].(string)
result, err := manager.ImportVPN(filePath, name)
if err != nil {
log.Warnf("handleImportVPN: failed to import: %v", err)
models.RespondError(conn, req.ID, fmt.Sprintf("failed to import VPN: %v", err))
return
}
models.Respond(conn, req.ID, result)
}
func handleGetVPNConfig(conn net.Conn, req Request, manager *Manager) {
uuidOrName, ok := req.Params["uuid"].(string)
if !ok {
uuidOrName, ok = req.Params["name"].(string)
}
if !ok {
uuidOrName, ok = req.Params["uuidOrName"].(string)
}
if !ok {
models.RespondError(conn, req.ID, "missing 'uuid', 'name', or 'uuidOrName' parameter")
return
}
config, err := manager.GetVPNConfig(uuidOrName)
if err != nil {
log.Warnf("handleGetVPNConfig: failed to get config: %v", err)
models.RespondError(conn, req.ID, fmt.Sprintf("failed to get VPN config: %v", err))
return
}
models.Respond(conn, req.ID, config)
}
func handleUpdateVPNConfig(conn net.Conn, req Request, manager *Manager) {
connUUID, ok := req.Params["uuid"].(string)
if !ok {
models.RespondError(conn, req.ID, "missing 'uuid' parameter")
return
}
updates := make(map[string]interface{})
if name, ok := req.Params["name"].(string); ok {
updates["name"] = name
}
if autoconnect, ok := req.Params["autoconnect"].(bool); ok {
updates["autoconnect"] = autoconnect
}
if data, ok := req.Params["data"].(map[string]interface{}); ok {
updates["data"] = data
}
if len(updates) == 0 {
models.RespondError(conn, req.ID, "no updates provided")
return
}
if err := manager.UpdateVPNConfig(connUUID, updates); err != nil {
log.Warnf("handleUpdateVPNConfig: failed to update: %v", err)
models.RespondError(conn, req.ID, fmt.Sprintf("failed to update VPN config: %v", err))
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "VPN config updated"})
}
func handleDeleteVPN(conn net.Conn, req Request, manager *Manager) {
uuidOrName, ok := req.Params["uuid"].(string)
if !ok {
uuidOrName, ok = req.Params["name"].(string)
}
if !ok {
uuidOrName, ok = req.Params["uuidOrName"].(string)
}
if !ok {
models.RespondError(conn, req.ID, "missing 'uuid', 'name', or 'uuidOrName' parameter")
return
}
if err := manager.DeleteVPN(uuidOrName); err != nil {
log.Warnf("handleDeleteVPN: failed to delete: %v", err)
models.RespondError(conn, req.ID, fmt.Sprintf("failed to delete VPN: %v", err))
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "VPN deleted"})
}
func handleSetVPNCredentials(conn net.Conn, req Request, manager *Manager) {
connUUID, ok := req.Params["uuid"].(string)
if !ok {
models.RespondError(conn, req.ID, "missing 'uuid' parameter")
return
}
username, _ := req.Params["username"].(string)
password, _ := req.Params["password"].(string)
save := true
if saveParam, ok := req.Params["save"].(bool); ok {
save = saveParam
}
if err := manager.SetVPNCredentials(connUUID, username, password, save); err != nil {
log.Warnf("handleSetVPNCredentials: failed to set credentials: %v", err)
models.RespondError(conn, req.ID, fmt.Sprintf("failed to set VPN credentials: %v", err))
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "VPN credentials set"})
}

View File

@@ -109,6 +109,7 @@ func (m *Manager) syncStateFromBackend() error {
m.state.EthernetDevice = backendState.EthernetDevice
m.state.EthernetConnected = backendState.EthernetConnected
m.state.EthernetConnectionUuid = backendState.EthernetConnectionUuid
m.state.EthernetDevices = backendState.EthernetDevices
m.state.WiFiIP = backendState.WiFiIP
m.state.WiFiDevice = backendState.WiFiDevice
m.state.WiFiConnected = backendState.WiFiConnected
@@ -155,6 +156,7 @@ func (m *Manager) snapshotState() NetworkState {
s.WiFiNetworks = append([]WiFiNetwork(nil), m.state.WiFiNetworks...)
s.WiFiDevices = append([]WiFiDevice(nil), m.state.WiFiDevices...)
s.WiredConnections = append([]WiredConnection(nil), m.state.WiredConnections...)
s.EthernetDevices = append([]EthernetDevice(nil), m.state.EthernetDevices...)
s.VPNProfiles = append([]VPNProfile(nil), m.state.VPNProfiles...)
s.VPNActive = append([]VPNActive(nil), m.state.VPNActive...)
return s
@@ -213,6 +215,9 @@ func stateChangedMeaningfully(old, new *NetworkState) bool {
if len(old.WiredConnections) != len(new.WiredConnections) {
return true
}
if len(old.EthernetDevices) != len(new.EthernetDevices) {
return true
}
for i := range old.WiFiNetworks {
oldNet := &old.WiFiNetworks[i]
@@ -242,6 +247,23 @@ func stateChangedMeaningfully(old, new *NetworkState) bool {
}
}
for i := range old.EthernetDevices {
oldDev := &old.EthernetDevices[i]
newDev := &new.EthernetDevices[i]
if oldDev.Name != newDev.Name {
return true
}
if oldDev.Connected != newDev.Connected {
return true
}
if oldDev.State != newDev.State {
return true
}
if oldDev.IP != newDev.IP {
return true
}
}
// Check VPN profiles count
if len(old.VPNProfiles) != len(new.VPNProfiles) {
return true
@@ -480,6 +502,18 @@ func (m *Manager) DisconnectEthernet() error {
return m.backend.DisconnectEthernet()
}
func (m *Manager) DisconnectEthernetDevice(device string) error {
return m.backend.DisconnectEthernetDevice(device)
}
func (m *Manager) GetEthernetDevices() []EthernetDevice {
m.stateMutex.RLock()
defer m.stateMutex.RUnlock()
devices := make([]EthernetDevice, len(m.state.EthernetDevices))
copy(devices, m.state.EthernetDevices)
return devices
}
func (m *Manager) activateConnection(uuid string) error {
return m.backend.ActivateWiredConnection(uuid)
}
@@ -508,6 +542,30 @@ func (m *Manager) ClearVPNCredentials(uuidOrName string) error {
return m.backend.ClearVPNCredentials(uuidOrName)
}
func (m *Manager) ListVPNPlugins() ([]VPNPlugin, error) {
return m.backend.ListVPNPlugins()
}
func (m *Manager) ImportVPN(filePath string, name string) (*VPNImportResult, error) {
return m.backend.ImportVPN(filePath, name)
}
func (m *Manager) GetVPNConfig(uuidOrName string) (*VPNConfig, error) {
return m.backend.GetVPNConfig(uuidOrName)
}
func (m *Manager) UpdateVPNConfig(uuid string, updates map[string]interface{}) error {
return m.backend.UpdateVPNConfig(uuid, updates)
}
func (m *Manager) DeleteVPN(uuidOrName string) error {
return m.backend.DeleteVPN(uuidOrName)
}
func (m *Manager) SetVPNCredentials(uuid, username, password string, save bool) error {
return m.backend.SetVPNCredentials(uuid, username, password, save)
}
func (m *Manager) SetWiFiAutoconnect(ssid string, autoconnect bool) error {
return m.backend.SetWiFiAutoconnect(ssid, autoconnect)
}

View File

@@ -49,6 +49,7 @@ func (b *SubscriptionBroker) Ask(ctx context.Context, req PromptRequest) (string
VpnService: req.VpnService,
Setting: req.SettingName,
Fields: req.Fields,
FieldsInfo: req.FieldsInfo,
Hints: req.Hints,
Reason: req.Reason,
ConnectionId: req.ConnectionId,

View File

@@ -52,20 +52,40 @@ type WiFiDevice struct {
Networks []WiFiNetwork `json:"networks"`
}
type EthernetDevice struct {
Name string `json:"name"`
HwAddress string `json:"hwAddress"`
State string `json:"state"`
Connected bool `json:"connected"`
IP string `json:"ip,omitempty"`
Speed uint32 `json:"speed,omitempty"`
Driver string `json:"driver,omitempty"`
}
type VPNProfile struct {
Name string `json:"name"`
UUID string `json:"uuid"`
Type string `json:"type"`
ServiceType string `json:"serviceType"`
Name string `json:"name"`
UUID string `json:"uuid"`
Type string `json:"type"`
ServiceType string `json:"serviceType"`
RemoteHost string `json:"remoteHost,omitempty"`
Username string `json:"username,omitempty"`
Autoconnect bool `json:"autoconnect"`
Data map[string]string `json:"data,omitempty"`
}
type VPNActive struct {
Name string `json:"name"`
UUID string `json:"uuid"`
Device string `json:"device,omitempty"`
State string `json:"state,omitempty"`
Type string `json:"type"`
Plugin string `json:"serviceType"`
Name string `json:"name"`
UUID string `json:"uuid"`
Device string `json:"device,omitempty"`
State string `json:"state,omitempty"`
Type string `json:"type"`
Plugin string `json:"serviceType"`
IP string `json:"ip,omitempty"`
Gateway string `json:"gateway,omitempty"`
RemoteHost string `json:"remoteHost,omitempty"`
Username string `json:"username,omitempty"`
MTU uint32 `json:"mtu,omitempty"`
Data map[string]string `json:"data,omitempty"`
}
type VPNState struct {
@@ -81,6 +101,7 @@ type NetworkState struct {
EthernetDevice string `json:"ethernetDevice"`
EthernetConnected bool `json:"ethernetConnected"`
EthernetConnectionUuid string `json:"ethernetConnectionUuid"`
EthernetDevices []EthernetDevice `json:"ethernetDevices"`
WiFiIP string `json:"wifiIP"`
WiFiDevice string `json:"wifiDevice"`
WiFiConnected bool `json:"wifiConnected"`
@@ -107,6 +128,12 @@ type ConnectionRequest struct {
DomainSuffixMatch string `json:"domainSuffixMatch,omitempty"`
Interactive bool `json:"interactive,omitempty"`
Device string `json:"device,omitempty"`
EAPMethod string `json:"eapMethod,omitempty"`
Phase2Auth string `json:"phase2Auth,omitempty"`
CACertPath string `json:"caCertPath,omitempty"`
ClientCertPath string `json:"clientCertPath,omitempty"`
PrivateKeyPath string `json:"privateKeyPath,omitempty"`
UseSystemCACerts *bool `json:"useSystemCACerts,omitempty"`
}
type WiredConnection struct {
@@ -150,17 +177,18 @@ type NetworkEvent struct {
}
type PromptRequest struct {
Name string `json:"name"`
SSID string `json:"ssid"`
ConnType string `json:"connType"`
VpnService string `json:"vpnService"`
SettingName string `json:"setting"`
Fields []string `json:"fields"`
Hints []string `json:"hints"`
Reason string `json:"reason"`
ConnectionId string `json:"connectionId"`
ConnectionUuid string `json:"connectionUuid"`
ConnectionPath string `json:"connectionPath"`
Name string `json:"name"`
SSID string `json:"ssid"`
ConnType string `json:"connType"`
VpnService string `json:"vpnService"`
SettingName string `json:"setting"`
Fields []string `json:"fields"`
FieldsInfo []FieldInfo `json:"fieldsInfo"`
Hints []string `json:"hints"`
Reason string `json:"reason"`
ConnectionId string `json:"connectionId"`
ConnectionUuid string `json:"connectionUuid"`
ConnectionPath string `json:"connectionPath"`
}
type PromptReply struct {
@@ -169,18 +197,25 @@ type PromptReply struct {
Cancel bool `json:"cancel"`
}
type FieldInfo struct {
Name string `json:"name"`
Label string `json:"label"`
IsSecret bool `json:"isSecret"`
}
type CredentialPrompt struct {
Token string `json:"token"`
Name string `json:"name"`
SSID string `json:"ssid"`
ConnType string `json:"connType"`
VpnService string `json:"vpnService"`
Setting string `json:"setting"`
Fields []string `json:"fields"`
Hints []string `json:"hints"`
Reason string `json:"reason"`
ConnectionId string `json:"connectionId"`
ConnectionUuid string `json:"connectionUuid"`
Token string `json:"token"`
Name string `json:"name"`
SSID string `json:"ssid"`
ConnType string `json:"connType"`
VpnService string `json:"vpnService"`
Setting string `json:"setting"`
Fields []string `json:"fields"`
FieldsInfo []FieldInfo `json:"fieldsInfo"`
Hints []string `json:"hints"`
Reason string `json:"reason"`
ConnectionId string `json:"connectionId"`
ConnectionUuid string `json:"connectionUuid"`
}
type NetworkInfoResponse struct {
@@ -203,3 +238,28 @@ type WiredIPConfig struct {
Gateway string `json:"gateway"`
DNS string `json:"dns"`
}
type VPNPlugin struct {
Name string `json:"name"`
ServiceType string `json:"serviceType"`
Program string `json:"program,omitempty"`
Supports []string `json:"supports,omitempty"`
FileExtensions []string `json:"fileExtensions"`
}
type VPNConfig struct {
UUID string `json:"uuid"`
Name string `json:"name"`
Type string `json:"type"`
ServiceType string `json:"serviceType,omitempty"`
Autoconnect bool `json:"autoconnect"`
Data map[string]string `json:"data,omitempty"`
}
type VPNImportResult struct {
Success bool `json:"success"`
UUID string `json:"uuid,omitempty"`
Name string `json:"name,omitempty"`
ServiceType string `json:"serviceType,omitempty"`
Error string `json:"error,omitempty"`
}

View File

@@ -21,3 +21,31 @@ func TestManager_GetWiredConfigs(t *testing.T) {
assert.Len(t, configs, 1)
assert.Equal(t, "Test", configs[0].ID)
}
func TestManager_GetEthernetDevices(t *testing.T) {
manager := &Manager{
state: &NetworkState{
EthernetDevices: []EthernetDevice{
{Name: "enp0s3", Connected: true, IP: "192.168.1.100"},
{Name: "enp0s8", Connected: false},
},
},
}
devices := manager.GetEthernetDevices()
assert.Len(t, devices, 2)
assert.Equal(t, "enp0s3", devices[0].Name)
assert.True(t, devices[0].Connected)
assert.Equal(t, "enp0s8", devices[1].Name)
assert.False(t, devices[1].Connected)
}
func TestManager_GetEthernetDevices_Empty(t *testing.T) {
manager := &Manager{
state: &NetworkState{},
}
devices := manager.GetEthernetDevices()
assert.Empty(t, devices)
}

View File

@@ -31,7 +31,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
)
const APIVersion = 20
const APIVersion = 21
type Capabilities struct {
Capabilities []string `json:"capabilities"`
@@ -1073,7 +1073,7 @@ func Start(printDocs bool) error {
log.Info(" network.getState - Get current network state")
log.Info(" network.wifi.scan - Scan for WiFi networks (params: device?)")
log.Info(" network.wifi.networks - Get WiFi network list")
log.Info(" network.wifi.connect - Connect to WiFi (params: ssid, password?, username?, device?)")
log.Info(" network.wifi.connect - Connect to WiFi (params: ssid, password?, username?, device?, eapMethod?, phase2Auth?, caCertPath?, clientCertPath?, privateKeyPath?, useSystemCACerts?)")
log.Info(" network.wifi.disconnect - Disconnect WiFi (params: device?)")
log.Info(" network.wifi.forget - Forget network (params: ssid)")
log.Info(" network.wifi.toggle - Toggle WiFi radio")
@@ -1089,6 +1089,11 @@ func Start(printDocs bool) error {
log.Info(" network.vpn.disconnect - Disconnect VPN (params: uuidOrName|name|uuid)")
log.Info(" network.vpn.disconnectAll - Disconnect all VPNs")
log.Info(" network.vpn.clearCredentials - Clear saved VPN credentials (params: uuidOrName|name|uuid)")
log.Info(" network.vpn.plugins - List available VPN plugins")
log.Info(" network.vpn.import - Import VPN from file (params: file|path, name?)")
log.Info(" network.vpn.getConfig - Get VPN configuration (params: uuid|name|uuidOrName)")
log.Info(" network.vpn.updateConfig - Update VPN configuration (params: uuid, name?, autoconnect?, data?)")
log.Info(" network.vpn.delete - Delete VPN connection (params: uuid|name|uuidOrName)")
log.Info(" network.preference.set - Set preference (params: preference [auto|wifi|ethernet])")
log.Info(" network.info - Get network info (params: ssid)")
log.Info(" network.credentials.submit - Submit credentials for prompt (params: token, secrets, save?)")