From ed85f421b8822b2f195c5a7e43642c0b62d2f9a9 Mon Sep 17 00:00:00 2001 From: Ben Francis Date: Fri, 13 Sep 2024 21:52:30 +0100 Subject: [PATCH] Network settings implementation for Ubuntu and Ubuntu Core - closes #3155 --- .github/workflows/build.yml | 2 +- .gitignore | 3 + README.md | 20 +- package-lock.json | 41 +- package.json | 6 + snap/snapcraft.yaml | 2 + src/app.ts | 3 + src/controllers/settings_controller.ts | 43 +- src/platform.ts | 33 +- src/platforms/base.ts | 7 + src/platforms/linux-ubuntu-core.ts | 299 +++++++++ src/platforms/linux-ubuntu.ts | 4 +- src/platforms/utilities/network-manager.ts | 747 +++++++++++++++++++++ src/test/common.ts | 4 + static/js/views/settings.js | 22 +- 15 files changed, 1209 insertions(+), 27 deletions(-) create mode 100644 src/platforms/linux-ubuntu-core.ts create mode 100644 src/platforms/utilities/network-manager.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ffe6fe146..791e5adb3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,7 +30,7 @@ jobs: - name: Install system dependencies run: | sudo apt -qq update - sudo apt install -y google-chrome-stable + sudo apt install -y google-chrome-stable libdbus-1-dev - name: Install node dependencies run: | npm ci diff --git a/.gitignore b/.gitignore index 15aea4110..ebbd17b03 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ npm-debug.log # Test artifacts /coverage/ + +# Built packages +*.snap diff --git a/README.md b/README.md index 9da9ec44c..9ebf3568e 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ [![Build Status](https://github.com/WebThingsIO/gateway/workflows/Build/badge.svg)](https://github.com/WebThingsIO/gateway/actions?query=workflow%3ABuild) [![codecov](https://codecov.io/gh/WebThingsIO/gateway/branch/master/graph/badge.svg)](https://codecov.io/gh/WebThingsIO/gateway) -[![dependencies](https://david-dm.org/WebThingsIO/gateway.svg)](https://david-dm.org/WebThingsIO/gateway) -[![devDependencies](https://david-dm.org/WebThingsIO/gateway/dev-status.svg)](https://david-dm.org/WebThingsIO/gateway?type=dev) [![license](https://img.shields.io/badge/license-MPL--2.0-blue.svg)](LICENSE) Web of Things gateway. @@ -12,9 +10,8 @@ Web of Things gateway. - If you have a Rasberry Pi, the easiest way to use the gateway is to [download and flash](http://webthings.io/gateway/) a pre-built software image to an SD card. - If you prefer to use Docker, we have a prebuilt Docker image available [here](https://hub.docker.com/r/webthingsio/gateway), for both ARM and amd64. You can also build your own image from this repository. -- On Fedora, Debian, Raspberry Pi OS, or Ubuntu, you can install the relevant .rpm or .deb package from the [releases page](https://github.com/WebThingsIO/gateway/releases). -- On Arch Linux, you can install the [webthings-gateway AUR package](https://aur.archlinux.org/packages/webthings-gateway/). The PKGBUILD for this package can also be seen [here](https://github.com/WebThingsIO/gateway-aur). -- Otherwise, you can build it from source yourself (see below). +- On Ubuntu or Ubuntu Core you can install the experimental [snap package](https://snapcraft.io/webthings-gateway) with `$ snap install webthings-gateway --edge`. (Requires the `system-observe` and `network-manager` interfaces to be connected in order to configure network settings). +- Otherwise, you can build WebThings Gateway from source yourself (see below). ## Documentation @@ -50,6 +47,7 @@ $ sudo apt install \ libbluetooth-dev \ libboost-python-dev \ libboost-thread-dev \ + libdbus-1-dev \ libffi-dev \ libglib2.0-dev \ libpng-dev \ @@ -93,11 +91,19 @@ $ brew update $ brew install \ autoconf \ libffi \ - pkg-config -$ sudo -H python2 -m pip install six + pkg-config \ + python@3.10 \ + dbus $ sudo -H python3 -m pip install git+https://github.com/WebThingsIO/gateway-addon-python#egg=gateway_addon ``` +To get DBus to build properly, you currently need to symlink `python` to `python3` if a python binary is not already available. E.g. + +``` +$ echo 'export PATH=/opt/homebrew/opt/python@3.10/libexec/bin:$PATH' >> ~/.zprofile +$ source ~/.zprofile +``` + ### Install Node.js #### nvm (Recommended) diff --git a/package-lock.json b/package-lock.json index 07e35250a..698ff65a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4158,6 +4158,12 @@ "integrity": "sha512-maNkQzgLbyoi5mwwAL0KCb6l5/rn02N9y/CIBf4AYPrJ0pU2AvUTQ5LOw6GA4AAPrtiL44LWiwXzybUAUUYA3Q==", "dev": true }, + "@types/dbus": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/dbus/-/dbus-1.0.9.tgz", + "integrity": "sha512-cpXBYPVv6PGXNdb6LfuMgTC4wRFeWSjbkQ4HhwhSkUhmewD1uM/zxWCrmrlwucqpk6iDojlDPXJQ9CAF8+XRpg==", + "dev": true + }, "@types/eslint": { "version": "8.21.2", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.21.2.tgz", @@ -4310,6 +4316,15 @@ "@types/node": "*" } }, + "@types/ip": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/ip/-/ip-1.1.3.tgz", + "integrity": "sha512-64waoJgkXFTYnCYDUWgSATJ/dXEBanVkaP5d4Sbk7P6U7cTTMhxVyROTckc6JKdwCrgnAjZMn0k3177aQxtDEA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/istanbul-lib-coverage": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", @@ -4458,6 +4473,12 @@ "@types/node": "*" } }, + "@types/netmask": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/netmask/-/netmask-2.0.5.tgz", + "integrity": "sha512-9Q5iw9+pHZBVLDG700dlQSWWHTYvOb8KfPjfQTNckCYky4IyjV2xh81+RgC1CCwqv92bYLpz1cVKyJav0B88uQ==", + "dev": true + }, "@types/node": { "version": "14.18.31", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.31.tgz", @@ -7590,6 +7611,14 @@ "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==", "dev": true }, + "dbus": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/dbus/-/dbus-1.0.7.tgz", + "integrity": "sha512-qba6/ajLoqzCy3Kl3aFgLXLP4TTf0qfgNjib1qoCJG/8HbSs0lDvxkz4nJU63CURZVzxvpK/VpQpT40KA8Kr3A==", + "requires": { + "nan": "^2.14.0" + } + }, "debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -10643,10 +10672,9 @@ } }, "ip": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", - "optional": true + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", + "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==" }, "ip-regex": { "version": "4.3.0", @@ -13025,6 +13053,11 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, + "netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==" + }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", diff --git a/package.json b/package.json index fba62d338..817571d0c 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "config": "^3.3.4", "country-list": "^2.2.0", "csv-parse": "^4.15.3", + "dbus": "^1.0.7", "express": "^4.17.1", "express-fileupload": "^1.2.1", "express-handlebars": "^5.3.5", @@ -52,11 +53,13 @@ "gateway-addon": "^1.2.0-alpha.1", "glob-to-regexp": "^0.4.1", "http-proxy": "^1.18.1", + "ip": "^2.0.1", "ip-regex": "^4.3.0", "jsonwebtoken": "^8.5.1", "minipass": "^3.3.5", "mkdirp": "^1.0.4", "ncp": "^2.0.0", + "netmask": "^2.0.2", "nocache": "^2.1.0", "node-fetch": "^2.6.7", "node-getopt": "^0.3.2", @@ -87,6 +90,7 @@ "@types/compression": "^1.7.0", "@types/config": "0.0.38", "@types/country-list": "^2.1.0", + "@types/dbus": "^1.0.9", "@types/event-to-promise": "^0.7.1", "@types/eventsource": "^1.1.6", "@types/express": "^4.17.11", @@ -97,12 +101,14 @@ "@types/find": "^0.2.1", "@types/glob-to-regexp": "^0.4.0", "@types/http-proxy": "^1.17.5", + "@types/ip": "^1.1.3", "@types/jest": "^27.0.1", "@types/jsdom": "^16.2.6", "@types/jsonfile": "^6.0.0", "@types/jsonwebtoken": "^8.5.0", "@types/mkdirp": "^1.0.1", "@types/ncp": "^2.0.4", + "@types/netmask": "^2.0.5", "@types/node": "^14.14.31", "@types/node-fetch": "^2.5.8", "@types/node-getopt": "^0.2.31", diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index de35d6309..55368cda5 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -17,6 +17,7 @@ apps: - network - network-bind - system-observe + - network-manager parts: python-deps: @@ -45,6 +46,7 @@ parts: - libpng-dev - libudev-dev - libusb-1.0-0-dev + - libdbus-1-dev override-build: | craftctl default npm install --only-dev diff --git a/src/app.ts b/src/app.ts index 2170bb042..16ea18a14 100644 --- a/src/app.ts +++ b/src/app.ts @@ -367,6 +367,9 @@ function startGateway(): void { } function gracefulExit(): void { + if (Platform.implemented('stop')) { + Platform.stop(); + } AddonManager.unloadAddons(); TunnelService.stop(); } diff --git a/src/controllers/settings_controller.ts b/src/controllers/settings_controller.ts index 23893f339..7083847e3 100644 --- a/src/controllers/settings_controller.ts +++ b/src/controllers/settings_controller.ts @@ -22,6 +22,7 @@ import TunnelService from '../tunnel-service'; import * as CertificateManager from '../certificate-manager'; import pkg from '../package.json'; import { HttpErrorWithCode } from '../errors'; +import { LanMode, NetworkAddresses } from '../platforms/types'; function build(): express.Router { const auth = jwtMiddleware.middleware(); @@ -397,7 +398,11 @@ function build(): express.Router { }); controller.get('/network/lan', auth, (_request, response) => { - if (Platform.implemented('getLanMode')) { + if (Platform.implemented('getLanModeAsync')) { + Platform.getLanModeAsync().then((mode: LanMode) => { + response.json(mode); + }); + } else if (Platform.implemented('getLanMode')) { response.json(Platform.getLanMode()); } else { response.status(500).send('LAN mode not implemented'); @@ -413,7 +418,15 @@ function build(): express.Router { const mode = request.body.mode; const options = request.body.options; - if (Platform.implemented('setLanMode')) { + if (Platform.implemented('setLanModeAsync')) { + Platform.setLanModeAsync(mode, options).then((result: boolean) => { + if (result == true) { + response.status(200).json({}); + } else { + response.status(500).send('Failed to update LAN configuration'); + } + }); + } else if (Platform.implemented('setLanMode')) { if (Platform.setLanMode(mode, options)) { response.status(200).json({}); } else { @@ -433,7 +446,11 @@ function build(): express.Router { }); controller.get('/network/wireless/networks', auth, (_request, response) => { - if (Platform.implemented('scanWirelessNetworks')) { + if (Platform.implemented('scanWirelessNetworksAsync')) { + Platform.scanWirelessNetworksAsync().then((networks) => { + response.json(networks); + }); + } else if (Platform.implemented('scanWirelessNetworks')) { response.json(Platform.scanWirelessNetworks()); } else { response.status(500).send('Wireless scanning not implemented'); @@ -450,7 +467,19 @@ function build(): express.Router { const mode = request.body.mode; const options = request.body.options; - if (Platform.implemented('setWirelessMode')) { + if (Platform.implemented('setWirelessModeAsync')) { + Platform.setWirelessModeAsync(enabled, mode, options) + .then((result) => { + if (result === true) { + response.status(200).json({}); + } else { + response.status(500).send('Failed to update wireless configuration'); + } + }) + .catch(() => { + response.status(500).send('Failed to update wireless configuration'); + }); + } else if (Platform.implemented('setWirelessMode')) { if (Platform.setWirelessMode(enabled, mode, options)) { response.status(200).json({}); } else { @@ -462,7 +491,11 @@ function build(): express.Router { }); controller.get('/network/addresses', auth, (_request, response) => { - if (Platform.implemented('getNetworkAddresses')) { + if (Platform.implemented('getNetworkAddressesAsync')) { + Platform.getNetworkAddressesAsync().then((networkAddresses: NetworkAddresses) => { + response.json(networkAddresses); + }); + } else if (Platform.implemented('getNetworkAddresses')) { response.json(Platform.getNetworkAddresses()); } else { response.status(500).send('Network addresses not implemented'); diff --git a/src/platform.ts b/src/platform.ts index 8c1594858..baadd9261 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -14,6 +14,7 @@ import LinuxArchPlatform from './platforms/linux-arch'; import LinuxDebianPlatform from './platforms/linux-debian'; import LinuxRaspbianPlatform from './platforms/linux-raspbian'; import LinuxUbuntuPlatform from './platforms/linux-ubuntu'; +import LinuxUbuntuCorePlatform from './platforms/linux-ubuntu-core'; import { LanMode, NetworkAddresses, @@ -68,7 +69,7 @@ export function getOS(): string { return 'linux-unknown'; } - // Otherwise try to detect if running on Ubuntu Core. + // Otherwise try to detect Ubuntu or Ubuntu Core from inside a snap try { const osReleaseLines = fs .readFileSync('/var/lib/snapd/hostfs/etc/os-release', { @@ -85,10 +86,14 @@ export function getOS(): string { let id = line.substring(3, line.length); // Remove any quotation marks id = id.replace(/"/g, ''); - if (id == 'ubuntu-core') { - return 'linux-ubuntu-core'; - } else { - console.log('Unknown host Linux distribution'); + switch (id) { + case 'ubuntu': + return 'linux-ubuntu'; + case 'ubuntu-core': + return 'linux-ubuntu-core'; + default: + console.log('Unknown host Linux distribution'); + break; } } } @@ -195,6 +200,9 @@ switch (getOS()) { case 'linux-ubuntu': platform = LinuxUbuntuPlatform; break; + case 'linux-ubuntu-core': + platform = LinuxUbuntuCorePlatform; + break; default: platform = null; break; @@ -205,21 +213,35 @@ export const setDhcpServerStatus = wrapPlatform(platform, 'setDhcpServe export const getHostname = wrapPlatform(platform, 'getHostname'); export const setHostname = wrapPlatform(platform, 'setHostname'); export const getLanMode = wrapPlatform(platform, 'getLanMode'); +export const getLanModeAsync = wrapPlatform>(platform, 'getLanModeAsync'); export const setLanMode = wrapPlatform(platform, 'setLanMode'); +export const setLanModeAsync = wrapPlatform>(platform, 'setLanModeAsync'); export const getMacAddress = wrapPlatform(platform, 'getMacAddress'); export const getMdnsServerStatus = wrapPlatform(platform, 'getMdnsServerStatus'); export const setMdnsServerStatus = wrapPlatform(platform, 'setMdnsServerStatus'); export const getNetworkAddresses = wrapPlatform(platform, 'getNetworkAddresses'); +export const getNetworkAddressesAsync = wrapPlatform>( + platform, + 'getNetworkAddressesAsync' +); export const getSshServerStatus = wrapPlatform(platform, 'getSshServerStatus'); export const setSshServerStatus = wrapPlatform(platform, 'setSshServerStatus'); export const getWirelessMode = wrapPlatform(platform, 'getWirelessMode'); export const setWirelessMode = wrapPlatform(platform, 'setWirelessMode'); +export const setWirelessModeAsync = wrapPlatform>( + platform, + 'setWirelessModeAsync' +); export const restartGateway = wrapPlatform(platform, 'restartGateway'); export const restartSystem = wrapPlatform(platform, 'restartSystem'); export const scanWirelessNetworks = wrapPlatform( platform, 'scanWirelessNetworks' ); +export const scanWirelessNetworksAsync = wrapPlatform>( + platform, + 'scanWirelessNetworksAsync' +); export const getSelfUpdateStatus = wrapPlatform(platform, 'getSelfUpdateStatus'); export const setSelfUpdateStatus = wrapPlatform(platform, 'setSelfUpdateStatus'); export const getValidTimezones = wrapPlatform(platform, 'getValidTimezones'); @@ -239,6 +261,7 @@ export const getNtpStatus = (): boolean => { return wrapPlatform(platform, 'getNtpStatus')(); }; +export const stop = wrapPlatform(platform, 'stop'); export const implemented = (fn: string): boolean => { if (platform === null) { diff --git a/src/platforms/base.ts b/src/platforms/base.ts index 724044df8..8176622d3 100644 --- a/src/platforms/base.ts +++ b/src/platforms/base.ts @@ -292,4 +292,11 @@ export default class BasePlatform { restartNtpSync(): boolean { throw new NotImplementedError('restartNtpSync'); } + + /** + * Gracefully stop any platform services on shutdown. + */ + stop(): void { + throw new NotImplementedError('stop'); + } } diff --git a/src/platforms/linux-ubuntu-core.ts b/src/platforms/linux-ubuntu-core.ts new file mode 100644 index 000000000..416663179 --- /dev/null +++ b/src/platforms/linux-ubuntu-core.ts @@ -0,0 +1,299 @@ +/** + * Ubuntu Core platform interface. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import ip from 'ip'; +import { Netmask } from 'netmask'; +import BasePlatform from './base'; +import NetworkManager, { ConnectionSettings } from './utilities/network-manager'; +import { LanMode, NetworkAddresses, WirelessNetwork } from './types'; + +export class LinuxUbuntuCorePlatform extends BasePlatform { + /** + * Disconnect NetworkManager. + */ + stop(): void { + NetworkManager.stop(); + } + + /** + * Get the current addresses for Wi-Fi and LAN. + * + * @returns {Promise} Promise that resolves with + * { + * lan: '...', + * wlan: { + * ip: '...', + * ssid: '...', + * } + * } + */ + async getNetworkAddressesAsync(): Promise { + const result: NetworkAddresses = { + lan: '', + wlan: { + ip: '', + ssid: '', + }, + }; + try { + const ethernetDevices = await NetworkManager.getEthernetDevices(); + const ethernetIp4Config = await NetworkManager.getDeviceIp4Config(ethernetDevices[0]); + result.lan = ethernetIp4Config[0].address; + } catch (error) { + console.log('Unable to detect an Ethernet IP address'); + } + try { + const wifiDevices = await NetworkManager.getWifiDevices(); + const wifiIp4Config = await NetworkManager.getDeviceIp4Config(wifiDevices[0]); + const accessPoint = await NetworkManager.getActiveAccessPoint(wifiDevices[0]); + const ssid = await NetworkManager.getAccessPointSsid(accessPoint); + result.wlan.ip = wifiIp4Config[0].address; + result.wlan.ssid = ssid; + } catch (error) { + console.log('Unable to detect a Wi-Fi IP address and active SSID'); + } + return result; + } + + /** + * Get LAN network settings. + * + * @returns {Promise} Promise that resolves with + * {mode: 'static|dhcp|...', options: {...}} + */ + async getLanModeAsync(): Promise { + const result: LanMode = { + mode: '', + options: {}, + }; + return NetworkManager.getEthernetDevices() + .then((devices) => { + return NetworkManager.getDeviceConnection(devices[0]); + }) + .then((connection) => { + return NetworkManager.getConnectionSettings(connection); + }) + .then((settings: ConnectionSettings) => { + if (settings && settings.ipv4 && settings.ipv4.method == 'auto') { + result.mode = 'dhcp'; + } else if (settings && settings.ipv4 && settings.ipv4.method == 'manual') { + result.mode = 'static'; + } + if (settings.ipv4 && settings.ipv4['address-data'] && settings.ipv4['address-data'][0]) { + if (settings.ipv4['address-data'][0].hasOwnProperty('address')) { + result.options.ipaddr = settings.ipv4['address-data'][0].address; + } + if (result.options.ipaddr && settings.ipv4['address-data'][0].hasOwnProperty('prefix')) { + // Convert cidr style prefix to dot-decimal netmask + const ip = result.options.ipaddr; + const cidr = settings.ipv4['address-data'][0].prefix; + const block = new Netmask(`${ip}/${cidr}`); + result.options.netmask = block.mask; + } + } + if (settings.ipv4 && settings.ipv4.hasOwnProperty('gateway')) { + result.options.gateway = settings.ipv4.gateway; + } + return result; + }) + .catch((error) => { + console.error(`Error getting LAN mode from Network Manager: ${error}`); + return result; + }); + } + + /** + * Set LAN network settings. + * + * @param {string} mode static|dhcp|.... + * @param {Record} options Mode-specific options. + * @returns {Promise} Promise that resolves true if successful and false if not. + */ + async setLanModeAsync(mode: string, options: Record): Promise { + let lanDevice: string; + let lanConnection: string; + return NetworkManager.getEthernetDevices() + .then((devices) => { + lanDevice = devices[0]; + return NetworkManager.getDeviceConnection(lanDevice); + }) + .then((connection) => { + lanConnection = connection; + // First get current settings to carry over some values + return NetworkManager.getConnectionSettings(lanConnection); + }) + .then((oldSettings) => { + // Carry over some values from the old settings + const settings: ConnectionSettings = { + connection: { + id: oldSettings.connection.id, + uuid: oldSettings.connection.uuid, + type: oldSettings.connection.type, + }, + }; + + if (mode == 'dhcp') { + // Set dynamic IP + settings.ipv4 = { + method: 'auto', + }; + } else if (mode == 'static') { + if ( + !( + options.hasOwnProperty('ipaddr') && + ip.isV4Format(options.ipaddr) && + options.hasOwnProperty('gateway') && + ip.isV4Format(options.gateway) && + options.hasOwnProperty('netmask') && + ip.isV4Format(options.netmask) + ) + ) { + console.log( + 'Setting a static IP address requires a valid IP address, gateway and netmask' + ); + return false; + } + // Set static IP address + // Convert dot-decimal netmask to cidr style prefix for storage + const netmask = new Netmask(options.ipaddr as string, options.netmask as string); + const prefix = netmask.bitmask; + // Convert dot-decimal IP and gateway to little endian integers for storage + const ipaddrReversed = (options.ipaddr as string).split('.').reverse().join('.'); + const ipaddrInt = ip.toLong(ipaddrReversed); + const gatewayReversed = (options.gateway as string).split('.').reverse().join('.'); + const gatewayInt = ip.toLong(gatewayReversed); + settings.ipv4 = { + method: 'manual', + addresses: [[ipaddrInt, prefix, gatewayInt]], + // The NetworkManager docs say that the addresses property is deprecated, + // but using address-data and gateway doesn't seem to work on Ubuntu yet. + /* + 'address-data': [{ + 'address': options.ipaddr, + 'prefix': prefix + }], + 'gateway': options.gateway + */ + }; + } else { + console.error('LAN mode not recognised'); + return false; + } + return NetworkManager.setConnectionSettings(lanConnection, settings); + }) + .then(() => { + return NetworkManager.activateConnection(lanConnection, lanDevice); + }) + .catch((error) => { + console.error(`Error setting LAN settings: ${error}`); + return false; + }); + } + + /** + * Scan for visible wireless networks on the first wireless device. + * + * @returns {Promise} Promise which resolves with an array + * of networks as objects: + * [ + * { + * ssid: '...', + * quality: , + * encryption: true|false, + * configured: true|false, + * connected: true|false + * }, + * ... + * ] + */ + async scanWirelessNetworksAsync(): Promise { + const wifiDevices = await NetworkManager.getWifiDevices(); + const wifiAccessPoints = await NetworkManager.getWifiAccessPoints(wifiDevices[0]); + let activeAccessPoint: string | null; + try { + activeAccessPoint = await NetworkManager.getActiveAccessPoint(wifiDevices[0]); + } catch (error) { + activeAccessPoint = null; + } + const apRequests: Array> = []; + wifiAccessPoints.forEach((ap) => { + apRequests.push(NetworkManager.getAccessPointDetails(ap, activeAccessPoint)); + }); + const responses = await Promise.all(apRequests); + return responses; + } + + /** + * Set the wireless mode and options. + * + * @param {boolean} enabled - whether or not wireless is enabled + * @param {string} mode - ap, sta, ... + * @param {Record} options - options specific to wireless mode + * @returns {Promise} Boolean indicating success. + */ + async setWirelessModeAsync( + enabled: boolean, + mode = 'ap', + options: Record = {} + ): Promise { + const valid = [ + // 'ap', //TODO: Implement ap mode + 'sta', + ]; + if (enabled && !valid.includes(mode)) { + console.error(`Wireless mode ${mode} not supported on this platform`); + return false; + } + const wifiDevices = await NetworkManager.getWifiDevices(); + + // If `enabled` set to false, disconnect wireless device + if (enabled === false) { + // Return false if no wifi device found + if (!wifiDevices[0]) { + return false; + } + try { + await NetworkManager.disconnectNetworkDevice(wifiDevices[0]); + } catch (error) { + console.error(`Error whilst attempting to disconnect wireless device: ${error}`); + return false; + } + return true; + } + + // Otherwise connect to Wi-Fi access point using provided options + if (!options.hasOwnProperty('ssid')) { + console.log('Could not connect to wireless network because no SSID provided'); + return false; + } + const accessPoint = await NetworkManager.getAccessPointbySsid(options.ssid as string); + if (accessPoint == null) { + console.log('No network with specified SSID found'); + return false; + } + let secure = false; + if (options.key) { + secure = true; + } + try { + NetworkManager.connectToWifiAccessPoint( + wifiDevices[0], + accessPoint, + options.ssid, + secure, + options.key + ); + } catch (error) { + console.error(`Error connecting to Wi-Fi access point: ${error}`); + return false; + } + return true; + } +} + +export default new LinuxUbuntuCorePlatform(); diff --git a/src/platforms/linux-ubuntu.ts b/src/platforms/linux-ubuntu.ts index 4cbadcb3e..a6170677f 100644 --- a/src/platforms/linux-ubuntu.ts +++ b/src/platforms/linux-ubuntu.ts @@ -6,8 +6,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { LinuxDebianPlatform } from './linux-debian'; +import { LinuxUbuntuCorePlatform } from './linux-ubuntu-core'; -class LinuxUbuntuPlatform extends LinuxDebianPlatform {} +class LinuxUbuntuPlatform extends LinuxUbuntuCorePlatform {} export default new LinuxUbuntuPlatform(); diff --git a/src/platforms/utilities/network-manager.ts b/src/platforms/utilities/network-manager.ts new file mode 100644 index 000000000..dca8a8d9d --- /dev/null +++ b/src/platforms/utilities/network-manager.ts @@ -0,0 +1,747 @@ +import DBus from 'dbus'; +import { WirelessNetwork } from '../types'; + +export interface ConnectionSettings { + connection: { + id: string; + uuid?: string; + type: string; + }; + ipv4?: { + method: string; + addresses?: Array>; + 'address-data'?: Array; + gateway?: string; + }; + '802-11-wireless'?: { + ssid: Array; + }; + '802-11-wireless-security'?: { + 'key-mgmt'?: string; + psk?: string; + }; +} + +export interface AddressData { + address: string; + prefix: number; +} + +/** + * Network Manager. + * + * Manages networking devices over DBus. + */ +class NetworkManager { + // Reference to the DBus system bus once connected + private systemBus: DBus.DBusConnection | null = null; + + /** + * Connect to the system bus. + */ + start(): void { + // There can only be one system bus instance open at a time. + if (!this.systemBus) { + this.systemBus = DBus.getBus('system'); + } + } + + /** + * Disconnect from the system bus. + */ + stop(): void { + if (this.systemBus) { + this.systemBus.disconnect(); + } + } + + /** + * Get a list of network adapters from the system network manager. + * + * @returns {Promise} An array of DBus object paths. + */ + getDevices(): Promise { + this.start(); + return new Promise((resolve, reject) => { + this.systemBus!.getInterface( + 'org.freedesktop.NetworkManager', + '/org/freedesktop/NetworkManager', + 'org.freedesktop.NetworkManager', + function (error, iface) { + if (error) { + console.error(`Error accessing the NetworkManager DBus interface: ${error}`); + reject(); + return; + } + iface.GetAllDevices(function (error: Error, result: string[]) { + if (error) { + console.error(`Error calling GetAllDevices on NetworkManager DBus: ${error}`); + reject(); + return; + } + resolve(result); + }); + } + ); + }); + } + + /** + * Get the device type for a given network adapter. + * + * @param {string} path Object path for device. + * @returns {Promise} Resolves with a device type + * (1 is Ethernet, 2 is Wi-Fi...). + */ + getDeviceType(path: string): Promise { + this.start(); + return new Promise((resolve, reject) => { + this.systemBus!.getInterface( + 'org.freedesktop.NetworkManager', + path, + 'org.freedesktop.NetworkManager.Device', + function (error, iface) { + if (error) { + console.error(error); + reject(); + return; + } + iface.getProperty('DeviceType', function (error, value) { + if (error) { + console.error(error); + reject(); + return; + } + resolve(+value); + }); + } + ); + }); + } + + /** + * Get a list of Ethernet network adapters from the system network manager. + * + * @returns {Promise} A promise which resolves with an array + * of DBus object paths. + */ + async getEthernetDevices(): Promise { + // Get a list of all network adapter devices + const devices = await this.getDevices(); + const ethernetDevices: string[] = []; + // Filter by type + for (const device of devices) { + const type = await this.getDeviceType(device); + if (type == 1) { + ethernetDevices.push(device); + } + } + return ethernetDevices; + } + + /** + * Get a list of Wi-Fi network adapters from the system network manager. + * + * @returns {Promise { + // Get a list of all network adapter devices + const devices = await this.getDevices(); + const wifiDevices: string[] = []; + // Filter by type + for (const device of devices) { + const type = await this.getDeviceType(device); + if (type == 2) { + wifiDevices.push(device); + } + } + return wifiDevices; + } + + /** + * Get the active connection associated with a device. + * + * @param {string} path Object path for device. + * @returns {Promise} Resolves with object path of the active + * connection object associated with this device. + */ + getDeviceConnection(path: string): Promise { + this.start(); + return new Promise((resolve, reject) => { + this.systemBus!.getInterface( + 'org.freedesktop.NetworkManager', + path, + 'org.freedesktop.NetworkManager.Device', + (error, iface) => { + if (error) { + console.error(error); + reject(); + return; + } + iface.getProperty('ActiveConnection', (error, activeConnectionPath) => { + if (error) { + console.error(error); + reject(); + return; + } + this.systemBus!.getInterface( + 'org.freedesktop.NetworkManager', + activeConnectionPath, + 'org.freedesktop.NetworkManager.Connection.Active', + (error, iface) => { + if (error) { + console.error(error); + reject(); + return; + } + iface.getProperty('Connection', function (error, value) { + if (error) { + console.error(error); + reject(); + return; + } + resolve(value); + }); + } + ); + }); + } + ); + }); + } + + /** + * Get the settings for a given connection. + * + * @param {string} path Object path for a connection settings profile. + * @returns {Promise} Resolves with the settings of a connection. + */ + getConnectionSettings(path: string): Promise { + this.start(); + return new Promise((resolve, reject) => { + this.systemBus!.getInterface( + 'org.freedesktop.NetworkManager', + path, + 'org.freedesktop.NetworkManager.Settings.Connection', + function (error, iface) { + if (error) { + console.error(error); + reject(); + return; + } + iface.GetSettings(function (error: Error, value: ConnectionSettings) { + if (error) { + console.error(error); + reject(); + return; + } + resolve(value); + }); + } + ); + }); + } + + /** + * Update connection settings. + * + * Note that this persists the connection object, but a connection needs to be + * reactivated in order for it to take effect. + * + * @param {string} path DBus object path of the Connection Settings object to update. + * @param {ConnectionSettings} settings A connection settings object. + * @returns {Promise} A Promise that resolves with true on success or + * rejects with an Error on failure. + */ + setConnectionSettings(path: string, settings: ConnectionSettings): Promise { + this.start(); + return new Promise((resolve, reject) => { + this.systemBus!.getInterface( + 'org.freedesktop.NetworkManager', + path, + 'org.freedesktop.NetworkManager.Settings.Connection', + function (error, iface) { + if (error) { + console.error(error); + reject(); + return; + } + iface.Update(settings, function (error: Error) { + if (error) { + console.error(error); + reject(); + return; + } + resolve(true); + }); + } + ); + }); + } + + /** + * Activate a network connection. + * + * @param {string} connection The DBus object path of the connection settings to apply. + * @param {string} device The DBus object path of the device to apply settings to. + * @returns {Promise} A Promise which resolves with true on success + * or rejects on failure. + */ + activateConnection(connection: string, device: string): Promise { + this.start(); + return new Promise((resolve, reject) => { + this.systemBus!.getInterface( + 'org.freedesktop.NetworkManager', + '/org/freedesktop/NetworkManager', + 'org.freedesktop.NetworkManager', + function (error, iface) { + if (error) { + console.error(error); + reject(); + return; + } + iface.ActivateConnection(connection, device, '/', function (error: Error) { + if (error) { + console.error(error); + reject(); + return; + } + resolve(true); + }); + } + ); + }); + } + + /** + * Get an IPv4 configuration for a given device path. + * + * @param {String} path Object path for a device. + * @returns {Promise>} Promise resolves with IP4Config object. + */ + getDeviceIp4Config(path: string): Promise> { + this.start(); + return new Promise((resolve, reject) => { + this.systemBus!.getInterface( + 'org.freedesktop.NetworkManager', + path, + 'org.freedesktop.NetworkManager.Device', + (error, iface) => { + if (error) { + console.error(error); + reject(); + return; + } + iface.getProperty('Ip4Config', (error, ip4ConfigPath) => { + if (error) { + console.error(error); + reject(); + return; + } + this.systemBus!.getInterface( + 'org.freedesktop.NetworkManager', + ip4ConfigPath, + 'org.freedesktop.NetworkManager.IP4Config', + (error, iface) => { + if (error) { + console.error(error); + reject(); + return; + } + // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/71006 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + iface.getProperty('AddressData', function (error, value: any) { + if (error) { + console.error(error); + reject(); + return; + } + resolve(value); + }); + } + ); + }); + } + ); + }); + } + + /** + * Get the SSID of the Wi-Fi access point with a given DBUS object path. + * + * @param {string} path DBUS object path of the Wi-Fi access point. + * @returns {Promise} The SSID of the access point. + */ + getAccessPointSsid(path: string): Promise { + this.start(); + return new Promise((resolve, reject) => { + this.systemBus!.getInterface( + 'org.freedesktop.NetworkManager', + path, + 'org.freedesktop.NetworkManager.AccessPoint', + function (error, iface) { + if (error) { + console.error(error); + reject(); + return; + } + // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/71006 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + iface.getProperty('Ssid', function (error, value: any) { + if (error) { + console.error(error); + reject(); + return; + } + // Convert SSID from byte array to string. + const ssid = String.fromCharCode(...value); + resolve(ssid); + }); + } + ); + }); + } + + /** + * Get the signal strength of the Wi-Fi access point with a given DBUS object path. + * + * @param {string} path DBUS object path of the Wi-Fi access point. + * @returns {Promise} The strength of the signal as a percentage. + */ + getAccessPointStrength(path: string): Promise { + this.start(); + return new Promise((resolve, reject) => { + this.systemBus!.getInterface( + 'org.freedesktop.NetworkManager', + path, + 'org.freedesktop.NetworkManager.AccessPoint', + function (error, iface) { + if (error) { + console.error(error); + reject(); + return; + } + // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/71006 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + iface.getProperty('Strength', function (error, value: any) { + if (error) { + console.error(error); + reject(); + return; + } + resolve(value); + }); + } + ); + }); + } + + /** + * Gets the encryption status of the Wi-Fi access point with a given DBUS object path. + * + * @param {string} path DBUS object path of the Wi-Fi access point. + * @returns {Promise} true if encrypted, false if not. + */ + async getAccessPointSecurity(path: string): Promise { + this.start(); + const wpaFlagRequest = new Promise((resolve, reject) => { + this.systemBus!.getInterface( + 'org.freedesktop.NetworkManager', + path, + 'org.freedesktop.NetworkManager.AccessPoint', + (error, iface) => { + if (error) { + console.error(error); + reject(); + return; + } + // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/71006 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + iface.getProperty('WpaFlags', function (error, value: any) { + if (error) { + console.error(error); + reject(); + return; + } + resolve(value); + }); + } + ); + }); + const wpa2FlagRequest = new Promise((resolve, reject) => { + this.systemBus!.getInterface( + 'org.freedesktop.NetworkManager', + path, + 'org.freedesktop.NetworkManager.AccessPoint', + function (error, iface) { + if (error) { + console.error(error); + reject(); + return; + } + // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/71006 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + iface.getProperty('RsnFlags', function (error, value: any) { + if (error) { + console.error(error); + reject(); + return; + } + resolve(value); + }); + } + ); + }); + // Request WPA and WPA2 flags for access point. + const requests = []; + requests.push(wpaFlagRequest); + requests.push(wpa2FlagRequest); + const responses = await Promise.all(requests); + if (responses[0] == 0 && responses[1] == 0) { + return false; + } else { + return true; + } + } + + /** + * Get details about an access point reachable from a wireless device. + * + * @param {string} path The DBUS path of an access point. + * @param {string|null} activeAccessPoint: The DBUS path of the active access point, if any. + * @returns {Promise} A Promise which resolves with a wireless network + * object of the form: + * { + * ssid: '...', + * quality: , + * encryption: true|false, + * configured: true|false, + * connected: true| false + * } + * @throws {Error} Error if not able to get all access point details. + */ + async getAccessPointDetails( + path: string, + activeAccessPoint: string | null + ): Promise { + let ssid: string; + let strength: number; + let security: boolean; + let connected: boolean; + if (path === activeAccessPoint) { + connected = true; + } else { + connected = false; + } + try { + ssid = await this.getAccessPointSsid(path); + strength = await this.getAccessPointStrength(path); + security = await this.getAccessPointSecurity(path); + } catch (error) { + console.error(error); + throw new Error('Failed to get access point details'); + } + const response = { + ssid: ssid, + quality: strength, + encryption: security, + configured: connected, // Currently assumes only configured if connected + connected: connected, + }; + // Resolve with access point details + return response; + } + + /** + * Get the active Access Point a given Wi-Fi adapter is connected to. + * + * @param {String} path DBUS Object path for a Wi-Fi device. + * @returns {Promise} Promise resolves with the DBUS object path of an access point. + */ + getActiveAccessPoint(path: string): Promise { + this.start(); + return new Promise((resolve, reject) => { + this.systemBus!.getInterface( + 'org.freedesktop.NetworkManager', + path, + 'org.freedesktop.NetworkManager.Device.Wireless', + (error, iface) => { + if (error) { + console.error(error); + reject(); + return; + } + iface.getProperty('ActiveAccessPoint', (error, accessPointPath) => { + if (error) { + console.log('Unable to detect a connected Wi-Fi access point'); + reject(); + return; + } + resolve(accessPointPath); + }); + } + ); + }); + } + + /** + * Get a list of access points for the wireless device at the given path. + * + * @param {String} path The DBUS object path of a wireless device. + * @returns {Promise} An array of DBus object paths of Access Points. + */ + getWifiAccessPoints(path: string): Promise { + this.start(); + return new Promise((resolve, reject) => { + this.systemBus!.getInterface( + 'org.freedesktop.NetworkManager', + path, + 'org.freedesktop.NetworkManager.Device.Wireless', + function (error, iface) { + if (error) { + console.error(`Error getting a wireless device via NetworkManager: ${error}`); + reject(); + return; + } + // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/71006 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + iface.getProperty('AccessPoints', function (error: Error | null, result: any) { + if (error) { + console.error(`Error getting AccessPoints from a wireless device: ${error}`); + reject(); + return; + } + resolve(result); + }); + } + ); + }); + } + + /** + * Get an access point DBUS object bath for a given SSID. + * + * @param {string} ssid The SSID of the network to search for. + * @returns {Promise } A Promise which resolves with the DBUS object + * path of the access point, or null if not found; + */ + async getAccessPointbySsid(ssid: string): Promise { + const wifiDevices = await this.getWifiDevices(); + const wifiAccessPoints = await this.getWifiAccessPoints(wifiDevices[0]); + // Return the first access point that has a matching SSID + // TODO: Deal with duplicates + for (const accessPoint of wifiAccessPoints) { + const accessPointSsid = await this.getAccessPointSsid(accessPoint); + if (accessPointSsid == ssid) { + return accessPoint; + } + } + return null; + } + + /** + * Connect to Wi-Fi access point. + * + * @param {string} wifiDevice DBUS object path of wireless device to use. + * @param {string} accessPoint DBUS object path of access point to connec to (e.g. 1) + * @param {string} ssid SSID of access point to connect to. + * @param {boolean} secure Whether or not authentication is provided. + * @param {string} password provided by user. + * @returns {Promise} Resolves on success, rejects with an Error on failure. + */ + connectToWifiAccessPoint( + wifiDevice: string, + accessPoint: string, + ssid: string, + secure: boolean, + password: string + ): Promise { + this.start(); + return new Promise((resolve, reject) => { + this.systemBus!.getInterface( + 'org.freedesktop.NetworkManager', + '/org/freedesktop/NetworkManager', + 'org.freedesktop.NetworkManager', + (error, iface) => { + if (error) { + reject(error); + return; + } + + // Convert SSID to an array of bytes + const ssidBytes = []; + for (let i = 0; i < ssid.length; ++i) { + ssidBytes.push(ssid.charCodeAt(i)); + } + + // Assemble connection information + const connectionInfo: ConnectionSettings = { + '802-11-wireless': { + ssid: ssidBytes, + }, + connection: { + id: ssid, + type: '802-11-wireless', + }, + }; + + if (secure) { + connectionInfo['802-11-wireless-security'] = { + 'key-mgmt': 'wpa-psk', + psk: password, + }; + } + + // TODO: Should we re-use an existing connection rather than add a new one + // if one already exists? + iface.AddAndActivateConnection( + connectionInfo, + wifiDevice, + accessPoint, + function (error: Error) { + if (error) { + reject(error); + return; + } + resolve(); + } + ); + } + ); + }); + } + + /** + * Disconnect a network device. + * + * @param {string} path DBUS object path of device. + * @returns {Promise} A promise which resolves upon successful + * deactivation or rejects with an Error on failure. + */ + disconnectNetworkDevice(path: string): Promise { + this.start(); + return new Promise((resolve, reject) => { + this.systemBus!.getInterface( + 'org.freedesktop.NetworkManager', + path, + 'org.freedesktop.NetworkManager.Device', + (error, iface) => { + if (error) { + reject(error); + return; + } + iface.Disconnect(function (error: Error) { + if (error) { + reject(error); + return; + } + resolve(); + }); + } + ); + }); + } +} + +export default new NetworkManager(); diff --git a/src/test/common.ts b/src/test/common.ts index 76f25931b..c8d1f5cd4 100644 --- a/src/test/common.ts +++ b/src/test/common.ts @@ -14,6 +14,7 @@ import Events from '../models/events'; import Logs from '../models/logs'; import Things from '../models/things'; import UserProfile from '../user-profile'; +import * as Platform from '../platform'; import e2p from 'event-to-promise'; import fs from 'fs'; import path from 'path'; @@ -91,6 +92,9 @@ afterEach(async () => { }); afterAll(async () => { + if (Platform.implemented('stop')) { + Platform.stop(); + } Logs.close(); await addonManager.unloadAddons(); diff --git a/static/js/views/settings.js b/static/js/views/settings.js index 8fe304809..9c3bf7b13 100644 --- a/static/js/views/settings.js +++ b/static/js/views/settings.js @@ -836,6 +836,8 @@ const SettingsScreen = { switch (body.os) { case 'linux-debian': case 'linux-raspbian': + case 'linux-ubuntu': + case 'linux-ubuntu-core': this.elements.network.client.main.classList.remove('hidden'); break; default: @@ -873,9 +875,23 @@ const SettingsScreen = { API.getLanSettings() .then((body) => { this.elements.network.client.ethernet.mode.value = body.mode || 'dhcp'; - this.elements.network.client.ethernet.ip.value = body.ipdaddr || ''; - this.elements.network.client.ethernet.netmask.value = body.netmask || '255.255.255.0'; - this.elements.network.client.ethernet.gateway.value = body.gateway || ''; + this.elements.network.client.ethernet.ip.value = body.options.ipaddr || ''; + this.elements.network.client.ethernet.netmask.value = + body.options.netmask || '255.255.255.0'; + this.elements.network.client.ethernet.gateway.value = body.options.gateway || ''; + const ethernetEls = [ + this.elements.network.client.ethernet.ipLabel, + this.elements.network.client.ethernet.ip, + this.elements.network.client.ethernet.netmaskLabel, + this.elements.network.client.ethernet.netmask, + this.elements.network.client.ethernet.gatewayLabel, + this.elements.network.client.ethernet.gateway, + ]; + if (body.mode === 'static') { + ethernetEls.forEach((el) => el.classList.remove('hidden')); + } else { + ethernetEls.forEach((el) => el.classList.add('hidden')); + } }) .catch((e) => { console.error(`Failed to get ethernet config: ${e}`);