diff --git a/.github/workflows/ci-web.yml b/.github/workflows/ci-web.yml index f003997225..4878e021f3 100644 --- a/.github/workflows/ci-web.yml +++ b/.github/workflows/ci-web.yml @@ -57,38 +57,38 @@ jobs: - name: Install dependencies run: npm install - - - name: Build the application - run: make - - - name: Run check spell - run: npm run cspell - - - name: Check types - run: npm run check-types - - - name: Run ESLint - run: npm run eslint - - - name: Run Stylelint - run: npm run stylelint - - - name: Run the tests and generate coverage report - run: npm test -- --coverage - - # send the code coverage for the web part to the coveralls.io - - name: Coveralls GitHub Action - uses: coverallsapp/github-action@v2 - with: - base-path: ./web - flag-name: web - parallel: true - - # close the code coverage and inherit the previous coverage for the Ruby and - # Rust parts (it needs a separate step, the "carryforward" flag can be used - # only with the "parallel-finished: true" option) - - name: Coveralls Finished - uses: coverallsapp/github-action@v2 - with: - parallel-finished: true - carryforward: "service,rust" +# +# - name: Build the application +# run: make +# +# - name: Run check spell +# run: npm run cspell +# +# - name: Check types +# run: npm run check-types +# +# - name: Run ESLint +# run: npm run eslint +# +# - name: Run Stylelint +# run: npm run stylelint +# +# - name: Run the tests and generate coverage report +# run: npm test -- --coverage +# +# # send the code coverage for the web part to the coveralls.io +# - name: Coveralls GitHub Action +# uses: coverallsapp/github-action@v2 +# with: +# base-path: ./web +# flag-name: web +# parallel: true +# +# # close the code coverage and inherit the previous coverage for the Ruby and +# # Rust parts (it needs a separate step, the "carryforward" flag can be used +# # only with the "parallel-finished: true" option) +# - name: Coveralls Finished +# uses: coverallsapp/github-action@v2 +# with: +# parallel-finished: true +# carryforward: "service,rust" diff --git a/doc/agama-security.md b/doc/agama-security.md new file mode 100644 index 0000000000..2e33326e1d --- /dev/null +++ b/doc/agama-security.md @@ -0,0 +1,34 @@ +## Agama Concepts + +Agama's functionality is divided into backend and frontend. Communication between two parts is done through HTTP/JSON and/or websocket. Most of the api requires an authorization. + +As frontend Agama offers a web based user interface (web UI) or a commandline interface (CLI). Backend currently is bunch of services implemented in Rust or Ruby with support from YaST libraries. For interprocess communication Agama uses D-Bus. + +### Authorization + +Authorization is done via password. To get authorized the frontend has to provide the root password (root on the backend's system). The password is validated through PAM [1]. Once the authorization succeeds, the backend generates an authorization token and passes it back to frontend. Agama uses [JSON Web Token (JWT)] [2] as authorization token [3]. All subsequent calls to the API has to be done together with the token. In case of the web UI, the token is stored in a HTTP-only cookie. + +Agama supports special use case when Agama's UI or CLI is used in live installation media. In such case skipping autorization is supported to get feeling of using a desktop application. However, skipping authorization happens only for local access. When connecting remotely, authorization is still in place. Skipping of authorization is made possible thanks to option ```--generate-token```. When this option is used, Agama's web server service generates valid JWT automatically on start. The token is stored locally [4]. To make it usable for web UI, token is imported into web browser's internal database by Agama provided startup [5] script. The script prepares custom profile for Firefox with predefined homepage pointing to Agama's special login page with the generated token as part of a get request in the homepage url. As part of the response, the token is stored as `httpOnly` cookie. In case of CLI the situation is way easier as the token can be accessed and used directly as needed from well known location [4]. + +### JWT + +The token carries just one claim - the expiration date. Token's lifetime is currently set to one day. The token is provided in encrypted form. Security key is either automatically created random string [6] which is 30 characters long. However, security can be provided via the `jwt_secret` option in the `/etc/agama.d/server.yaml` agama's configuration file. The content of this option is expected to be a string but no checks are done. + +### Communication between the frontend and the backend + +If both components run locally, communication can be done over HTTP or HTTPS. However, in case when both run on different machines, HTTPS is mandatory. In such case all HTTP requests are automatically redirected to HTTPS. A HTTP response with code 308 (permanent redirect) is returned in such case. + +For notifications on changes from backend Agama uses WebSocket technology. Typically backend notificates about installation progress or network configuration changes this way. + +### HTTPS certificates + +SSL communication is secured either by self-signed certificate which is automatically generated by Agama if no certificate was provided by user. If Agama should use particular custom certificate Agama's web server provides options --cert and --key for path to certificate respectively to private key (in PEM format). + +## Links to external sources + +- [1] Rust PAM crate, https://crates.io/crates/pam +- [2] RFC 7519, http://jwt.io +- [3] Rust jsonwebtoken crate, https://crates.io/crates/jsonwebtoken +- [4] Backend's machine at /run/agama/token +- [5] [Firefox startup script] See https://github.com/openSUSE/agama/blob/master/live/root/root/.icewm/startup +- [6] Rust rand crate, https://crates.io/crates/rand diff --git a/live/README.md b/live/README.md index 2afe492e50..1ac7b62bcd 100644 --- a/live/README.md +++ b/live/README.md @@ -1,6 +1,7 @@ # Live ISO + ## Table of Content - [Live ISO](#live-iso) @@ -26,19 +27,15 @@ ## Layout -This directory contains a set of files that are used to build the Agama Live ISO -image. +This directory contains a set of files that are used to build the Agama Live ISO image. -- [src](src) subdirectory contains all source files which are copied unmodified - to the OBS project -- [root](root) subdirectory contains files which are added to the Live ISO root - system (inside the squashfs image) -- [root-ALP-PXE](root-ALP-PXE) subdirectory contains specific files for the ALP - image used for the PXE boot, see a separate [PXE documentation](PXE.md) for - more details about the PXE boot -- [config-cdroot](config-cdroot) subdirectory contains file which are copied to - the uncompressed root of the ISO image, the files can be accessed just by - mounting the ISO file or the DVD medium +- [src](src) subdirectory contains all source files which are copied unmodified to the OBS project +- [root](root) subdirectory contains files which are added to the Live ISO root system (inside the + squashfs image) +- [root-ALP-PXE](root-ALP-PXE) subdirectory contains specific files for the ALP image used for the + PXE boot, see a separate [PXE documentation](PXE.md) for more details about the PXE boot +- [config-cdroot](config-cdroot) subdirectory contains file which are copied to the uncompressed + root of the ISO image, the files can be accessed just by mounting the ISO file or the DVD medium ## Building the Sources @@ -66,17 +63,15 @@ To build the ISO locally run the make build ``` -command. The built ISO image is saved to the `/var/tmp/build-root` directory, -see the end of the build for output for the exact ISO file name. +command. The built ISO image is saved to the `/var/tmp/build-root` directory, see the end of the +build for output for the exact ISO file name. -For building an ISO image you need a lot of free space at the `/var` partition. -Make sure there is at least 25GiB free space otherwise the build will -fail. +For building an ISO image you need a lot of free space at the `/var` partition. Make sure there is +at least 25GiB free space otherwise the build will fail. ### Build Options -By default this will build the openSUSE image. If you want to build -another image then run +By default this will build the openSUSE image. If you want to build another image then run ```shell make build FLAVOR= @@ -84,13 +79,12 @@ make build FLAVOR= make build FLAVOR=ALP ``` -See the [_multibuild](src/_multibuild) file for the list of available build -flavors. +See the [_multibuild](src/_multibuild) file for the list of available build flavors. -By default it will use the [systemsmanagement:Agama:Staging]( -https://build.opensuse.org/project/show/systemsmanagement:Agama:Staging) OBS -project. If you want to build using another project, like your fork, then delete -the `dist` directory and checkout the OBS project manually and run the build: +By default it will use the +[systemsmanagement:Agama:Staging](https://build.opensuse.org/project/show/systemsmanagement:Agama:Staging) +OBS project. If you want to build using another project, like your fork, then delete the `dist` +directory and checkout the OBS project manually and run the build: ```shell rm -rf dist @@ -101,111 +95,99 @@ make build ## Image Definition -The [KIWI](https://github.com/OSInside/kiwi) image builder is used by OBS to -build the Live ISO. See the [KIWI documentation]( -https://osinside.github.io/kiwi/index.html) for more details about the build -workflow and the `.kiwi` file format. +The [KIWI](https://github.com/OSInside/kiwi) image builder is used by OBS to build the Live ISO. See +the [KIWI documentation](https://osinside.github.io/kiwi/index.html) for more details about the +build workflow and the `.kiwi` file format. ### KIWI Files The main Kiwi source files are located in the [src](src) subdirectory: -- [agama-live.kiwi](src/agama-live.kiwi) is the main KIWI file which drives the - ISO image build. -- [config.sh](src/config.sh) is a KIWI hook script which is called and the end - of the build process, after all packages are installed but before compressing - and building the image. The script runs in the image chroot and is usually - used to adjust the system configuration (enable/disable services, patching - configuration files or deleting not needed files). -- [_constraints](src/_constraints) file tells OBS to build the image on the - hosts with enough resources (enough free disk space). -- [_multibuild](src/_multibuild) defines the image flavors (KIWI profiles) - which are available to build -- [images.sh](src/images.sh) - injects a script which checks whether the machine - has enough RAM when booting the Live ISO -- [fix_bootconfig](src/fix_bootconfig) - a special KIWI hook script which sets - the boot configuration on S390 and PPC64 architectures. +- [agama-live.kiwi](src/agama-live.kiwi) is the main KIWI file which drives the ISO image build. +- [config.sh](src/config.sh) is a KIWI hook script which is called and the end of the build process, + after all packages are installed but before compressing and building the image. The script runs in + the image chroot and is usually used to adjust the system configuration (enable/disable services, + patching configuration files or deleting not needed files). +- [_constraints](src/_constraints) file tells OBS to build the image on the hosts with enough + resources (enough free disk space). +- [_multibuild](src/_multibuild) defines the image flavors (KIWI profiles) which are available to + build +- [images.sh](src/images.sh) - injects a script which checks whether the machine has enough RAM when + booting the Live ISO +- [fix_bootconfig](src/fix_bootconfig) - a special KIWI hook script which sets the boot + configuration on S390 and PPC64 architectures. ## Image Configuration -The Live ISO is configured to allow using some features and allow running Agama -there. +The Live ISO is configured to allow using some features and allow running Agama there. ### SSH Server -The SSH connection for the root user is enabled in the [10_root_login.conf]( -root/etc/ssh/sshd_config.d/10_root_login.conf) file. +The SSH connection for the root user is enabled in the +[10_root_login.conf](root/etc/ssh/sshd_config.d/10_root_login.conf) file. ### Autologin -Automatic root login and staring the graphical environment is configured in -several files. +Automatic root login and staring the graphical environment is configured in several files. -- [x11-autologin.service](src/etc/systemd/system/x11-autologin.service) uses - `startx` to start an x11 session. -- `startx` runs the Icewm window manager via [.xinitrc](root/root/.xinitrc) - file. +- [x11-autologin.service](src/etc/systemd/system/x11-autologin.service) uses `startx` to start an + x11 session. +- `startx` runs the Icewm window manager via [.xinitrc](root/root/.xinitrc) file. - Icewm autostarts Firefox via [startup](root/root/.icewm/startup) file. -- Icewm uses the usual YaST2 installation - [preferences.yast2](root/etc/icewm/preferences.yast2) configuration file +- Icewm uses the usual YaST2 installation [preferences.yast2](root/etc/icewm/preferences.yast2) + configuration file ### Firefox Profile -The default Firefox configuration is defined in the -[profile](root/root/.mozilla/firefox/profile) file. It disables several features -which do not make sense in Live ISO like remembering the used passwords. +The default Firefox configuration is defined in the [profile](root/root/.mozilla/firefox/profile) +file. It disables several features which do not make sense in Live ISO like remembering the used +passwords. ### Dracut menu -The [98dracut-menu](live/root/usr/lib/dracut/modules.d/98dracut-menu) directory -implements a simple menu system for dracut. To activate it -during boot add `rd.cmdline=menu` to the boot prompt. This is similar to -`rd.cmdline=ask` which gives you a simple one-line prompt to add boot options. +The [98dracut-menu](live/root/usr/lib/dracut/modules.d/98dracut-menu) directory implements a simple +menu system for dracut. To activate it during boot add `rd.cmdline=menu` to the boot prompt. This is +similar to `rd.cmdline=ask` which gives you a simple one-line prompt to add boot options. -The dracut-cmdline-menu can currently set the `root` and `proxy` options. The -settings are copied (using a dracut pre-pivot hook) to the live system in +The dracut-cmdline-menu can currently set the `root` and `proxy` options. The settings are copied +(using a dracut pre-pivot hook) to the live system in [cmdline-menu.conf](root/etc/cmdline-menu.conf). -There is also the complete command line in the -[cmdline-full.conf](root/etc/cmdline-full.conf) file - maybe it can useful at -least for debugging. +There is also the complete command line in the [cmdline-full.conf](root/etc/cmdline-full.conf) +file - maybe it can useful at least for debugging. -For more details see [dracut.bootup(7)]( -https://man.archlinux.org/man/dracut.bootup.7.en), -[dracut-pre-pivot.service(8)]( -https://man.archlinux.org/man/extra/dracut/dracut-pre-pivot.service.8.en). +For more details see [dracut.bootup(7)](https://man.archlinux.org/man/dracut.bootup.7.en), +[dracut-pre-pivot.service(8)](https://man.archlinux.org/man/extra/dracut/dracut-pre-pivot.service.8.en). -To arrange the dracut config in KIWI you have to adjust the default dracut -config of the live system. This is done in [config.sh](src/config.sh). You can -also fill in a default network location if one is defined for a product -(currently not). +To arrange the dracut config in KIWI you have to adjust the default dracut config of the live +system. This is done in [config.sh](src/config.sh). You can also fill in a default network location +if one is defined for a product (currently not). ### Avahi/mDNS -The mDNS service allows resolving host names in the local network without -a DNS server. That is implemented by the `avahi-daemon` service which enabled -in the [config.sh](src/config.sh) file and installed in the `avahi` RPM package. +The mDNS service allows resolving host names in the local network without a DNS server. That is +implemented by the `avahi-daemon` service which enabled in the [config.sh](src/config.sh) file and +installed in the `avahi` RPM package. The mDNS protocol resolves the hosts in the `.local` domain. #### The Default Hostname -By default the Agama live ISO sets the `agama` host name which can be used -as `agama.local` full hostname in URL. +By default the Agama live ISO sets the `agama` host name which can be used as `agama.local` full +hostname in URL. -The default hostname is set by the -[agama-hostname](root/etc/systemd/system/agama-hostname.service) service. +The default hostname is set by the [agama-hostname](root/etc/systemd/system/agama-hostname.service) +service. -If the hostname is set via the `hostname=` boot parameter then the `agama` -host name is not used, the boot option takes precedence. +If the hostname is set via the `hostname=` boot parameter then the `agama` host name is not used, +the boot option takes precedence. #### Service Advertisement The Avahi HTTPS service announcement is configured via the Avahi [agama.service](root/etc/avahi/services/agama.service) file -That allows scanning all running Agama instances in the local network with -command: +That allows scanning all running Agama instances in the local network with command: ```shell avahi-browse -t -r _agama._sub._https._tcp @@ -213,28 +195,25 @@ avahi-browse -t -r _agama._sub._https._tcp ### The Default Cockpit/Agama TCP Port -The default Cockpit TCP port is 9090. That makes sense for the system management -framework as the default ports might be used by a running Apache or other web -servers. +The default Cockpit TCP port is 9090. That makes sense for the system management framework as the +default ports might be used by a running Apache or other web servers. -But Agama runs from a Live ISO where running a web server does not make much -sense so we can safely use the default HTTP(S) ports. +But Agama runs from a Live ISO where running a web server does not make much sense so we can safely +use the default HTTP(S) ports. The default port is changed in the [listen.conf](root/etc/systemd/system/cockpit.socket.d/listen.conf) file. ### Autoinstallation Support -The autoinstallation is started using the -[agama-auto](root/etc/systemd/system/agama-auto.service) service which starts -the [auto.sh](root/usr/bin/auto.sh) script. This script downloads the +The autoinstallation is started using the [agama-auto](root/etc/systemd/system/agama-auto.service) +service which starts the [auto.sh](root/usr/bin/auto.sh) script. This script downloads the installation profile, applies it to Agama and starts the installation. ### Firmware Cleanup -The [fw_cleanup.rb](root/tmp/fw_cleanup.rb) script removes the unused firmware -from the image. Many firmware files are not needed, this makes the final ISO -much smaller. +The [fw_cleanup.rb](root/tmp/fw_cleanup.rb) script removes the unused firmware from the image. Many +firmware files are not needed, this makes the final ISO much smaller. -This script is started from [config.sh](src/config.sh) the script and after -running it the script deleted. (Not needed anymore in the system.) +This script is started from [config.sh](src/config.sh) the script and after running it the script +deleted. (Not needed anymore in the system.) diff --git a/live/agama-live.kiwi b/live/agama-live.kiwi new file mode 100644 index 0000000000..5b03f485df --- /dev/null +++ b/live/agama-live.kiwi @@ -0,0 +1,195 @@ + + + + + + + YaST Team + yast2-maintainers@suse.de + Agama Live ISO + + + + + + + + + + 7.0.0 + zypper + en_US + us + Europe/Berlin + true + false + bgrt + openSUSE + + + + + + + + + + + + + + + + + + + + + + true + true + /dev/ram1 + false + false + + 3000 + + + + + + + true + true + /dev/ram1 + false + false + + 1900 + + + + + + + true + true + /dev/ram1 + false + false + + 1900 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/live/config.sh b/live/config.sh new file mode 100644 index 0000000000..422643bb8b --- /dev/null +++ b/live/config.sh @@ -0,0 +1,133 @@ +#! /bin/bash + +set -x + +# KIWI functions +test -f /.kconfig && . /.kconfig +test -f /.profile && . /.profile + +# greeting +echo "Configure image: [$kiwi_iname]..." + +# setup baseproduct link +suseSetupProduct + +# activate services +systemctl enable sshd.service +systemctl enable NetworkManager.service +systemctl enable avahi-daemon.service +systemctl enable agama.service +systemctl enable agama-auto.service +systemctl enable agama-hostname.service +systemctl enable agama-proxy-setup.service +systemctl enable setup-systemd-proxy-env.path +systemctl enable x11-autologin.service +systemctl enable spice-vdagent.service +systemctl enable zramswap + +# default target +systemctl set-default graphical.target + +# adjust owner of extracted files +chown -R root:root /root +find /etc -user 1000 | xargs chown root:root + +### setup dracut for live system + +label=${kiwi_install_volid:-$kiwi_iname} +arch=$(uname -m) + +echo "Setting default live root: live:LABEL=$label" +mkdir /etc/cmdline.d +echo "root=live:LABEL=$label" >/etc/cmdline.d/10-liveroot.conf +echo "root_disk=live:LABEL=$label" >>/etc/cmdline.d/10-liveroot.conf +# if there's a default network location, add it here +# echo "root_net=" >> /etc/cmdline.d/10-liveroot.conf +echo 'install_items+=" /etc/cmdline.d/10-liveroot.conf "' >/etc/dracut.conf.d/10-liveroot-file.conf +echo 'add_dracutmodules+=" dracut-menu "' >>/etc/dracut.conf.d/10-liveroot-file.conf + +if [ "${arch}" = "s390x" ];then + # workaround for custom bootloader setting + touch /config.bootoptions +fi + +################################################################################ +# Reducing the used space + +# Clean-up logs +rm /var/log/zypper.log /var/log/zypp/history + +du -h -s /usr/{share,lib}/locale/ +# delete translations and unusupported languages (makes ISO about 22MiB smaller) +# build list of ignore options for "ls" with supported languages like "-I cs* -I de* -I es* ..." +readarray -t IGNORE_OPTS < <(ls /usr/share/cockpit/agama/po.*.js.gz | sed -e "s#/usr/share/cockpit/agama/po\.\(.*\)\.js\.gz#-I\n\\1*#") +# additionally keep the en_US translations +ls -1 "${IGNORE_OPTS[@]}" -I en_US /usr/share/locale/ | xargs -I% sh -c "echo 'Removing translations %...' && rm -rf /usr/share/locale/%" + +# delete locale definitions for unsupported languages (explicitly keep the C and en_US locales) +ls -1 "${IGNORE_OPTS[@]}" -I "en_US*" -I "C.*" /usr/lib/locale/ | xargs -I% sh -c "echo 'Removing locale %...' && rm -rf /usr/lib/locale/%" + +# delete unused translations (MO files) +for t in zypper gettext-runtime p11-kit polkit-1 xkeyboard-config; do + rm /usr/share/locale/*/LC_MESSAGES/$t.mo +done +du -h -s /usr/{share,lib}/locale/ + +# remove documentation +du -h -s /usr/share/doc/packages/ +rm -rf /usr/share/doc/packages/* +# remove man pages +du -h -s /usr/share/man +rm -rf /usr/share/man/* + +## removing drivers and firmware makes the Live ISO about 370MiB smaller +# sound related, Agama does not use sound, added by icewm dependencies +rpm -e --nodeps alsa alsa-utils alsa-ucm-conf + +# driver and firmware cleanup +# Note: openSUSE Tumbleweed Live completely removes firmware for some server +# network cars, because you very likely won't run TW KDE Live on a server. +# But for Agama installer it makes more sense to run on server. So we keep it +# and remove the drivers for sound cards and TV cards instead. Those do not +# make sense on a server. +du -h -s /lib/modules /lib/firmware +# delete sound drivers +rm -rfv /lib/modules/*/kernel/sound +# delete TV cards and radio cards +rm -rfv /lib/modules/*/kernel/drivers/media/ + +# remove the unused firmware (not referenced by kernel drivers) +/fw_cleanup.rb --delete +# remove the script, not needed anymore +rm /fw_cleanup.rb +du -h -s /lib/modules /lib/firmware + +################################################################################ +# The rest of the file was copied from the openSUSE Tumbleweed Live ISO +# https://build.opensuse.org/package/view_file/openSUSE:Factory:Live/livecd-tumbleweed-kde/config.sh?expand=1 +# + +# disable the services included by dependencies +for s in purge-kernels; do + systemctl -f disable $s || true +done + +# Only used for OpenCL and X11 acceleration on vmwgfx (?), saves ~50MiB +rpm -e --nodeps Mesa-gallium +# Too big and will have to be dropped anyway (unmaintained, known security issues) +rm -rf /usr/lib*/libmfxhw*.so.* /usr/lib*/mfx/ + +# the new, optional nvidia gsp firmware blobs are huge - ~ 70MB +du -h -s /lib/firmware/nvidia +find /lib/firmware/nvidia -name gsp | xargs -r rm -rf +du -h -s /lib/firmware/nvidia +# The gems are unpackaged already, no need to store them twice +du -h -s /usr/lib*/ruby/gems/*/cache/ +rm -rf /usr/lib*/ruby/gems/*/cache/ + +# Not needed, boo#1166406 +rm -f /boot/vmlinux*.[gx]z +rm -f /lib/modules/*/vmlinux*.[gx]z + +# Remove generated files (boo#1098535) +rm -rf /var/cache/zypp/* /var/lib/zypp/AnonymousUniqueId /var/lib/systemd/random-seed diff --git a/live/root/.mozilla/firefox/profile/user.js.template b/live/root/.mozilla/firefox/profile/user.js.template new file mode 100644 index 0000000000..afe9e19ccd --- /dev/null +++ b/live/root/.mozilla/firefox/profile/user.js.template @@ -0,0 +1,10 @@ +// Mozilla User Preferences + +// do not remember or generate passwords +user_pref("signon.management.page.breach-alerts.enabled", false); +user_pref("signon.rememberSignons", false); +user_pref("signon.generation.enabled", false); +// start always in the custom homepage +user_pref("browser.startup.page", 1); +// custom homepage: the value is expected to be replaced with the login URL by the startup script +user_pref("browser.startup.homepage", "__HOMEPAGE__"); diff --git a/live/root/.xinitrc b/live/root/.xinitrc new file mode 100644 index 0000000000..fd0aab570a --- /dev/null +++ b/live/root/.xinitrc @@ -0,0 +1 @@ +icewm-session -c /etc/icewm/preferences.yast2 diff --git a/live/root/root/.icewm/startup b/live/root/root/.icewm/startup index 209d5242e2..7f8d091d27 100755 --- a/live/root/root/.icewm/startup +++ b/live/root/root/.icewm/startup @@ -1,3 +1,9 @@ -#! /bin/sh +#!/usr/bin/env sh +# Start a browser to connect to Agama's web user interface skipping the authentication. -BROWSER="firefox --kiosk --profile $HOME/.mozilla/firefox/profile" /usr/libexec/cockpit-desktop /cockpit/@localhost/agama/index.html +TOKEN_FILE=/run/agama/token +TOKEN=$(cat $TOKEN_FILE) +PREFS=$HOME/.mozilla/firefox/profile/user.js + +sed -e "s/__HOMEPAGE__/http:\/\/localhost\/login?token=$TOKEN/" $PREFS.template > $PREFS +firefox --kiosk --profile $HOME/.mozilla/firefox/profile diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7361babac9..fa2c072e0b 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -66,6 +66,7 @@ dependencies = [ "log", "serde", "serde_json", + "serde_repr", "tempfile", "thiserror", "tokio", @@ -113,6 +114,7 @@ dependencies = [ "once_cell", "openssl", "pam", + "pin-project", "rand", "regex", "serde", @@ -125,6 +127,7 @@ dependencies = [ "tokio", "tokio-openssl", "tokio-stream", + "tokio-test", "tower", "tower-http", "tracing", @@ -392,6 +395,28 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "async-stream" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.53", +] + [[package]] name = "async-task" version = "4.7.0" @@ -488,6 +513,7 @@ dependencies = [ "axum", "axum-core", "bytes", + "cookie", "futures-util", "headers", "http 1.1.0", @@ -503,9 +529,9 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.69" +version = "0.3.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +checksum = "95d8e92cac0961e91dbd517496b00f7e9b92363dbe6d42c3198268323798860c" dependencies = [ "addr2line", "cc", @@ -650,9 +676,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.5.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" [[package]] name = "cc" @@ -847,6 +873,17 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cd91cf61412820176e137621345ee43b3f4423e589e7ae4e50d601d93e35ef8" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1524,6 +1561,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ce4ef31cda248bbdb6e6820603b82dfcd9e833db65a43e997a0ccec777d11fe" + [[package]] name = "httparse" version = "1.8.0" @@ -1782,9 +1825,9 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "9.2.0" +version = "9.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7ea04a7c5c055c175f189b6dc6ba036fd62306b58c66c9f6389036c503a3f4" +checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f" dependencies = [ "base64 0.21.7", "js-sys", @@ -1833,9 +1876,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.15" +version = "1.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037731f5d3aaa87a5675e895b63ddff1a87624bc29f77004ea829809654e48f6" +checksum = "5e143b5e666b2695d28f6bca6497720813f699c9602dd7f5cac91008b8ada7f9" dependencies = [ "cc", "libc", @@ -1898,6 +1941,9 @@ name = "macaddr" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8" +dependencies = [ + "serde", +] [[package]] name = "malloc_buf" @@ -1944,6 +1990,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3153,9 +3209,9 @@ dependencies = [ [[package]] name = "temp-dir" -version = "0.1.12" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd16aa9ffe15fe021c6ee3766772132c6e98dfa395a167e16864f61a9cfb71d6" +checksum = "1f227968ec00f0e5322f9b8173c7a0cbcff6181a0a5b28e9892491c286277231" [[package]] name = "tempfile" @@ -3338,6 +3394,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-test" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + [[package]] name = "tokio-tungstenite" version = "0.21.0" @@ -3435,9 +3504,15 @@ dependencies = [ "bitflags 2.5.0", "bytes", "futures-core", + "futures-util", "http 1.1.0", "http-body 1.0.0", "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", "tokio", "tokio-util", @@ -3575,6 +3650,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.15" diff --git a/rust/WEB-SERVER.md b/rust/WEB-SERVER.md index b92c900596..6637309e57 100644 --- a/rust/WEB-SERVER.md +++ b/rust/WEB-SERVER.md @@ -82,10 +82,10 @@ $ curl http://localhost:3000/ping ### Authentication The web server uses a bearer token for HTTP authentication. You can get the token by providing your -password to the `/authenticate` endpoint. +password to the `/auth` endpoint. ``` -$ curl http://localhost:3000/authenticate \ +$ curl http://localhost:3000/api/auth \ -H "Content-Type: application/json" \ -d '{"password": "your-password"}' {"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3MDg1MTA5MzB9.3HmKAC5u4H_FigMqEa9e74OFAq40UldjlaExrOGqE0U"}⏎ diff --git a/rust/agama-cli/src/auth.rs b/rust/agama-cli/src/auth.rs index 0e1c34bf13..a93441bcd8 100644 --- a/rust/agama-cli/src/auth.rs +++ b/rust/agama-cli/src/auth.rs @@ -1,5 +1,5 @@ use clap::{arg, Args, Subcommand}; -use home; + use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE}; use std::fs; use std::fs::File; @@ -9,7 +9,7 @@ use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; const DEFAULT_JWT_FILE: &str = ".agama/agama-jwt"; -const DEFAULT_AUTH_URL: &str = "http://localhost:3000/api/authenticate"; +const DEFAULT_AUTH_URL: &str = "http://localhost:3000/api/auth"; const DEFAULT_FILE_MODE: u32 = 0o600; #[derive(Subcommand, Debug)] @@ -36,7 +36,7 @@ pub async fn run(subcommand: AuthCommands) -> anyhow::Result<()> { /// Reads stored token and returns it fn jwt() -> anyhow::Result { if let Some(file) = jwt_file() { - if let Ok(token) = read_line_from_file(&file.as_path()) { + if let Ok(token) = read_line_from_file(file.as_path()) { return Ok(token); } } @@ -93,7 +93,7 @@ impl Credentials for KnownCredentials { impl Credentials for FileCredentials { fn password(&self) -> io::Result { - read_line_from_file(&self.path.as_path()) + read_line_from_file(self.path.as_path()) } } @@ -119,7 +119,7 @@ fn read_line_from_file(path: &Path) -> io::Result { )); } - if let Ok(file) = File::open(&path) { + if let Ok(file) = File::open(path) { // cares only of first line, take everything. No comments // or something like that supported let raw = BufReader::new(file).lines().next(); diff --git a/rust/agama-lib/Cargo.toml b/rust/agama-lib/Cargo.toml index 40702e2fde..842c93e38e 100644 --- a/rust/agama-lib/Cargo.toml +++ b/rust/agama-lib/Cargo.toml @@ -16,6 +16,7 @@ jsonschema = { version = "0.16.1", default-features = false } log = "0.4" serde = { version = "1.0.152", features = ["derive"] } serde_json = "1.0.94" +serde_repr = "0.1.18" tempfile = "3.4.0" thiserror = "1.0.39" tokio = { version = "1.33.0", features = ["macros", "rt-multi-thread"] } diff --git a/rust/agama-lib/src/dbus.rs b/rust/agama-lib/src/dbus.rs index 863b42cb65..02ca6f302c 100644 --- a/rust/agama-lib/src/dbus.rs +++ b/rust/agama-lib/src/dbus.rs @@ -1,7 +1,48 @@ +use anyhow::Context; use std::collections::HashMap; -use zbus::zvariant; +use zbus::zvariant::{self, OwnedValue, Value}; + +use crate::error::ServiceError; /// Nested hash to send to D-Bus. pub type NestedHash<'a> = HashMap<&'a str, HashMap<&'a str, zvariant::Value<'a>>>; /// Nested hash as it comes from D-Bus. pub type OwnedNestedHash = HashMap>; + +/// Helper to get property of given type from ManagedObjects map or any generic D-Bus Hash with variant as value +pub fn get_property<'a, T>( + properties: &'a HashMap, + name: &str, +) -> Result +where + T: TryFrom>, + >>::Error: Into, +{ + let value: Value = properties + .get(name) + .ok_or(zbus::zvariant::Error::Message(format!( + "Failed to find property '{}'", + name + )))? + .into(); + + T::try_from(value).map_err(|e| e.into()) +} + +/// It is similar helper like get_property with difference that name does not need to be in HashMap. +/// In such case `None` is returned, so type has to be enclosed in `Option`. +pub fn get_optional_property<'a, T>( + properties: &'a HashMap, + name: &str, +) -> Result, zbus::zvariant::Error> +where + T: TryFrom>, + >>::Error: Into, +{ + if let Some(value) = properties.get(name) { + let value: Value = value.into(); + T::try_from(value).map(|v| Some(v)).map_err(|e| e.into()) + } else { + Ok(None) + } +} diff --git a/rust/agama-lib/src/error.rs b/rust/agama-lib/src/error.rs index 03106ccde4..30f61af834 100644 --- a/rust/agama-lib/src/error.rs +++ b/rust/agama-lib/src/error.rs @@ -2,7 +2,7 @@ use curl; use serde_json; use std::io; use thiserror::Error; -use zbus; +use zbus::{self, zvariant}; #[derive(Error, Debug)] pub enum ServiceError { @@ -10,6 +10,10 @@ pub enum ServiceError { DBus(#[from] zbus::Error), #[error("Could not connect to Agama bus at '{0}': {1}")] DBusConnectionError(String, #[source] zbus::Error), + #[error("D-Bus protocol error: {0}")] + DBusProtocol(#[from] zbus::fdo::Error), + #[error("Unexpected type on D-Bus '{0}'")] + ZVariant(#[from] zvariant::Error), // it's fine to say only "Error" because the original // specific error will be printed too #[error("Error: {0}")] @@ -22,6 +26,8 @@ pub enum ServiceError { UnknownPatterns(Vec), #[error("Could not perform action '{0}'")] UnsuccessfulAction(String), + #[error("Unknown installation phase: '{0}")] + UnknownInstallationPhase(u32), } #[derive(Error, Debug)] diff --git a/rust/agama-lib/src/localization.rs b/rust/agama-lib/src/localization.rs index 32e5462b34..65bb1ae8bc 100644 --- a/rust/agama-lib/src/localization.rs +++ b/rust/agama-lib/src/localization.rs @@ -6,5 +6,6 @@ mod settings; mod store; pub use client::LocalizationClient; +pub use proxies::LocaleProxy; pub use settings::LocalizationSettings; pub use store::LocalizationStore; diff --git a/rust/agama-lib/src/localization/client.rs b/rust/agama-lib/src/localization/client.rs index 6e59ef35de..2b15ea2c78 100644 --- a/rust/agama-lib/src/localization/client.rs +++ b/rust/agama-lib/src/localization/client.rs @@ -3,6 +3,7 @@ use crate::error::ServiceError; use zbus::Connection; /// D-Bus client for the software service +#[derive(Clone)] pub struct LocalizationClient<'a> { localization_proxy: LocaleProxy<'a>, } @@ -22,6 +23,10 @@ impl<'a> LocalizationClient<'a> { Ok(first) } + pub async fn locales(&self) -> zbus::Result> { + self.localization_proxy.locales().await + } + pub async fn keyboard(&self) -> Result { Ok(self.localization_proxy.keymap().await?) } @@ -35,6 +40,10 @@ impl<'a> LocalizationClient<'a> { self.localization_proxy.set_locales(&locales).await } + pub async fn set_locales(&self, locales: &[&str]) -> zbus::Result<()> { + self.localization_proxy.set_locales(locales).await + } + pub async fn set_keyboard(&self, keyboard: &str) -> zbus::Result<()> { self.localization_proxy.set_keymap(keyboard).await } diff --git a/rust/agama-lib/src/localization/proxies.rs b/rust/agama-lib/src/localization/proxies.rs index 9e271f930d..fe1550f664 100644 --- a/rust/agama-lib/src/localization/proxies.rs +++ b/rust/agama-lib/src/localization/proxies.rs @@ -39,6 +39,6 @@ trait Locale { /// UILocale property #[dbus_proxy(property, name = "UILocale")] fn uilocale(&self) -> zbus::Result; - #[dbus_proxy(property)] + #[dbus_proxy(property, name = "UILocale")] fn set_uilocale(&self, value: &str) -> zbus::Result<()>; } diff --git a/rust/agama-lib/src/manager.rs b/rust/agama-lib/src/manager.rs index 52ad830024..ef7d9f7bc2 100644 --- a/rust/agama-lib/src/manager.rs +++ b/rust/agama-lib/src/manager.rs @@ -1,45 +1,96 @@ +//! This module implements the web API for the manager module. + use crate::error::ServiceError; use crate::proxies::ServiceStatusProxy; use crate::{ progress::Progress, - proxies::{ManagerProxy, ProgressProxy}, + proxies::{Manager1Proxy, ProgressProxy}, }; +use serde_repr::Serialize_repr; use tokio_stream::StreamExt; use zbus::Connection; /// D-Bus client for the manager service +#[derive(Clone)] pub struct ManagerClient<'a> { - manager_proxy: ManagerProxy<'a>, + manager_proxy: Manager1Proxy<'a>, progress_proxy: ProgressProxy<'a>, status_proxy: ServiceStatusProxy<'a>, } +/// Represents the installation phase. +/// NOTE: does this conversion have any value? +#[derive(Clone, Copy, Debug, PartialEq, Serialize_repr, utoipa::ToSchema)] +#[repr(u32)] +pub enum InstallationPhase { + /// Start up phase. + Startup, + /// Configuration phase. + Config, + /// Installation phase. + Install, +} + +impl TryFrom for InstallationPhase { + type Error = ServiceError; + + fn try_from(value: u32) -> Result { + match value { + 0 => Ok(Self::Startup), + 1 => Ok(Self::Config), + 2 => Ok(Self::Install), + _ => Err(ServiceError::UnknownInstallationPhase(value)), + } + } +} + impl<'a> ManagerClient<'a> { pub async fn new(connection: Connection) -> zbus::Result> { Ok(Self { - manager_proxy: ManagerProxy::new(&connection).await?, + manager_proxy: Manager1Proxy::new(&connection).await?, progress_proxy: ProgressProxy::new(&connection).await?, status_proxy: ServiceStatusProxy::new(&connection).await?, }) } + /// Returns the list of busy services. pub async fn busy_services(&self) -> Result, ServiceError> { Ok(self.manager_proxy.busy_services().await?) } + /// Returns the current installation phase. + pub async fn current_installation_phase(&self) -> Result { + let phase = self.manager_proxy.current_installation_phase().await?; + phase.try_into() + } + + /// Starts the probing process. pub async fn probe(&self) -> Result<(), ServiceError> { self.wait().await?; Ok(self.manager_proxy.probe().await?) } + /// Starts the installation. pub async fn install(&self) -> Result<(), ServiceError> { Ok(self.manager_proxy.commit().await?) } + /// Executes the after installation tasks. + pub async fn finish(&self) -> Result<(), ServiceError> { + Ok(self.manager_proxy.finish().await?) + } + + /// Determines whether it is possible to start the installation. pub async fn can_install(&self) -> Result { Ok(self.manager_proxy.can_install().await?) } + /// Determines whether the installer is running on Iguana. + pub async fn use_iguana(&self) -> Result { + Ok(self.manager_proxy.iguana_backend().await?) + } + + /// Returns the current progress. pub async fn progress(&self) -> zbus::Result { Progress::from_proxy(&self.progress_proxy).await } diff --git a/rust/agama-lib/src/network/client.rs b/rust/agama-lib/src/network/client.rs index b8a6b427a4..a76bf4c431 100644 --- a/rust/agama-lib/src/network/client.rs +++ b/rust/agama-lib/src/network/client.rs @@ -3,7 +3,7 @@ use super::proxies::{ WirelessProxy, }; use super::settings::{BondSettings, MatchSettings, NetworkConnection, WirelessSettings}; -use super::types::{Device, DeviceType, SSID}; +use super::types::{Device, DeviceState, DeviceType, SSID}; use crate::error::ServiceError; use tokio_stream::StreamExt; use zbus::zvariant::OwnedObjectPath; @@ -86,10 +86,12 @@ impl<'a> NetworkClient<'a> { .await?; let name = device_proxy.name().await?; let device_type = device_proxy.type_().await?; + let state = DeviceState::try_from(device_proxy.state().await?).expect("Unknown state"); Ok(Device { name, - type_: DeviceType::try_from(device_type).unwrap(), + type_: DeviceType::try_from(device_type).expect("Unknown type"), + state, }) } diff --git a/rust/agama-lib/src/network/proxies.rs b/rust/agama-lib/src/network/proxies.rs index 9710980885..db7de278b1 100644 --- a/rust/agama-lib/src/network/proxies.rs +++ b/rust/agama-lib/src/network/proxies.rs @@ -25,6 +25,9 @@ trait Device { /// Type property #[dbus_proxy(property)] fn type_(&self) -> zbus::Result; + /// State property + #[dbus_proxy(property)] + fn state(&self) -> zbus::Result; } #[dbus_proxy( diff --git a/rust/agama-lib/src/network/settings.rs b/rust/agama-lib/src/network/settings.rs index db0321f74c..444e9c0b2d 100644 --- a/rust/agama-lib/src/network/settings.rs +++ b/rust/agama-lib/src/network/settings.rs @@ -1,6 +1,6 @@ //! Representation of the network settings -use super::types::DeviceType; +use super::types::{DeviceState, DeviceType, Status}; use agama_settings::error::ConversionError; use agama_settings::{SettingObject, SettingValue, Settings}; use cidr::IpInet; @@ -71,6 +71,7 @@ impl Default for BondSettings { pub struct NetworkDevice { pub id: String, pub type_: DeviceType, + pub state: DeviceState, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] @@ -100,6 +101,8 @@ pub struct NetworkConnection { pub bond: Option, #[serde(rename = "mac-address", skip_serializing_if = "Option::is_none")] pub mac_address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, } impl NetworkConnection { diff --git a/rust/agama-lib/src/network/types.rs b/rust/agama-lib/src/network/types.rs index cb8f9695c8..9bc89e13fb 100644 --- a/rust/agama-lib/src/network/types.rs +++ b/rust/agama-lib/src/network/types.rs @@ -1,13 +1,19 @@ +use cidr::errors::NetworkParseError; use serde::{Deserialize, Serialize}; -use std::{fmt, str}; +use std::{ + fmt, + str::{self, FromStr}, +}; use thiserror::Error; use zbus; /// Network device -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] pub struct Device { pub name: String, pub type_: DeviceType, + pub state: DeviceState, } #[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)] @@ -25,15 +31,25 @@ impl fmt::Display for SSID { } } +impl FromStr for SSID { + type Err = NetworkParseError; + + fn from_str(s: &str) -> Result { + Ok(SSID(s.as_bytes().into())) + } +} + impl From for Vec { fn from(value: SSID) -> Self { value.0 } } -#[derive(Debug, PartialEq, Copy, Clone, Serialize, Deserialize)] +#[derive(Default, Debug, PartialEq, Copy, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] pub enum DeviceType { Loopback = 0, + #[default] Ethernet = 1, Wireless = 2, Dummy = 3, @@ -42,6 +58,110 @@ pub enum DeviceType { Bridge = 6, } +// For now this mirrors NetworkManager, because it was less mental work than coming up with +// what exactly Agama needs. Expected to be adapted. +#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DeviceState { + #[default] + Unknown = 0, + Unmanaged = 10, + Unavailable = 20, + Disconnected = 30, + Prepare = 40, + Config = 50, + NeedAuth = 60, + IpConfig = 70, + IpCheck = 80, + Secondaries = 90, + Activated = 100, + Deactivating = 110, + Failed = 120, +} +#[derive(Debug, Error, PartialEq)] +#[error("Invalid state: {0}")] +pub struct InvalidDeviceState(String); + +impl TryFrom for DeviceState { + type Error = InvalidDeviceState; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(DeviceState::Unknown), + 10 => Ok(DeviceState::Unmanaged), + 20 => Ok(DeviceState::Unavailable), + 30 => Ok(DeviceState::Disconnected), + 40 => Ok(DeviceState::Prepare), + 50 => Ok(DeviceState::Config), + 60 => Ok(DeviceState::NeedAuth), + 70 => Ok(DeviceState::IpConfig), + 80 => Ok(DeviceState::IpCheck), + 90 => Ok(DeviceState::Secondaries), + 100 => Ok(DeviceState::Activated), + 110 => Ok(DeviceState::Deactivating), + 120 => Ok(DeviceState::Failed), + _ => Err(InvalidDeviceState(value.to_string())), + } + } +} +impl fmt::Display for DeviceState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match &self { + DeviceState::Unknown => "unknown", + DeviceState::Unmanaged => "unmanaged", + DeviceState::Unavailable => "unavailable", + DeviceState::Disconnected => "disconnected", + DeviceState::Prepare => "prepare", + DeviceState::Config => "config", + DeviceState::NeedAuth => "need_auth", + DeviceState::IpConfig => "ip_config", + DeviceState::IpCheck => "ip_check", + DeviceState::Secondaries => "secondaries", + DeviceState::Activated => "activated", + DeviceState::Deactivating => "deactivating", + DeviceState::Failed => "failed", + }; + write!(f, "{}", name) + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Status { + #[default] + Up, + Down, + Removed, +} + +impl fmt::Display for Status { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match &self { + Status::Up => "up", + Status::Down => "down", + Status::Removed => "removed", + }; + write!(f, "{}", name) + } +} + +#[derive(Debug, Error, PartialEq)] +#[error("Invalid status: {0}")] +pub struct InvalidStatus(String); + +impl TryFrom<&str> for Status { + type Error = InvalidStatus; + + fn try_from(value: &str) -> Result { + match value { + "up" => Ok(Status::Up), + "down" => Ok(Status::Down), + "removed" => Ok(Status::Removed), + _ => Err(InvalidStatus(value.to_string())), + } + } +} + /// Bond mode #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy)] pub enum BondMode { diff --git a/rust/agama-lib/src/product.rs b/rust/agama-lib/src/product.rs index 8b352f602d..c7a0a6582e 100644 --- a/rust/agama-lib/src/product.rs +++ b/rust/agama-lib/src/product.rs @@ -1,10 +1,10 @@ //! Implements support for handling the product settings mod client; -mod proxies; +pub mod proxies; mod settings; mod store; -pub use client::{Product, ProductClient}; +pub use client::{Product, ProductClient, RegistrationRequirement}; pub use settings::ProductSettings; pub use store::ProductStore; diff --git a/rust/agama-lib/src/product/client.rs b/rust/agama-lib/src/product/client.rs index 2f4e18f432..a75824e8f1 100644 --- a/rust/agama-lib/src/product/client.rs +++ b/rust/agama-lib/src/product/client.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use crate::error::ServiceError; use crate::software::proxies::SoftwareProductProxy; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use zbus::Connection; use super::proxies::RegistrationProxy; @@ -18,6 +18,35 @@ pub struct Product { pub description: String, } +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub enum RegistrationRequirement { + /// Product does not require registration + NotRequired = 0, + /// Product has optional registration + Optional = 1, + /// It is mandatory to register the product + Mandatory = 2, +} + +impl TryFrom for RegistrationRequirement { + type Error = (); + + fn try_from(v: u32) -> Result { + match v { + x if x == RegistrationRequirement::NotRequired as u32 => { + Ok(RegistrationRequirement::NotRequired) + } + x if x == RegistrationRequirement::Optional as u32 => { + Ok(RegistrationRequirement::Optional) + } + x if x == RegistrationRequirement::Mandatory as u32 => { + Ok(RegistrationRequirement::Mandatory) + } + _ => Err(()), + } + } +} + /// D-Bus client for the software service #[derive(Clone)] pub struct ProductClient<'a> { @@ -86,6 +115,13 @@ impl<'a> ProductClient<'a> { Ok(self.registration_proxy.email().await?) } + pub async fn registration_requirement(&self) -> Result { + let requirement = self.registration_proxy.requirement().await?; + // unknown number can happen only if we do programmer mistake + let result: RegistrationRequirement = requirement.try_into().unwrap(); + Ok(result) + } + /// register product pub async fn register(&self, code: &str, email: &str) -> Result<(u32, String), ServiceError> { let mut options: HashMap<&str, zbus::zvariant::Value> = HashMap::new(); @@ -94,4 +130,9 @@ impl<'a> ProductClient<'a> { } Ok(self.registration_proxy.register(code, options).await?) } + + /// de-register product + pub async fn deregister(&self) -> Result<(u32, String), ServiceError> { + Ok(self.registration_proxy.deregister().await?) + } } diff --git a/rust/agama-lib/src/progress.rs b/rust/agama-lib/src/progress.rs index b79db61195..ab9bd5aa89 100644 --- a/rust/agama-lib/src/progress.rs +++ b/rust/agama-lib/src/progress.rs @@ -78,6 +78,19 @@ impl Progress { finished: finished?, }) } + + pub fn from_cached_proxy(proxy: &crate::proxies::ProgressProxy<'_>) -> Option { + let (current_step, current_title) = proxy.cached_current_step().ok()??; + let max_steps = proxy.cached_total_steps().ok()??; + let finished = proxy.cached_finished().ok()??; + + Some(Progress { + current_step, + current_title, + max_steps, + finished, + }) + } } /// Monitorizes and reports the progress of Agama's current operation. diff --git a/rust/agama-lib/src/proxies.rs b/rust/agama-lib/src/proxies.rs index a2d8673835..d33efa6921 100644 --- a/rust/agama-lib/src/proxies.rs +++ b/rust/agama-lib/src/proxies.rs @@ -47,16 +47,19 @@ trait ServiceStatus { default_service = "org.opensuse.Agama.Manager1", default_path = "/org/opensuse/Agama/Manager1" )] -trait Manager { +trait Manager1 { /// CanInstall method fn can_install(&self) -> zbus::Result; /// CollectLogs method - fn collect_logs(&self, user: &str) -> zbus::Result; + fn collect_logs(&self) -> zbus::Result; /// Commit method fn commit(&self) -> zbus::Result<()>; + /// Finish method + fn finish(&self) -> zbus::Result<()>; + /// Probe method fn probe(&self) -> zbus::Result<()>; @@ -68,6 +71,10 @@ trait Manager { #[dbus_proxy(property)] fn current_installation_phase(&self) -> zbus::Result; + /// IguanaBackend property + #[dbus_proxy(property)] + fn iguana_backend(&self) -> zbus::Result; + /// InstallationPhases property #[dbus_proxy(property)] fn installation_phases( @@ -89,7 +96,7 @@ trait Questions1 { /// New method #[dbus_proxy(name = "New")] - fn new_quetion( + fn new_question( &self, class: &str, text: &str, @@ -114,3 +121,71 @@ trait Questions1 { #[dbus_proxy(property)] fn set_interactive(&self, value: bool) -> zbus::Result<()>; } + +#[dbus_proxy( + interface = "org.opensuse.Agama1.Questions.Generic", + default_service = "org.opensuse.Agama1", + default_path = "/org/opensuse/Agama1/Questions" +)] +trait GenericQuestion { + /// Answer property + #[dbus_proxy(property)] + fn answer(&self) -> zbus::Result; + #[dbus_proxy(property)] + fn set_answer(&self, value: &str) -> zbus::Result<()>; + + /// Class property + #[dbus_proxy(property)] + fn class(&self) -> zbus::Result; + + /// Data property + #[dbus_proxy(property)] + fn data(&self) -> zbus::Result>; + + /// DefaultOption property + #[dbus_proxy(property)] + fn default_option(&self) -> zbus::Result; + + /// Id property + #[dbus_proxy(property)] + fn id(&self) -> zbus::Result; + + /// Options property + #[dbus_proxy(property)] + fn options(&self) -> zbus::Result>; + + /// Text property + #[dbus_proxy(property)] + fn text(&self) -> zbus::Result; +} + +#[dbus_proxy( + interface = "org.opensuse.Agama1.Questions.WithPassword", + default_service = "org.opensuse.Agama1", + default_path = "/org/opensuse/Agama1/Questions" +)] +trait QuestionWithPassword { + /// Password property + #[dbus_proxy(property)] + fn password(&self) -> zbus::Result; + #[dbus_proxy(property)] + fn set_password(&self, value: &str) -> zbus::Result<()>; +} + +#[dbus_proxy(interface = "org.opensuse.Agama1.Issues", assume_defaults = true)] +trait Issues { + /// All property + #[dbus_proxy(property)] + fn all(&self) -> zbus::Result>; +} + +#[dbus_proxy(interface = "org.opensuse.Agama1.Validation", assume_defaults = true)] +trait Validation { + /// Errors property + #[dbus_proxy(property)] + fn errors(&self) -> zbus::Result>; + + /// Valid property + #[dbus_proxy(property)] + fn valid(&self) -> zbus::Result; +} diff --git a/rust/agama-lib/src/software/client.rs b/rust/agama-lib/src/software/client.rs index 4769976847..1364956196 100644 --- a/rust/agama-lib/src/software/client.rs +++ b/rust/agama-lib/src/software/client.rs @@ -1,14 +1,15 @@ use super::proxies::Software1Proxy; use crate::error::ServiceError; use serde::Serialize; +use serde_repr::Serialize_repr; use std::collections::HashMap; use zbus::Connection; /// Represents a software product #[derive(Debug, Serialize, utoipa::ToSchema)] pub struct Pattern { - /// Pattern ID (eg., "aaa_base", "gnome") - pub id: String, + /// Pattern name (eg., "aaa_base", "gnome") + pub name: String, /// Pattern category (e.g., "Production") pub category: String, /// Pattern icon path locally on system @@ -22,7 +23,8 @@ pub struct Pattern { } /// Represents the reason why a pattern is selected. -#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Serialize_repr)] +#[repr(u8)] pub enum SelectedBy { /// The pattern was selected by the user. User = 0, @@ -69,8 +71,8 @@ impl<'a> SoftwareClient<'a> { .await? .into_iter() .map( - |(id, (category, description, icon, summary, order))| Pattern { - id, + |(name, (category, description, icon, summary, order))| Pattern { + name, category, icon, description, @@ -118,11 +120,19 @@ impl<'a> SoftwareClient<'a> { } /// Selects patterns by user - pub async fn select_patterns(&self, patterns: &[String]) -> Result<(), ServiceError> { - let patterns: Vec<&str> = patterns.iter().map(AsRef::as_ref).collect(); + pub async fn select_patterns( + &self, + patterns: HashMap, + ) -> Result<(), ServiceError> { + let (add, remove): (Vec<_>, Vec<_>) = + patterns.into_iter().partition(|(_, install)| *install); + + let add: Vec<_> = add.iter().map(|(name, _)| name.as_ref()).collect(); + let remove: Vec<_> = remove.iter().map(|(name, _)| name.as_ref()).collect(); + let wrong_patterns = self .software_proxy - .set_user_patterns(patterns.as_slice()) + .set_user_patterns(add.as_slice(), remove.as_slice()) .await?; if !wrong_patterns.is_empty() { Err(ServiceError::UnknownPatterns(wrong_patterns)) diff --git a/rust/agama-lib/src/software/proxies.rs b/rust/agama-lib/src/software/proxies.rs index e7298f2187..cdb79b4829 100644 --- a/rust/agama-lib/src/software/proxies.rs +++ b/rust/agama-lib/src/software/proxies.rs @@ -48,7 +48,7 @@ trait Software1 { fn remove_pattern(&self, id: &str) -> zbus::Result; /// SetUserPatterns method - fn set_user_patterns(&self, ids: &[&str]) -> zbus::Result>; + fn set_user_patterns(&self, add: &[&str], remove: &[&str]) -> zbus::Result>; /// UsedDiskSpace method fn used_disk_space(&self) -> zbus::Result; diff --git a/rust/agama-lib/src/software/store.rs b/rust/agama-lib/src/software/store.rs index 66b6f6b092..5bb5ee21c4 100644 --- a/rust/agama-lib/src/software/store.rs +++ b/rust/agama-lib/src/software/store.rs @@ -1,5 +1,7 @@ //! Implements the store for the storage settings. +use std::collections::HashMap; + use super::{SoftwareClient, SoftwareSettings}; use crate::error::ServiceError; use zbus::Connection; @@ -22,9 +24,12 @@ impl<'a> SoftwareStore<'a> { } pub async fn store(&self, settings: &SoftwareSettings) -> Result<(), ServiceError> { - self.software_client - .select_patterns(&settings.patterns) - .await?; + let patterns: HashMap = settings + .patterns + .iter() + .map(|name| (name.to_owned(), true)) + .collect(); + self.software_client.select_patterns(patterns).await?; Ok(()) } diff --git a/rust/agama-lib/src/storage.rs b/rust/agama-lib/src/storage.rs index dfb9105357..a6796ae7f5 100644 --- a/rust/agama-lib/src/storage.rs +++ b/rust/agama-lib/src/storage.rs @@ -1,6 +1,7 @@ //! Implements support for handling the storage settings -mod client; +pub mod client; +pub mod device; mod proxies; mod settings; mod store; diff --git a/rust/agama-lib/src/storage/client.rs b/rust/agama-lib/src/storage/client.rs index 06455992b8..3828c02720 100644 --- a/rust/agama-lib/src/storage/client.rs +++ b/rust/agama-lib/src/storage/client.rs @@ -1,12 +1,17 @@ //! Implements a client to access Agama's storage service. -use super::proxies::{BlockDeviceProxy, ProposalCalculatorProxy, ProposalProxy, Storage1Proxy}; +use super::device::{BlockDevice, Device, DeviceInfo}; +use super::proxies::{DeviceProxy, ProposalCalculatorProxy, ProposalProxy, Storage1Proxy}; use super::StorageSettings; +use crate::dbus::{get_optional_property, get_property}; use crate::error::ServiceError; +use anyhow::{anyhow, Context}; use futures_util::future::join_all; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use zbus::zvariant::OwnedObjectPath; +use zbus::fdo::ObjectManagerProxy; +use zbus::names::{InterfaceName, OwnedInterfaceName}; +use zbus::zvariant::{OwnedObjectPath, OwnedValue}; use zbus::Connection; /// Represents a storage device @@ -16,11 +21,100 @@ pub struct StorageDevice { description: String, } +/// Represents a single change action done to storage +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Action { + device: String, + text: String, + subvol: bool, + delete: bool, +} + +/// Represents value for target key of Volume +/// It is snake cased when serializing to be compatible with yast2-storage-ng. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum VolumeTarget { + Default, + NewPartition, + NewVg, + Device, + Filesystem, +} + +impl TryFrom> for VolumeTarget { + type Error = zbus::zvariant::Error; + + fn try_from(value: zbus::zvariant::Value) -> Result { + let svalue: String = value.try_into()?; + match svalue.as_str() { + "default" => Ok(VolumeTarget::Default), + "new_partition" => Ok(VolumeTarget::NewPartition), + "new_vg" => Ok(VolumeTarget::NewVg), + "device" => Ok(VolumeTarget::Device), + "filesystem" => Ok(VolumeTarget::Filesystem), + _ => Err(zbus::zvariant::Error::Message( + format!("Wrong value for Target: {}", svalue).to_string(), + )), + } + } +} + +/// Represents volume outline aka requirements for volume +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct VolumeOutline { + required: bool, + fs_types: Vec, + support_auto_size: bool, + snapshots_configurable: bool, + snaphosts_affect_sizes: bool, + size_relevant_volumes: Vec, +} + +impl TryFrom> for VolumeOutline { + type Error = zbus::zvariant::Error; + + fn try_from(value: zbus::zvariant::Value) -> Result { + let mvalue: HashMap = value.try_into()?; + let res = VolumeOutline { + required: get_property(&mvalue, "Required")?, + fs_types: get_property(&mvalue, "FsTypes")?, + support_auto_size: get_property(&mvalue, "SupportAutoSize")?, + snapshots_configurable: get_property(&mvalue, "SnapshotsConfigurable")?, + snaphosts_affect_sizes: get_property(&mvalue, "SnapshotsAffectSizes")?, + size_relevant_volumes: get_property(&mvalue, "SizeRelevantVolumes")?, + }; + + Ok(res) + } +} + +/// Represents a single volume +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Volume { + mount_path: String, + mount_options: Vec, + target: VolumeTarget, + target_device: Option, + min_size: u64, + max_size: Option, + auto_size: bool, + snapshots: Option, + transactional: Option, + outline: Option, +} + /// D-Bus client for the storage service +#[derive(Clone)] pub struct StorageClient<'a> { pub connection: Connection, calculator_proxy: ProposalCalculatorProxy<'a>, storage_proxy: Storage1Proxy<'a>, + object_manager_proxy: ObjectManagerProxy<'a>, + proposal_proxy: ProposalProxy<'a>, } impl<'a> StorageClient<'a> { @@ -28,6 +122,12 @@ impl<'a> StorageClient<'a> { Ok(Self { calculator_proxy: ProposalCalculatorProxy::new(&connection).await?, storage_proxy: Storage1Proxy::new(&connection).await?, + object_manager_proxy: ObjectManagerProxy::builder(&connection) + .destination("org.opensuse.Agama.Storage1")? + .path("/org/opensuse/Agama/Storage1")? + .build() + .await?, + proposal_proxy: ProposalProxy::new(&connection).await?, connection, }) } @@ -40,6 +140,27 @@ impl<'a> StorageClient<'a> { Ok(ProposalProxy::new(&self.connection).await?) } + pub async fn devices_dirty_bit(&self) -> Result { + Ok(self.storage_proxy.deprecated_system().await?) + } + + pub async fn actions(&self) -> Result, ServiceError> { + let actions = self.proposal_proxy.actions().await?; + let mut result: Vec = Vec::with_capacity(actions.len()); + + for i in actions { + let action = Action { + device: get_property(&i, "Device")?, + text: get_property(&i, "Text")?, + subvol: get_property(&i, "Subvol")?, + delete: get_property(&i, "Delete")?, + }; + result.push(action); + } + + Ok(result) + } + /// Returns the available devices /// /// These devices can be used for installing the system. @@ -55,22 +176,38 @@ impl<'a> StorageClient<'a> { join_all(devices).await.into_iter().collect() } + pub async fn volume_for(&self, mount_path: &str) -> Result { + let volume_hash = self.calculator_proxy.default_volume(mount_path).await?; + let volume = Volume { + mount_path: get_property(&volume_hash, "MountPath")?, + mount_options: get_property(&volume_hash, "MountOptions")?, + target: get_property(&volume_hash, "Target")?, + target_device: get_optional_property(&volume_hash, "TargetDevice")?, + min_size: get_property(&volume_hash, "MinSize")?, + max_size: get_optional_property(&volume_hash, "MaxSize")?, + auto_size: get_property(&volume_hash, "AutoSize")?, + snapshots: get_optional_property(&volume_hash, "Snapshots")?, + transactional: get_optional_property(&volume_hash, "Transactional")?, + outline: get_optional_property(&volume_hash, "Outline")?, + }; + + Ok(volume) + } + /// Returns the storage device for the given D-Bus path async fn storage_device( &self, dbus_path: OwnedObjectPath, ) -> Result { - let proxy = BlockDeviceProxy::builder(&self.connection) + let proxy = DeviceProxy::builder(&self.connection) .path(dbus_path)? .build() .await?; - let name = proxy.name().await?; - // TODO: The description is not used yet. Decide what info to show, for example the device - // size, see https://crates.io/crates/size. - let description = name.clone(); - - Ok(StorageDevice { name, description }) + Ok(StorageDevice { + name: proxy.name().await?, + description: proxy.description().await?, + }) } /// Returns the boot device proposal setting @@ -140,4 +277,106 @@ impl<'a> StorageClient<'a> { Ok(self.calculator_proxy.calculate(dbus_settings).await?) } + + async fn build_device( + &self, + object: &( + OwnedObjectPath, + HashMap>, + ), + ) -> Result { + let interfaces = &object.1; + Ok(Device { + device_info: self.build_device_info(object).await?, + component: None, + drive: None, + block_device: self.build_block_device(interfaces).await?, + filesystem: None, + lvm_lv: None, + lvm_vg: None, + md: None, + multipath: None, + partition: None, + partition_table: None, + raid: None, + }) + } + + pub async fn system_devices(&self) -> Result, ServiceError> { + let objects = self.object_manager_proxy.get_managed_objects().await?; + let mut result = vec![]; + for object in objects { + let path = &object.0; + if !path.as_str().contains("Storage1/system") { + continue; + } + + result.push(self.build_device(&object).await?) + } + + Ok(result) + } + + pub async fn staging_devices(&self) -> Result, ServiceError> { + let objects = self.object_manager_proxy.get_managed_objects().await?; + let mut result = vec![]; + for object in objects { + let path = &object.0; + if !path.as_str().contains("Storage1/staging") { + continue; + } + + result.push(self.build_device(&object).await?) + } + + Ok(result) + } + + async fn build_device_info( + &self, + object: &( + OwnedObjectPath, + HashMap>, + ), + ) -> Result { + let interfaces = &object.1; + let interface: OwnedInterfaceName = + InterfaceName::from_static_str_unchecked("org.opensuse.Agama.Storage1.Device").into(); + let properties = interfaces.get(&interface); + // All devices has to implement device info, so report error if it is not there + if let Some(properties) = properties { + Ok(DeviceInfo { + sid: get_property(properties, "SID")?, + name: get_property(properties, "Name")?, + description: get_property(properties, "Description")?, + }) + } else { + let message = + format!("storage device {} is missing Device interface", object.0).to_string(); + Err(zbus::zvariant::Error::Message(message).into()) + } + } + + async fn build_block_device( + &self, + interfaces: &HashMap>, + ) -> Result, ServiceError> { + let interface: OwnedInterfaceName = + InterfaceName::from_static_str_unchecked("org.opensuse.Agama.Storage1.Block").into(); + let properties = interfaces.get(&interface); + if let Some(properties) = properties { + Ok(Some(BlockDevice { + active: get_property(properties, "Active")?, + encrypted: get_property(properties, "Encrypted")?, + recoverable_size: get_property(properties, "RecoverableSize")?, + size: get_property(properties, "Size")?, + start: get_property(properties, "Start")?, + systems: get_property(properties, "Systems")?, + udev_ids: get_property(properties, "UdevIds")?, + udev_paths: get_property(properties, "UdevPaths")?, + })) + } else { + Ok(None) + } + } } diff --git a/rust/agama-lib/src/storage/device.rs b/rust/agama-lib/src/storage/device.rs new file mode 100644 index 0000000000..5a27b7831f --- /dev/null +++ b/rust/agama-lib/src/storage/device.rs @@ -0,0 +1,69 @@ +use serde::{Deserialize, Serialize}; + +/// Information about system device created by composition to reflect different devices on system +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Device { + pub device_info: DeviceInfo, + pub block_device: Option, + pub component: Option, + pub drive: Option, + pub filesystem: Option, + pub lvm_lv: Option, + pub lvm_vg: Option, + pub md: Option, + pub multipath: Option, + pub partition: Option, + pub partition_table: Option, + pub raid: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct DeviceInfo { + pub sid: u32, + pub name: String, + pub description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct BlockDevice { + pub active: bool, + pub encrypted: bool, + pub recoverable_size: u64, + pub size: u64, + pub start: u64, + pub systems: Vec, + pub udev_ids: Vec, + pub udev_paths: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Component {} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Drive {} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Filesystem {} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct LvmLv {} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct LvmVg {} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct MD {} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Multipath {} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Partition {} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PartitionTable {} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Raid {} diff --git a/rust/agama-lib/src/storage/proxies.rs b/rust/agama-lib/src/storage/proxies.rs index 29f08c79cd..3bea233564 100644 --- a/rust/agama-lib/src/storage/proxies.rs +++ b/rust/agama-lib/src/storage/proxies.rs @@ -103,21 +103,30 @@ trait Proposal { #[dbus_proxy( interface = "org.opensuse.Agama.Storage1.Block", - default_service = "org.opensuse.Agama.Storage1" + default_service = "org.opensuse.Agama.Storage1", + default_path = "/org/opensuse/Agama/Storage1" )] -trait BlockDevice { +trait Block { /// Active property #[dbus_proxy(property)] fn active(&self) -> zbus::Result; - /// Name property + /// Encrypted property #[dbus_proxy(property)] - fn name(&self) -> zbus::Result; + fn encrypted(&self) -> zbus::Result; + + /// RecoverableSize property + #[dbus_proxy(property)] + fn recoverable_size(&self) -> zbus::Result; /// Size property #[dbus_proxy(property)] fn size(&self) -> zbus::Result; + /// Start property + #[dbus_proxy(property)] + fn start(&self) -> zbus::Result; + /// Systems property #[dbus_proxy(property)] fn systems(&self) -> zbus::Result>; @@ -130,3 +139,91 @@ trait BlockDevice { #[dbus_proxy(property)] fn udev_paths(&self) -> zbus::Result>; } + +#[dbus_proxy( + interface = "org.opensuse.Agama.Storage1.Drive", + default_service = "org.opensuse.Agama.Storage1", + default_path = "/org/opensuse/Agama/Storage1" +)] +trait Drive { + /// Bus property + #[dbus_proxy(property)] + fn bus(&self) -> zbus::Result; + + /// BusId property + #[dbus_proxy(property)] + fn bus_id(&self) -> zbus::Result; + + /// Driver property + #[dbus_proxy(property)] + fn driver(&self) -> zbus::Result>; + + /// Info property + #[dbus_proxy(property)] + fn info(&self) -> zbus::Result>; + + /// Model property + #[dbus_proxy(property)] + fn model(&self) -> zbus::Result; + + /// Transport property + #[dbus_proxy(property)] + fn transport(&self) -> zbus::Result; + + /// Type property + #[dbus_proxy(property)] + fn type_(&self) -> zbus::Result; + + /// Vendor property + #[dbus_proxy(property)] + fn vendor(&self) -> zbus::Result; +} + +#[dbus_proxy( + interface = "org.opensuse.Agama.Storage1.Multipath", + default_service = "org.opensuse.Agama.Storage1", + default_path = "/org/opensuse/Agama/Storage1" +)] +trait Multipath { + /// Wires property + #[dbus_proxy(property)] + fn wires(&self) -> zbus::Result>; +} + +#[dbus_proxy( + interface = "org.opensuse.Agama.Storage1.PartitionTable", + default_service = "org.opensuse.Agama.Storage1", + default_path = "/org/opensuse/Agama/Storage1" +)] +trait PartitionTable { + /// Partitions property + #[dbus_proxy(property)] + fn partitions(&self) -> zbus::Result>; + + /// Type property + #[dbus_proxy(property)] + fn type_(&self) -> zbus::Result; + + /// UnusedSlots property + #[dbus_proxy(property)] + fn unused_slots(&self) -> zbus::Result>; +} + +#[dbus_proxy( + interface = "org.opensuse.Agama.Storage1.Device", + default_service = "org.opensuse.Agama.Storage1", + default_path = "/org/opensuse/Agama/Storage1" +)] +trait Device { + /// Description property + #[dbus_proxy(property)] + fn description(&self) -> zbus::Result; + + /// Name property + #[dbus_proxy(property)] + fn name(&self) -> zbus::Result; + + /// SID property + #[dbus_proxy(property, name = "SID")] + fn sid(&self) -> zbus::Result; +} diff --git a/rust/agama-lib/src/users.rs b/rust/agama-lib/src/users.rs index a7dc878639..9ee6a72b5a 100644 --- a/rust/agama-lib/src/users.rs +++ b/rust/agama-lib/src/users.rs @@ -1,7 +1,7 @@ //! Implements support for handling the users settings mod client; -mod proxies; +pub mod proxies; mod settings; mod store; diff --git a/rust/agama-lib/src/users/client.rs b/rust/agama-lib/src/users/client.rs index 437ff3f21d..979b788e3c 100644 --- a/rust/agama-lib/src/users/client.rs +++ b/rust/agama-lib/src/users/client.rs @@ -3,11 +3,12 @@ use super::proxies::{FirstUser as FirstUserFromDBus, Users1Proxy}; use crate::error::ServiceError; use agama_settings::{settings::Settings, SettingValue, SettingsError}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use zbus::Connection; /// Represents the settings for the first user -#[derive(Serialize, Debug, Default)] +#[derive(Serialize, Deserialize, Clone, Debug, Default, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] pub struct FirstUser { /// First user's full name pub full_name: String, @@ -66,6 +67,7 @@ impl Settings for FirstUser { } /// D-Bus client for the users service +#[derive(Clone)] pub struct UsersClient<'a> { users_proxy: Users1Proxy<'a>, } @@ -91,6 +93,10 @@ impl<'a> UsersClient<'a> { Ok(self.users_proxy.set_root_password(value, encrypted).await?) } + pub async fn remove_root_password(&self) -> Result { + Ok(self.users_proxy.remove_root_password().await?) + } + /// Whether the root password is set or not pub async fn is_root_password(&self) -> Result { Ok(self.users_proxy.root_password_set().await?) @@ -121,4 +127,8 @@ impl<'a> UsersClient<'a> { ) .await } + + pub async fn remove_first_user(&self) -> zbus::Result { + Ok(self.users_proxy.remove_first_user().await? == 0) + } } diff --git a/rust/agama-lib/src/users/settings.rs b/rust/agama-lib/src/users/settings.rs index 39b31c1484..afc75ec317 100644 --- a/rust/agama-lib/src/users/settings.rs +++ b/rust/agama-lib/src/users/settings.rs @@ -17,7 +17,7 @@ pub struct UserSettings { /// First user settings /// /// Holds the settings for the first user. -#[derive(Debug, Default, Settings, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Settings, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FirstUserSettings { /// First user's full name diff --git a/rust/agama-locale-data/src/lib.rs b/rust/agama-locale-data/src/lib.rs index fad38cbe70..669f2eba86 100644 --- a/rust/agama-locale-data/src/lib.rs +++ b/rust/agama-locale-data/src/lib.rs @@ -19,7 +19,7 @@ pub mod timezone_part; use keyboard::xkeyboard; -pub use locale::{InvalidKeymap, InvalidLocaleCode, KeymapId, LocaleCode}; +pub use locale::{InvalidKeymap, InvalidLocaleCode, KeymapId, LocaleId}; fn file_reader(file_path: &str) -> anyhow::Result { let file = File::open(file_path) diff --git a/rust/agama-locale-data/src/locale.rs b/rust/agama-locale-data/src/locale.rs index f46f5501fd..56e51931c7 100644 --- a/rust/agama-locale-data/src/locale.rs +++ b/rust/agama-locale-data/src/locale.rs @@ -7,7 +7,7 @@ use std::{fmt::Display, str::FromStr}; use thiserror::Error; #[derive(Clone, Debug, PartialEq, Serialize)] -pub struct LocaleCode { +pub struct LocaleId { // ISO-639 pub language: String, // ISO-3166 @@ -15,7 +15,7 @@ pub struct LocaleCode { pub encoding: String, } -impl Display for LocaleCode { +impl Display for LocaleId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, @@ -25,7 +25,7 @@ impl Display for LocaleCode { } } -impl Default for LocaleCode { +impl Default for LocaleId { fn default() -> Self { Self { language: "en".to_string(), @@ -39,7 +39,7 @@ impl Default for LocaleCode { #[error("Not a valid locale string: {0}")] pub struct InvalidLocaleCode(String); -impl TryFrom<&str> for LocaleCode { +impl TryFrom<&str> for LocaleId { type Error = InvalidLocaleCode; fn try_from(value: &str) -> Result { @@ -86,6 +86,15 @@ pub struct KeymapId { pub variant: Option, } +impl Default for KeymapId { + fn default() -> Self { + Self { + layout: "us".to_string(), + variant: None, + } + } +} + #[derive(Error, Debug, PartialEq)] #[error("Invalid keymap ID: {0}")] pub struct InvalidKeymap(String); diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index 680657be1d..76ab2b682c 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -25,11 +25,11 @@ tokio-stream = "0.1.14" gettext-rs = { version = "0.7.0", features = ["gettext-system"] } regex = "1.10.2" once_cell = "1.18.0" -macaddr = "1.0" +macaddr = { version = "1.0", features = ["serde_std"] } async-trait = "0.1.75" axum = { version = "0.7.4", features = ["ws"] } serde_json = "1.0.113" -tower-http = { version = "0.5.1", features = ["compression-br", "trace"] } +tower-http = { version = "0.5.1", features = ["compression-br", "fs", "trace"] } tracing-subscriber = "0.3.18" tracing-journald = "0.3.0" tracing = "0.1.40" @@ -39,7 +39,7 @@ utoipa = { version = "4.2.0", features = ["axum_extras"] } config = "0.14.0" rand = "0.8.5" jsonwebtoken = "9.2.0" -axum-extra = { version = "0.9.2", features = ["typed-header"] } +axum-extra = { version = "0.9.2", features = ["cookie", "typed-header"] } chrono = { version = "0.4.34", default-features = false, features = [ "now", "std", @@ -48,6 +48,7 @@ chrono = { version = "0.4.34", default-features = false, features = [ ] } pam = "0.8.0" serde_with = "3.6.1" +pin-project = "1.1.5" openssl = "0.10.64" hyper = "1.2.0" hyper-util = "0.1.3" @@ -64,3 +65,4 @@ path = "src/agama-web-server.rs" [dev-dependencies] http-body-util = "0.1.0" +tokio-test = "0.4.3" diff --git a/rust/agama-server/src/agama-web-server.rs b/rust/agama-server/src/agama-web-server.rs index a484b75a0f..186828f59b 100644 --- a/rust/agama-server/src/agama-web-server.rs +++ b/rust/agama-server/src/agama-web-server.rs @@ -1,8 +1,18 @@ +use std::{ + fs, + io::{self, Write}, + os::unix::fs::OpenOptionsExt, + path::{Path, PathBuf}, + pin::Pin, + process::{ExitCode, Termination}, +}; + use agama_lib::connection_to; use agama_server::{ l10n::helpers, - web::{self, run_monitor}, + web::{self, generate_token, run_monitor}, }; +use anyhow::Context; use axum::{ extract::Request as AxumRequest, http::{Request, Response}, @@ -14,14 +24,14 @@ use hyper::body::Incoming; use hyper_util::rt::{TokioExecutor, TokioIo}; use hyper_util::server::conn::auto::Builder; use openssl::ssl::{Ssl, SslAcceptor, SslFiletype, SslMethod}; -use std::process::{ExitCode, Termination}; -use std::{path::PathBuf, pin::Pin}; use tokio::sync::broadcast::channel; use tokio_openssl::SslStream; use tower::Service; use tracing_subscriber::prelude::*; use utoipa::OpenApi; +const DEFAULT_WEB_UI_DIR: &str = "/usr/share/agama/web_ui"; + #[derive(Subcommand, Debug)] enum Commands { /// Start the API server. @@ -40,6 +50,17 @@ struct Cli { pub command: Commands, } +fn find_web_ui_dir() -> PathBuf { + if let Ok(home) = std::env::var("HOME") { + let path = Path::new(&home).join(".local/share/agama"); + if path.exists() { + return path; + } + } + + Path::new(DEFAULT_WEB_UI_DIR).into() +} + #[derive(Args, Debug)] struct ServeArgs { // Address/port to listen on (":::3000" listens for both IPv6 and IPv4 @@ -71,6 +92,11 @@ struct ServeArgs { help = "The D-Bus address for connecting to the Agama service" )] dbus_address: String, + // Directory containing the web UI code. + #[arg(long)] + web_ui_dir: Option, + #[arg(long)] + generate_token: Option, } impl ServeArgs { @@ -267,16 +293,21 @@ async fn start_server(address: String, service: Router, ssl_acceptor: SslAccepto /// Start serving the API. /// `options`: command-line arguments. async fn serve_command(args: ServeArgs) -> anyhow::Result<()> { - let journald = tracing_journald::layer().expect("could not connect to journald"); + let journald = tracing_journald::layer().context("could not connect to journald")?; tracing_subscriber::registry().with(journald).init(); let (tx, _) = channel(16); run_monitor(tx.clone()).await?; let config = web::ServiceConfig::load()?; - let dbus = connection_to(&args.dbus_address).await?; - let service = web::service(config, tx, dbus).await?; + if let Some(token_file) = args.generate_token.clone() { + write_token(&token_file, &config.jwt_secret).context("could not create the token file")?; + } + + let dbus = connection_to(&args.dbus_address).await?; + let web_ui_dir = args.web_ui_dir.clone().unwrap_or(find_web_ui_dir()); + let service = web::service(config, tx, dbus, web_ui_dir).await?; // TODO: Move elsewhere? Use a singleton? (It would be nice to use the same // generated self-signed certificate on both ports.) let ssl_acceptor = if let Ok(ssl_acceptor) = args.ssl_acceptor() { @@ -320,6 +351,17 @@ async fn run_command(cli: Cli) -> anyhow::Result<()> { } } +fn write_token(path: &PathBuf, secret: &str) -> io::Result<()> { + let token = generate_token(secret); + let mut file = fs::OpenOptions::new() + .create(true) + .write(true) + .mode(0o400) + .open(path)?; + file.write_all(token.as_bytes())?; + Ok(()) +} + /// Represents the result of execution. pub enum CliResult { /// Successful execution. diff --git a/rust/agama-server/src/cert.rs b/rust/agama-server/src/cert.rs index db02bf951b..d1e00550d0 100644 --- a/rust/agama-server/src/cert.rs +++ b/rust/agama-server/src/cert.rs @@ -4,9 +4,7 @@ use openssl::error::ErrorStack; use openssl::hash::MessageDigest; use openssl::pkey::{PKey, Private}; use openssl::rsa::Rsa; -use openssl::x509::extension::{ - BasicConstraints, KeyUsage, SubjectAlternativeName, SubjectKeyIdentifier, -}; +use openssl::x509::extension::{BasicConstraints, SubjectAlternativeName, SubjectKeyIdentifier}; use openssl::x509::{X509NameBuilder, X509}; // TODO: move the certificate related functions into a struct @@ -46,6 +44,7 @@ pub fn create_certificate() -> Result<(X509, PKey), ErrorStack> { }; builder.set_serial_number(&serial_number)?; builder.set_subject_name(&x509_name)?; + builder.set_issuer_name(&x509_name)?; builder.set_pubkey(&key)?; let not_before = Asn1Time::days_from_now(0)?; @@ -54,13 +53,6 @@ pub fn create_certificate() -> Result<(X509, PKey), ErrorStack> { builder.set_not_after(¬_after)?; builder.append_extension(BasicConstraints::new().critical().ca().build()?)?; - builder.append_extension( - KeyUsage::new() - .critical() - .key_cert_sign() - .crl_sign() - .build()?, - )?; builder.append_extension( SubjectAlternativeName::new() diff --git a/rust/agama-server/src/error.rs b/rust/agama-server/src/error.rs index 926feab063..21b7c0d987 100644 --- a/rust/agama-server/src/error.rs +++ b/rust/agama-server/src/error.rs @@ -1,11 +1,25 @@ -use zbus_macros::DBusError; +use agama_lib::error::ServiceError; +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; -#[derive(DBusError, Debug)] -#[dbus_error(prefix = "org.opensuse.Agama1.Locale")] +use crate::{l10n::LocaleError, questions::QuestionsError}; + +#[derive(thiserror::Error, Debug)] pub enum Error { - #[dbus_error(zbus_error)] - ZBus(zbus::Error), + #[error("D-Bus error: {0}")] + DBus(#[from] zbus::Error), + #[error("Generic error: {0}")] Anyhow(String), + #[error("Agama service error: {0}")] + Service(#[from] ServiceError), + #[error("Questions service error: {0}")] + Questions(QuestionsError), + #[error("Software service error: {0}")] + Locale(#[from] LocaleError), } // This would be nice, but using it for a return type @@ -22,6 +36,15 @@ impl From for Error { impl From for zbus::fdo::Error { fn from(value: Error) -> zbus::fdo::Error { - zbus::fdo::Error::Failed(format!("Localization error: {value}")) + zbus::fdo::Error::Failed(format!("D-Bus error: {value}")) + } +} + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let body = json!({ + "error": self.to_string() + }); + (StatusCode::BAD_REQUEST, Json(body)).into_response() } } diff --git a/rust/agama-server/src/l10n.rs b/rust/agama-server/src/l10n.rs index a5ebb08fe9..3ef747cd47 100644 --- a/rust/agama-server/src/l10n.rs +++ b/rust/agama-server/src/l10n.rs @@ -1,246 +1,16 @@ +mod dbus; +pub mod error; pub mod helpers; mod keyboard; +pub mod l10n; mod locale; mod timezone; pub mod web; -use crate::error::Error; -use agama_locale_data::{KeymapId, LocaleCode}; -use anyhow::Context; +pub use dbus::export_dbus_objects; +pub use error::LocaleError; pub use keyboard::Keymap; -use keyboard::KeymapsDatabase; +pub use l10n::L10n; pub use locale::LocaleEntry; -use locale::LocalesDatabase; -use std::process::Command; pub use timezone::TimezoneEntry; -use timezone::TimezonesDatabase; -use zbus::{dbus_interface, Connection}; - -pub struct Locale { - timezone: String, - timezones_db: TimezonesDatabase, - locales: Vec, - pub locales_db: LocalesDatabase, - keymap: KeymapId, - keymaps_db: KeymapsDatabase, - ui_locale: LocaleCode, -} - -#[dbus_interface(name = "org.opensuse.Agama1.Locale")] -impl Locale { - /// Gets the supported locales information. - /// - /// Each element of the list has these parts: - /// - /// * The locale code (e.g., "es_ES.UTF-8"). - /// * The name of the language according to the language defined by the - /// UILocale property. - /// * The name of the territory according to the language defined by the - /// UILocale property. - fn list_locales(&self) -> Result, Error> { - let locales = self - .locales_db - .entries() - .iter() - .map(|l| { - ( - l.code.to_string(), - l.language.to_string(), - l.territory.to_string(), - ) - }) - .collect::>(); - Ok(locales) - } - - #[dbus_interface(property)] - fn locales(&self) -> Vec { - self.locales.to_owned() - } - - #[dbus_interface(property)] - fn set_locales(&mut self, locales: Vec) -> zbus::fdo::Result<()> { - for loc in &locales { - if !self.locales_db.exists(loc.as_str()) { - return Err(zbus::fdo::Error::Failed(format!( - "Unsupported locale value '{loc}'" - ))); - } - } - self.locales = locales; - Ok(()) - } - - #[dbus_interface(property, name = "UILocale")] - fn ui_locale(&self) -> String { - self.ui_locale.to_string() - } - - #[dbus_interface(property, name = "UILocale")] - fn set_ui_locale(&mut self, locale: &str) -> zbus::fdo::Result<()> { - let locale: LocaleCode = locale - .try_into() - .map_err(|_e| zbus::fdo::Error::Failed(format!("Invalid locale value '{locale}'")))?; - helpers::set_service_locale(&locale); - Ok(self.translate(&locale)?) - } - - /// Returns a list of the supported keymaps. - /// - /// Each element of the list contains: - /// - /// * The keymap identifier (e.g., "es" or "es(ast)"). - /// * The name of the keyboard in language set by the UILocale property. - fn list_keymaps(&self) -> Result, Error> { - let keymaps = self - .keymaps_db - .entries() - .iter() - .map(|k| (k.id.to_string(), k.localized_description())) - .collect(); - Ok(keymaps) - } - - #[dbus_interface(property)] - fn keymap(&self) -> String { - self.keymap.to_string() - } - - #[dbus_interface(property)] - fn set_keymap(&mut self, keymap_id: &str) -> Result<(), zbus::fdo::Error> { - let keymap_id: KeymapId = keymap_id - .parse() - .map_err(|_e| zbus::fdo::Error::InvalidArgs("Cannot parse keymap ID".to_string()))?; - - if !self.keymaps_db.exists(&keymap_id) { - return Err(zbus::fdo::Error::Failed( - "Cannot find this keymap".to_string(), - )); - } - self.keymap = keymap_id; - Ok(()) - } - - /// Returns a list of the supported timezones. - /// - /// Each element of the list contains: - /// - /// * The timezone identifier (e.g., "Europe/Berlin"). - /// * A list containing each part of the name in the language set by the - /// UILocale property. - /// * The name, in the language set by UILocale, of the main country - /// associated to the timezone (typically, the name of the city that is - /// part of the identifier) or empty string if there is no country. - fn list_timezones(&self) -> Result, String)>, Error> { - let timezones: Vec<_> = self - .timezones_db - .entries() - .iter() - .map(|tz| { - ( - tz.code.to_string(), - tz.parts.clone(), - tz.country.clone().unwrap_or_default(), - ) - }) - .collect(); - Ok(timezones) - } - - #[dbus_interface(property)] - fn timezone(&self) -> &str { - self.timezone.as_str() - } - - #[dbus_interface(property)] - fn set_timezone(&mut self, timezone: &str) -> Result<(), zbus::fdo::Error> { - let timezone = timezone.to_string(); - if !self.timezones_db.exists(&timezone) { - return Err(zbus::fdo::Error::Failed(format!( - "Unsupported timezone value '{timezone}'" - ))); - } - self.timezone = timezone; - Ok(()) - } - - // TODO: what should be returned value for commit? - fn commit(&mut self) -> Result<(), Error> { - const ROOT: &str = "/mnt"; - Command::new("/usr/bin/systemd-firstboot") - .args([ - "--root", - ROOT, - "--force", - "--locale", - self.locales.first().context("missing locale")?.as_str(), - "--keymap", - &self.keymap.to_string(), - "--timezone", - &self.timezone, - ]) - .status() - .context("Failed to execute systemd-firstboot")?; - - Ok(()) - } -} - -impl Locale { - pub fn new_with_locale(ui_locale: &LocaleCode) -> Result { - const DEFAULT_TIMEZONE: &str = "Europe/Berlin"; - - let locale = ui_locale.to_string(); - let mut locales_db = LocalesDatabase::new(); - locales_db.read(&locale)?; - - let mut default_locale = ui_locale.to_string(); - if !locales_db.exists(locale.as_str()) { - // TODO: handle the case where the database is empty (not expected!) - default_locale = locales_db.entries().first().unwrap().code.to_string(); - }; - - let mut timezones_db = TimezonesDatabase::new(); - timezones_db.read(&ui_locale.language)?; - - let mut default_timezone = DEFAULT_TIMEZONE.to_string(); - if !timezones_db.exists(&default_timezone) { - default_timezone = timezones_db.entries().first().unwrap().code.to_string(); - }; - - let mut keymaps_db = KeymapsDatabase::new(); - keymaps_db.read()?; - - let locale = Self { - keymap: "us".parse().unwrap(), - timezone: default_timezone, - locales: vec![default_locale], - locales_db, - timezones_db, - keymaps_db, - ui_locale: ui_locale.clone(), - }; - - Ok(locale) - } - - pub fn translate(&mut self, locale: &LocaleCode) -> Result<(), Error> { - self.timezones_db.read(&locale.language)?; - self.locales_db.read(&locale.language)?; - self.ui_locale = locale.clone(); - Ok(()) - } -} - -pub async fn export_dbus_objects( - connection: &Connection, - locale: &LocaleCode, -) -> Result<(), Box> { - const PATH: &str = "/org/opensuse/Agama1/Locale"; - - // When serving, request the service name _after_ exposing the main object - let locale_iface = Locale::new_with_locale(locale)?; - connection.object_server().at(PATH, locale_iface).await?; - - Ok(()) -} +pub use web::LocaleConfig; diff --git a/rust/agama-server/src/l10n/dbus.rs b/rust/agama-server/src/l10n/dbus.rs new file mode 100644 index 0000000000..67b2bcf78a --- /dev/null +++ b/rust/agama-server/src/l10n/dbus.rs @@ -0,0 +1,110 @@ +use std::sync::{Arc, RwLock}; + +use agama_locale_data::{KeymapId, LocaleId}; +use zbus::{dbus_interface, Connection}; + +use super::L10n; + +struct L10nInterface { + backend: Arc>, +} + +#[dbus_interface(name = "org.opensuse.Agama1.Locale")] +impl L10nInterface { + #[dbus_interface(property)] + pub fn locales(&self) -> Vec { + let backend = self.backend.read().unwrap(); + backend.locales.to_owned() + } + + #[dbus_interface(property)] + pub fn set_locales(&mut self, locales: Vec) -> zbus::fdo::Result<()> { + let mut backend = self.backend.write().unwrap(); + if locales.is_empty() { + return Err(zbus::fdo::Error::Failed( + "The locales list cannot be empty".to_string(), + )); + } + backend.set_locales(&locales).map_err(|e| { + zbus::fdo::Error::Failed(format!("Could not set the locales: {}", e.to_string())) + })?; + Ok(()) + } + + #[dbus_interface(property, name = "UILocale")] + pub fn ui_locale(&self) -> String { + let backend = self.backend.read().unwrap(); + backend.ui_locale.to_string() + } + + #[dbus_interface(property, name = "UILocale")] + pub fn set_ui_locale(&mut self, locale: &str) -> zbus::fdo::Result<()> { + let mut backend = self.backend.write().unwrap(); + let locale: LocaleId = locale + .try_into() + .map_err(|_e| zbus::fdo::Error::Failed(format!("Invalid locale value '{locale}'")))?; + Ok(backend.translate(&locale)?) + } + + #[dbus_interface(property)] + pub fn keymap(&self) -> String { + let backend = self.backend.read().unwrap(); + backend.keymap.to_string() + } + + #[dbus_interface(property)] + fn set_keymap(&mut self, keymap_id: &str) -> Result<(), zbus::fdo::Error> { + let mut backend = self.backend.write().unwrap(); + let keymap_id: KeymapId = keymap_id + .parse() + .map_err(|_e| zbus::fdo::Error::InvalidArgs("Cannot parse keymap ID".to_string()))?; + + backend.set_keymap(keymap_id).map_err(|e| { + zbus::fdo::Error::Failed(format!("Could not set the keymap: {}", e.to_string())) + })?; + + Ok(()) + } + + #[dbus_interface(property)] + pub fn timezone(&self) -> String { + let backend = self.backend.read().unwrap(); + backend.timezone.clone() + } + + #[dbus_interface(property)] + pub fn set_timezone(&mut self, timezone: &str) -> Result<(), zbus::fdo::Error> { + let mut backend = self.backend.write().unwrap(); + + backend.set_timezone(timezone).map_err(|e| { + zbus::fdo::Error::Failed(format!("Could not set the timezone: {}", e.to_string())) + })?; + Ok(()) + } + + // TODO: what should be returned value for commit? + pub fn commit(&mut self) -> zbus::fdo::Result<()> { + let backend = self.backend.read().unwrap(); + + backend.commit().map_err(|e| { + zbus::fdo::Error::Failed(format!("Could not apply the l10n configuration: {e}")) + })?; + Ok(()) + } +} + +pub async fn export_dbus_objects( + connection: &Connection, + locale: &LocaleId, +) -> Result<(), Box> { + const PATH: &str = "/org/opensuse/Agama1/Locale"; + + // When serving, request the service name _after_ exposing the main object + let backend = L10n::new_with_locale(locale)?; + let locale_iface = L10nInterface { + backend: Arc::new(RwLock::new(backend)), + }; + connection.object_server().at(PATH, locale_iface).await?; + + Ok(()) +} diff --git a/rust/agama-server/src/l10n/error.rs b/rust/agama-server/src/l10n/error.rs new file mode 100644 index 0000000000..28665eb321 --- /dev/null +++ b/rust/agama-server/src/l10n/error.rs @@ -0,0 +1,15 @@ +use agama_locale_data::{InvalidKeymap, KeymapId}; + +#[derive(thiserror::Error, Debug)] +pub enum LocaleError { + #[error("Unknown locale code: {0}")] + UnknownLocale(String), + #[error("Unknown timezone: {0}")] + UnknownTimezone(String), + #[error("Unknown keymap: {0}")] + UnknownKeymap(KeymapId), + #[error("Invalid keymap: {0}")] + InvalidKeymap(#[from] InvalidKeymap), + #[error("Could not apply the changes")] + Commit(#[from] std::io::Error), +} diff --git a/rust/agama-server/src/l10n/helpers.rs b/rust/agama-server/src/l10n/helpers.rs index 507094cd04..b806ca690e 100644 --- a/rust/agama-server/src/l10n/helpers.rs +++ b/rust/agama-server/src/l10n/helpers.rs @@ -2,16 +2,16 @@ //! //! FIXME: find a better place for the localization function -use agama_locale_data::LocaleCode; +use agama_locale_data::LocaleId; use gettextrs::{bind_textdomain_codeset, setlocale, textdomain, LocaleCategory}; use std::env; /// Initializes the service locale. /// /// It returns the used locale. Defaults to `en_US.UTF-8`. -pub fn init_locale() -> Result> { +pub fn init_locale() -> Result> { let lang = env::var("LANG").unwrap_or("en_US.UTF-8".to_string()); - let locale: LocaleCode = lang.as_str().try_into().unwrap_or_default(); + let locale: LocaleId = lang.as_str().try_into().unwrap_or_default(); set_service_locale(&locale); textdomain("xkeyboard-config")?; @@ -21,7 +21,7 @@ pub fn init_locale() -> Result> { /// Sets the service locale. /// -pub fn set_service_locale(locale: &LocaleCode) { +pub fn set_service_locale(locale: &LocaleId) { if setlocale(LocaleCategory::LcAll, locale.to_string()).is_none() { log::warn!("Could not set the locale"); } diff --git a/rust/agama-server/src/l10n/keyboard.rs b/rust/agama-server/src/l10n/keyboard.rs index aec68657e6..39286f7230 100644 --- a/rust/agama-server/src/l10n/keyboard.rs +++ b/rust/agama-server/src/l10n/keyboard.rs @@ -1,12 +1,15 @@ use agama_locale_data::{get_localectl_keymaps, keyboard::XkbConfigRegistry, KeymapId}; use gettextrs::*; use serde::Serialize; +use serde_with::{serde_as, DisplayFromStr}; use std::collections::HashMap; +#[serde_as] // Minimal representation of a keymap #[derive(Clone, Debug, Serialize, utoipa::ToSchema)] pub struct Keymap { /// Keymap identifier (e.g., "us") + #[serde_as(as = "DisplayFromStr")] pub id: KeymapId, /// Keymap description description: String, diff --git a/rust/agama-server/src/l10n/l10n.rs b/rust/agama-server/src/l10n/l10n.rs new file mode 100644 index 0000000000..5c70cce1df --- /dev/null +++ b/rust/agama-server/src/l10n/l10n.rs @@ -0,0 +1,160 @@ +use std::io; +use std::process::Command; + +use crate::error::Error; +use agama_locale_data::{KeymapId, LocaleId}; +use regex::Regex; + +use super::keyboard::KeymapsDatabase; +use super::locale::LocalesDatabase; +use super::timezone::TimezonesDatabase; +use super::{helpers, LocaleError}; + +pub struct L10n { + pub timezone: String, + pub timezones_db: TimezonesDatabase, + pub locales: Vec, + pub locales_db: LocalesDatabase, + pub keymap: KeymapId, + pub keymaps_db: KeymapsDatabase, + pub ui_locale: LocaleId, + pub ui_keymap: KeymapId, +} + +impl L10n { + pub fn new_with_locale(ui_locale: &LocaleId) -> Result { + const DEFAULT_TIMEZONE: &str = "Europe/Berlin"; + + let locale = ui_locale.to_string(); + let mut locales_db = LocalesDatabase::new(); + locales_db.read(&locale)?; + + let mut default_locale = ui_locale.to_string(); + if !locales_db.exists(locale.as_str()) { + // TODO: handle the case where the database is empty (not expected!) + default_locale = locales_db.entries().first().unwrap().id.to_string(); + }; + + let mut timezones_db = TimezonesDatabase::new(); + timezones_db.read(&ui_locale.language)?; + + let mut default_timezone = DEFAULT_TIMEZONE.to_string(); + if !timezones_db.exists(&default_timezone) { + default_timezone = timezones_db.entries().first().unwrap().code.to_string(); + }; + + let mut keymaps_db = KeymapsDatabase::new(); + keymaps_db.read()?; + + let ui_keymap = Self::x11_keymap().unwrap_or("us".to_string()); + + let locale = Self { + keymap: "us".parse().unwrap(), + timezone: default_timezone, + locales: vec![default_locale], + locales_db, + timezones_db, + keymaps_db, + ui_locale: ui_locale.clone(), + ui_keymap: ui_keymap.parse().unwrap_or_default(), + }; + + Ok(locale) + } + + pub fn set_locales(&mut self, locales: &Vec) -> Result<(), LocaleError> { + for loc in locales { + if !self.locales_db.exists(loc.as_str()) { + return Err(LocaleError::UnknownLocale(loc.to_string()))?; + } + } + self.locales = locales.clone(); + Ok(()) + } + + pub fn set_timezone(&mut self, timezone: &str) -> Result<(), LocaleError> { + // TODO: modify exists() to receive an `&str` + if !self.timezones_db.exists(&timezone.to_string()) { + return Err(LocaleError::UnknownTimezone(timezone.to_string()))?; + } + self.timezone = timezone.to_owned(); + Ok(()) + } + + pub fn set_keymap(&mut self, keymap_id: KeymapId) -> Result<(), LocaleError> { + if !self.keymaps_db.exists(&keymap_id) { + return Err(LocaleError::UnknownKeymap(keymap_id)); + } + + self.keymap = keymap_id; + Ok(()) + } + + // TODO: use LocaleError + pub fn translate(&mut self, locale: &LocaleId) -> Result<(), Error> { + helpers::set_service_locale(&locale); + self.timezones_db.read(&locale.language)?; + self.locales_db.read(&locale.language)?; + self.ui_locale = locale.clone(); + Ok(()) + } + + // TODO: use LocaleError + pub fn set_ui_keymap(&mut self, keymap_id: KeymapId) -> Result<(), LocaleError> { + if !self.keymaps_db.exists(&keymap_id) { + return Err(LocaleError::UnknownKeymap(keymap_id)); + } + + let keymap = keymap_id.to_string(); + self.ui_keymap = keymap_id; + + Command::new("/usr/bin/localectl") + .args(["set-x11-keymap", &keymap]) + .output() + .map_err(LocaleError::Commit)?; + Command::new("/usr/bin/setxkbmap") + .arg(keymap) + .env("DISPLAY", ":0") + .output() + .map_err(LocaleError::Commit)?; + Ok(()) + } + + // TODO: what should be returned value for commit? + pub fn commit(&self) -> Result<(), LocaleError> { + const ROOT: &str = "/mnt"; + + Command::new("/usr/bin/systemd-firstboot") + .args([ + "--root", + ROOT, + "--force", + "--locale", + self.locales.first().unwrap_or(&"en_US.UTF-8".to_string()), + "--keymap", + &self.keymap.to_string(), + "--timezone", + &self.timezone, + ]) + .status()?; + Ok(()) + } + + fn x11_keymap() -> Result { + let output = Command::new("setxkbmap") + .arg("-query") + .env("DISPLAY", ":0") + .output()?; + let output = String::from_utf8(output.stdout) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + + let keymap_regexp = Regex::new(r"(?m)^layout: (.+)$").unwrap(); + let captures = keymap_regexp.captures(&output); + let keymap = captures + .and_then(|c| c.get(1).map(|e| e.as_str())) + .unwrap_or("us") + .to_string(); + + Ok(keymap) + } +} diff --git a/rust/agama-server/src/l10n/locale.rs b/rust/agama-server/src/l10n/locale.rs index 65d76ec1e4..4f38d30abe 100644 --- a/rust/agama-server/src/l10n/locale.rs +++ b/rust/agama-server/src/l10n/locale.rs @@ -1,7 +1,7 @@ //! This module provides support for reading the locales database. use crate::error::Error; -use agama_locale_data::{InvalidLocaleCode, LocaleCode}; +use agama_locale_data::{InvalidLocaleCode, LocaleId}; use anyhow::Context; use serde::Serialize; use serde_with::{serde_as, DisplayFromStr}; @@ -13,7 +13,7 @@ use std::process::Command; pub struct LocaleEntry { /// The locale code (e.g., "es_ES.UTF-8"). #[serde_as(as = "DisplayFromStr")] - pub code: LocaleCode, + pub id: LocaleId, /// Localized language name (e.g., "Spanish", "Español", etc.) pub language: String, /// Localized territory name (e.g., "Spain", "España", etc.) @@ -26,7 +26,7 @@ pub struct LocaleEntry { /// translations are obtained from the `agama_locale_data` crate. #[derive(Default)] pub struct LocalesDatabase { - known_locales: Vec, + known_locales: Vec, locales: Vec, } @@ -47,7 +47,7 @@ impl LocalesDatabase { String::from_utf8(result.stdout).context("Invalid UTF-8 sequence from list-locales")?; self.known_locales = output .lines() - .filter_map(|line| TryInto::::try_into(line).ok()) + .filter_map(|line| TryInto::::try_into(line).ok()) .collect(); self.locales = self.get_locales(ui_language)?; Ok(()) @@ -56,10 +56,10 @@ impl LocalesDatabase { /// Determines whether a locale exists in the database. pub fn exists(&self, locale: T) -> bool where - T: TryInto, + T: TryInto, T::Error: Into, { - if let Ok(locale) = TryInto::::try_into(locale) { + if let Ok(locale) = TryInto::::try_into(locale) { return self.known_locales.contains(&locale); } @@ -101,7 +101,7 @@ impl LocalesDatabase { .unwrap_or(territory.id.to_string()); let entry = LocaleEntry { - code: code.clone(), + id: code.clone(), language: language_label, territory: territory_label, }; @@ -115,15 +115,15 @@ impl LocalesDatabase { #[cfg(test)] mod tests { use super::LocalesDatabase; - use agama_locale_data::LocaleCode; + use agama_locale_data::LocaleId; #[test] fn test_read_locales() { let mut db = LocalesDatabase::new(); db.read("de").unwrap(); let found_locales = db.entries(); - let spanish: LocaleCode = "es_ES".try_into().unwrap(); - let found = found_locales.iter().find(|l| l.code == spanish).unwrap(); + let spanish: LocaleId = "es_ES".try_into().unwrap(); + let found = found_locales.iter().find(|l| l.id == spanish).unwrap(); assert_eq!(&found.language, "Spanisch"); assert_eq!(&found.territory, "Spanien"); } diff --git a/rust/agama-server/src/l10n/web.rs b/rust/agama-server/src/l10n/web.rs index 1f84f4d437..0288ada8b9 100644 --- a/rust/agama-server/src/l10n/web.rs +++ b/rust/agama-server/src/l10n/web.rs @@ -1,80 +1,68 @@ //! This module implements the web API for the localization module. -use super::{keyboard::Keymap, locale::LocaleEntry, timezone::TimezoneEntry, Locale}; +use super::{ + error::LocaleError, keyboard::Keymap, locale::LocaleEntry, timezone::TimezoneEntry, L10n, +}; use crate::{ error::Error, - l10n::helpers, web::{Event, EventsSender}, }; -use agama_locale_data::{InvalidKeymap, LocaleCode}; +use agama_lib::{error::ServiceError, localization::LocaleProxy}; +use agama_locale_data::LocaleId; use axum::{ extract::State, http::StatusCode, - response::{IntoResponse, Response}, - routing::{get, put}, + response::IntoResponse, + routing::{get, patch}, Json, Router, }; use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::sync::{Arc, RwLock}; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum LocaleError { - #[error("Unknown locale code: {0}")] - UnknownLocale(String), - #[error("Unknown timezone: {0}")] - UnknownTimezone(String), - #[error("Invalid keymap: {0}")] - InvalidKeymap(#[from] InvalidKeymap), - #[error("Cannot translate: {0}")] - OtherError(#[from] Error), -} - -impl IntoResponse for LocaleError { - fn into_response(self) -> Response { - let body = json!({ - "error": self.to_string() - }); - (StatusCode::BAD_REQUEST, Json(body)).into_response() - } -} +use std::sync::Arc; +use tokio::sync::RwLock; #[derive(Clone)] -struct LocaleState { - locale: Arc>, +struct LocaleState<'a> { + locale: Arc>, + proxy: LocaleProxy<'a>, events: EventsSender, } /// Sets up and returns the axum service for the localization module. /// /// * `events`: channel to send the events to the main service. -pub fn l10n_service(events: EventsSender) -> Router { - let code = LocaleCode::default(); - let locale = Locale::new_with_locale(&code).unwrap(); +pub async fn l10n_service( + dbus: zbus::Connection, + events: EventsSender, +) -> Result { + let id = LocaleId::default(); + let locale = L10n::new_with_locale(&id).unwrap(); + let proxy = LocaleProxy::new(&dbus).await?; let state = LocaleState { locale: Arc::new(RwLock::new(locale)), + proxy, events, }; - Router::new() + let router = Router::new() .route("/keymaps", get(keymaps)) .route("/locales", get(locales)) .route("/timezones", get(timezones)) - .route("/config", put(set_config).get(get_config)) - .with_state(state) + .route("/config", patch(set_config).get(get_config)) + .with_state(state); + Ok(router) } #[utoipa::path(get, path = "/l10n/locales", responses( (status = 200, description = "List of known locales", body = Vec) ))] -async fn locales(State(state): State) -> Json> { - let data = state.locale.read().unwrap(); +async fn locales(State(state): State>) -> Json> { + let data = state.locale.read().await; let locales = data.locales_db.entries().to_vec(); Json(locales) } -#[derive(Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] pub struct LocaleConfig { /// Locales to install in the target system locales: Option>, @@ -84,13 +72,15 @@ pub struct LocaleConfig { timezone: Option, /// User-interface locale. It is actually not related to the `locales` property. ui_locale: Option, + /// User-interface locale. It is relevant only on local installations. + ui_keymap: Option, } #[utoipa::path(get, path = "/l10n/timezones", responses( (status = 200, description = "List of known timezones") ))] -async fn timezones(State(state): State) -> Json> { - let data = state.locale.read().unwrap(); +async fn timezones(State(state): State>) -> Json> { + let data = state.locale.read().await; let timezones = data.timezones_db.entries().to_vec(); Json(timezones) } @@ -98,110 +88,100 @@ async fn timezones(State(state): State) -> Json> #[utoipa::path(get, path = "/l10n/keymaps", responses( (status = 200, description = "List of known keymaps", body = Vec) ))] -async fn keymaps(State(state): State) -> Json> { - let data = state.locale.read().unwrap(); +async fn keymaps(State(state): State>) -> Json> { + let data = state.locale.read().await; let keymaps = data.keymaps_db.entries().to_vec(); Json(keymaps) } -#[utoipa::path(put, path = "/l10n/config", responses( - (status = 200, description = "Set the locale configuration", body = LocaleConfig) +// TODO: update all or nothing +// TODO: send only the attributes that have changed +#[utoipa::path(patch, path = "/l10n/config", responses( + (status = 204, description = "Set the locale configuration", body = LocaleConfig) ))] async fn set_config( - State(state): State, + State(state): State>, Json(value): Json, -) -> Result, LocaleError> { - let mut data = state.locale.write().unwrap(); +) -> Result { + let mut data = state.locale.write().await; + let mut changes = LocaleConfig::default(); if let Some(locales) = &value.locales { - for loc in locales { - if !data.locales_db.exists(loc.as_str()) { - return Err(LocaleError::UnknownLocale(loc.to_string())); - } - } - data.locales = locales.clone(); + data.set_locales(&locales)?; + changes.locales = value.locales.clone(); } if let Some(timezone) = &value.timezone { - if !data.timezones_db.exists(timezone) { - return Err(LocaleError::UnknownTimezone(timezone.to_string())); - } - data.timezone = timezone.to_owned(); + data.set_timezone(timezone)?; + changes.timezone = value.timezone.clone(); } if let Some(keymap_id) = &value.keymap { - data.keymap = keymap_id.parse()?; + let keymap_id = keymap_id.parse().map_err(LocaleError::InvalidKeymap)?; + data.set_keymap(keymap_id)?; + changes.keymap = value.keymap.clone(); } if let Some(ui_locale) = &value.ui_locale { - let locale: LocaleCode = ui_locale + let locale: LocaleId = ui_locale .as_str() .try_into() .map_err(|_e| LocaleError::UnknownLocale(ui_locale.to_string()))?; - - helpers::set_service_locale(&locale); data.translate(&locale)?; + changes.ui_locale = Some(locale.to_string()); + _ = state.events.send(Event::LocaleChanged { locale: locale.to_string(), }); } - Ok(Json(())) + if let Some(ui_keymap) = &value.ui_keymap { + let ui_keymap = ui_keymap.parse().map_err(LocaleError::InvalidKeymap)?; + data.set_ui_keymap(ui_keymap)?; + } + + if let Err(e) = update_dbus(&state.proxy, &changes).await { + log::warn!("Could not synchronize settings in the localization D-Bus service: {e}"); + } + _ = state.events.send(Event::L10nConfigChanged(changes)); + + Ok(StatusCode::NO_CONTENT) } #[utoipa::path(get, path = "/l10n/config", responses( (status = 200, description = "Localization configuration", body = LocaleConfig) ))] -async fn get_config(State(state): State) -> Json { - let data = state.locale.read().unwrap(); +async fn get_config(State(state): State>) -> Json { + let data = state.locale.read().await; Json(LocaleConfig { locales: Some(data.locales.clone()), - keymap: Some(data.keymap()), - timezone: Some(data.timezone().to_string()), - ui_locale: Some(data.ui_locale().to_string()), + keymap: Some(data.keymap.to_string()), + timezone: Some(data.timezone.to_string()), + ui_locale: Some(data.ui_locale.to_string()), + ui_keymap: Some(data.ui_keymap.to_string()), }) } -#[cfg(test)] -mod tests { - use crate::l10n::{web::LocaleState, Locale}; - use agama_locale_data::{KeymapId, LocaleCode}; - use std::sync::{Arc, RwLock}; - use tokio::{sync::broadcast::channel, test}; - - fn build_state() -> LocaleState { - let (tx, _) = channel(16); - let default_code = LocaleCode::default(); - let locale = Locale::new_with_locale(&default_code).unwrap(); - LocaleState { - locale: Arc::new(RwLock::new(locale)), - events: tx, - } +pub async fn update_dbus( + client: &LocaleProxy<'_>, + config: &LocaleConfig, +) -> Result<(), ServiceError> { + if let Some(locales) = &config.locales { + let locales: Vec<_> = locales.iter().map(|l| l.as_ref()).collect(); + client.set_locales(&locales).await?; } - #[test] - async fn test_locales() { - let state = build_state(); - let response = super::locales(axum::extract::State(state)).await; - let default = LocaleCode::default(); - let found = response.iter().find(|l| l.code == default); - assert!(found.is_some()); + if let Some(keymap) = &config.keymap { + client.set_keymap(keymap.as_str()).await?; } - #[test] - async fn test_keymaps() { - let state = build_state(); - let response = super::keymaps(axum::extract::State(state)).await; - let english: KeymapId = "us".parse().unwrap(); - let found = response.iter().find(|k| k.id == english); - assert!(found.is_some()); + if let Some(timezone) = &config.timezone { + client.set_timezone(&timezone).await?; } - #[test] - async fn test_timezones() { - let state = build_state(); - let response = super::timezones(axum::extract::State(state)).await; - let found = response.iter().find(|t| t.code == "Atlantic/Canary"); - assert!(found.is_some()); + if let Some(ui_locale) = &config.ui_locale { + client.set_uilocale(ui_locale).await?; } + + Ok(()) } diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index 9216277655..46f0cfc909 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -1,8 +1,11 @@ pub mod cert; pub mod error; pub mod l10n; +pub mod manager; pub mod network; pub mod questions; pub mod software; +pub mod storage; +pub mod users; pub mod web; pub use web::service; diff --git a/rust/agama-server/src/manager.rs b/rust/agama-server/src/manager.rs new file mode 100644 index 0000000000..033981d28b --- /dev/null +++ b/rust/agama-server/src/manager.rs @@ -0,0 +1,2 @@ +pub mod web; +pub use web::manager_service; diff --git a/rust/agama-server/src/manager/web.rs b/rust/agama-server/src/manager/web.rs new file mode 100644 index 0000000000..404bb72f84 --- /dev/null +++ b/rust/agama-server/src/manager/web.rs @@ -0,0 +1,145 @@ +//! This module implements the web API for the manager service. +//! +//! The module offers two public functions: +//! +//! * `manager_service` which returns the Axum service. +//! * `manager_stream` which offers an stream that emits the manager events coming from D-Bus. + +use std::pin::Pin; + +use agama_lib::{ + error::ServiceError, + manager::{InstallationPhase, ManagerClient}, + proxies::Manager1Proxy, +}; +use axum::{ + extract::State, + routing::{get, post}, + Json, Router, +}; +use serde::Serialize; +use tokio_stream::{Stream, StreamExt}; + +use crate::{ + error::Error, + web::{ + common::{progress_router, service_status_router}, + Event, + }, +}; + +#[derive(Clone)] +pub struct ManagerState<'a> { + manager: ManagerClient<'a>, +} + +/// Holds information about the manager's status. +#[derive(Clone, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct InstallerStatus { + /// Current installation phase. + phase: InstallationPhase, + /// List of busy services. + busy: Vec, + /// Whether Agama is running on Iguana. + iguana: bool, + /// Whether it is possible to start the installation. + can_install: bool, +} + +/// Returns a stream that emits manager related events coming from D-Bus. +/// +/// It emits the Event::InstallationPhaseChanged event. +/// +/// * `connection`: D-Bus connection to listen for events. +pub async fn manager_stream( + dbus: zbus::Connection, +) -> Result + Send>>, Error> { + let proxy = Manager1Proxy::new(&dbus).await?; + let stream = proxy + .receive_current_installation_phase_changed() + .await + .then(|change| async move { + if let Ok(phase) = change.get().await { + match InstallationPhase::try_from(phase) { + Ok(phase) => Some(Event::InstallationPhaseChanged { phase }), + Err(error) => { + log::warn!("Ignoring the installation phase change. Error: {}", error); + None + } + } + } else { + None + } + }) + .filter_map(|e| e); + Ok(Box::pin(stream)) +} + +/// Sets up and returns the axum service for the manager module +pub async fn manager_service(dbus: zbus::Connection) -> Result { + const DBUS_SERVICE: &str = "org.opensuse.Agama.Manager1"; + const DBUS_PATH: &str = "/org/opensuse/Agama/Manager1"; + + let status_router = service_status_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; + let progress_router = progress_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; + let manager = ManagerClient::new(dbus).await?; + let state = ManagerState { manager }; + Ok(Router::new() + .route("/probe", post(probe_action)) + .route("/install", post(install_action)) + .route("/finish", post(finish_action)) + .route("/installer", get(installer_status)) + .merge(status_router) + .merge(progress_router) + .with_state(state)) +} + +/// Starts the probing process. +#[utoipa::path(get, path = "/api/manager/probe", responses( + (status = 200, description = "The probing process was started.") +))] +async fn probe_action(State(state): State>) -> Result<(), Error> { + state.manager.probe().await?; + Ok(()) +} + +/// Starts the probing process. +#[utoipa::path(get, path = "/api/manager/install", responses( + (status = 200, description = "The installation process was started.") +))] +async fn install_action(State(state): State>) -> Result<(), Error> { + state.manager.install().await?; + Ok(()) +} + +/// Executes the post installation tasks (e.g., rebooting the system). +#[utoipa::path(get, path = "/api/manager/install", responses( + (status = 200, description = "The installation tasks are executed.") +))] +async fn finish_action(State(state): State>) -> Result<(), Error> { + state.manager.finish().await?; + Ok(()) +} + +/// Returns the manager status. +#[utoipa::path(get, path = "/api/manager/installer", responses( + (status = 200, description = "Installation status.", body = ManagerStatus) +))] +async fn installer_status( + State(state): State>, +) -> Result, Error> { + let phase = state.manager.current_installation_phase().await?; + // CanInstall gets blocked during installation + let can_install = match phase { + InstallationPhase::Install => false, + _ => state.manager.can_install().await?, + }; + let status = InstallerStatus { + phase, + can_install, + busy: state.manager.busy_services().await?, + iguana: state.manager.use_iguana().await?, + }; + Ok(Json(status)) +} diff --git a/rust/agama-server/src/network.rs b/rust/agama-server/src/network.rs index 536fdfdbf8..633397a3b6 100644 --- a/rust/agama-server/src/network.rs +++ b/rust/agama-server/src/network.rs @@ -45,6 +45,7 @@ pub mod error; pub mod model; mod nm; pub mod system; +pub mod web; pub use action::Action; pub use adapter::{Adapter, NetworkAdapterError}; diff --git a/rust/agama-server/src/network/action.rs b/rust/agama-server/src/network/action.rs index 3b98ee912f..057b5fff68 100644 --- a/rust/agama-server/src/network/action.rs +++ b/rust/agama-server/src/network/action.rs @@ -1,10 +1,10 @@ -use crate::network::model::Connection; +use crate::network::model::{AccessPoint, Connection, Device}; use agama_lib::network::types::DeviceType; use tokio::sync::oneshot; use uuid::Uuid; use zbus::zvariant::OwnedObjectPath; -use super::{error::NetworkStateError, NetworkAdapterError}; +use super::{error::NetworkStateError, model::GeneralState, NetworkAdapterError}; pub type Responder = oneshot::Sender; pub type ControllerConnection = (Connection, Vec); @@ -21,11 +21,17 @@ pub enum Action { DeviceType, Responder>, ), + /// Add a new connection + NewConnection(Box, Responder>), + /// Gets a connection by its id + GetConnection(String, Responder>), + /// Gets a connection by its Uuid + GetConnectionByUuid(Uuid, Responder>), /// Gets a connection - GetConnection(Uuid, Responder>), - /// Gets a connection + GetConnections(Responder>), + /// Gets a connection path GetConnectionPath(Uuid, Responder>), - /// Gets a connection + /// Gets a connection path by id GetConnectionPathById(String, Responder>), /// Get connections paths GetConnectionsPaths(Responder>), @@ -34,8 +40,23 @@ pub enum Action { Uuid, Responder>, ), + /// Gets all scanned access points + GetAccessPoints(Responder>), + /// Adds a new device. + AddDevice(Box), + /// Updates a device by its `name`. + UpdateDevice(String, Box), + /// Removes a device by its `name`. + RemoveDevice(String), + /// Gets a device by its name + GetDevice(String, Responder>), + /// Gets all the existent devices + GetDevices(Responder>), + /// Gets a device path + GetDevicePath(String, Responder>), /// Get devices paths GetDevicesPaths(Responder>), + GetGeneralState(Responder), /// Sets a controller's ports. It uses the Uuid of the controller and the IDs or interface names /// of the ports. SetPorts( @@ -43,10 +64,14 @@ pub enum Action { Box>, Responder>, ), - /// Update a connection (replacing the old one). - UpdateConnection(Box), + /// Updates a connection (replacing the old one). + UpdateConnection(Box, Responder>), + /// Updates the general network configuration + UpdateGeneralState(GeneralState), + /// Forces a wireless networks scan refresh + RefreshScan(Responder>), /// Remove the connection with the given Uuid. - RemoveConnection(Uuid), + RemoveConnection(String, Responder>), /// Apply the current configuration. Apply(Responder>), } diff --git a/rust/agama-server/src/network/adapter.rs b/rust/agama-server/src/network/adapter.rs index 8aba3251db..9c755a0141 100644 --- a/rust/agama-server/src/network/adapter.rs +++ b/rust/agama-server/src/network/adapter.rs @@ -1,7 +1,8 @@ -use crate::network::NetworkState; +use crate::network::{model::StateConfig, Action, NetworkState}; use agama_lib::error::ServiceError; use async_trait::async_trait; use thiserror::Error; +use tokio::sync::mpsc::UnboundedSender; #[derive(Error, Debug)] pub enum NetworkAdapterError { @@ -11,13 +12,19 @@ pub enum NetworkAdapterError { Write(ServiceError), #[error("Checkpoint handling error: {0}")] Checkpoint(ServiceError), // only relevant for adapters that implement a checkpoint mechanism + #[error("The network watcher cannot run: {0}")] + Watcher(ServiceError), } -/// A trait for the ability to read/write from/to a network service +/// A trait for the ability to read/write from/to a network service. #[async_trait] pub trait Adapter { - async fn read(&self) -> Result; + async fn read(&self, config: StateConfig) -> Result; async fn write(&self, network: &NetworkState) -> Result<(), NetworkAdapterError>; + /// Returns the watcher, which is responsible for listening for network changes. + fn watcher(&self) -> Option> { + None + } } impl From for zbus::fdo::Error { @@ -25,3 +32,15 @@ impl From for zbus::fdo::Error { zbus::fdo::Error::Failed(value.to_string()) } } + +#[async_trait] +/// A trait for the ability to listen for network changes. +pub trait Watcher { + /// Listens for network changes and emit actions to update the state. + /// + /// * `actions`: channel to emit the actions. + async fn run( + self: Box, + actions: UnboundedSender, + ) -> Result<(), NetworkAdapterError>; +} diff --git a/rust/agama-server/src/network/dbus/interfaces/common.rs b/rust/agama-server/src/network/dbus/interfaces/common.rs index e5a49f122c..a0faeebb1f 100644 --- a/rust/agama-server/src/network/dbus/interfaces/common.rs +++ b/rust/agama-server/src/network/dbus/interfaces/common.rs @@ -25,7 +25,7 @@ pub trait ConnectionInterface { let actions = self.actions().await; let (tx, rx) = oneshot::channel(); actions - .send(Action::GetConnection(self.uuid(), tx)) + .send(Action::GetConnectionByUuid(self.uuid(), tx)) .unwrap(); rx.await .unwrap() @@ -41,13 +41,15 @@ pub trait ConnectionInterface { where F: FnOnce(&mut NetworkConnection) + std::marker::Send, { + let (tx, rx) = oneshot::channel(); let mut connection = self.get_connection().await?; func(&mut connection); let actions = self.actions().await; actions - .send(Action::UpdateConnection(Box::new(connection))) + .send(Action::UpdateConnection(Box::new(connection), tx)) .unwrap(); - Ok(()) + + rx.await.unwrap() } } @@ -73,9 +75,12 @@ pub trait ConnectionConfigInterface: ConnectionInterface { func(&mut config); connection.config = config.into(); let actions = self.actions().await; + + let (tx, rx) = oneshot::channel(); actions - .send(Action::UpdateConnection(Box::new(connection))) + .send(Action::UpdateConnection(Box::new(connection), tx)) .unwrap(); - Ok(()) + + rx.await.unwrap() } } diff --git a/rust/agama-server/src/network/dbus/interfaces/connections.rs b/rust/agama-server/src/network/dbus/interfaces/connections.rs index 885aefb042..56a2e11363 100644 --- a/rust/agama-server/src/network/dbus/interfaces/connections.rs +++ b/rust/agama-server/src/network/dbus/interfaces/connections.rs @@ -21,7 +21,7 @@ pub struct Connections { impl Connections { /// Creates a Connections interface object. /// - /// * `objects`: Objects paths registry. + /// * `actions`: sending-half of a channel to send actions. pub fn new(actions: UnboundedSender) -> Self { Self { actions: Arc::new(Mutex::new(actions)), @@ -101,7 +101,10 @@ impl Connections { .parse() .map_err(|_| NetworkStateError::InvalidUuid(uuid.to_string()))?; let actions = self.actions.lock().await; - actions.send(Action::RemoveConnection(uuid)).unwrap(); + let (tx, rx) = oneshot::channel(); + actions.send(Action::RemoveConnection(uuid, tx)).unwrap(); + + rx.await.unwrap()?; Ok(()) } diff --git a/rust/agama-server/src/network/dbus/service.rs b/rust/agama-server/src/network/dbus/service.rs index b085cf4855..64785fc85c 100644 --- a/rust/agama-server/src/network/dbus/service.rs +++ b/rust/agama-server/src/network/dbus/service.rs @@ -3,30 +3,22 @@ //! This module defines a D-Bus service which exposes Agama's network configuration. use crate::network::{Adapter, NetworkSystem}; use std::error::Error; -use tokio; use zbus::Connection; /// Represents the Agama networking D-Bus service. /// /// It is responsible for starting the [NetworkSystem] on a different thread. +/// TODO: this struct might not be needed anymore. pub struct NetworkService; impl NetworkService { /// Starts listening and dispatching events on the D-Bus connection. - pub async fn start( + pub async fn start( connection: &Connection, adapter: T, ) -> Result<(), Box> { - let mut network = NetworkSystem::new(connection.clone(), adapter); - - tokio::spawn(async move { - network - .setup() - .await - .expect("Could not set up the D-Bus tree"); - - network.listen().await; - }); + let network = NetworkSystem::new(connection.clone(), adapter); + network.start().await?; Ok(()) } } diff --git a/rust/agama-server/src/network/dbus/tree.rs b/rust/agama-server/src/network/dbus/tree.rs index a1872c2da9..76674d9a90 100644 --- a/rust/agama-server/src/network/dbus/tree.rs +++ b/rust/agama-server/src/network/dbus/tree.rs @@ -130,6 +130,10 @@ impl Tree { self.objects.devices_paths() } + pub fn device_path(&self, name: &str) -> Option { + self.objects.device_path(name).map(|o| o.into()) + } + /// Returns all connection paths. pub fn connections_paths(&self) -> Vec { self.objects.connections_paths() @@ -237,6 +241,12 @@ impl ObjectsRegistry { path } + /// Returns the path for a device. + /// + /// * `name`: device name. + pub fn device_path(&self, name: &str) -> Option { + self.devices.get(name).map(|p| p.as_ref()) + } /// Returns the path for a connection. /// /// * `uuid`: connection ID. diff --git a/rust/agama-server/src/network/error.rs b/rust/agama-server/src/network/error.rs index f8df1e916b..348f0494f6 100644 --- a/rust/agama-server/src/network/error.rs +++ b/rust/agama-server/src/network/error.rs @@ -6,6 +6,10 @@ use thiserror::Error; pub enum NetworkStateError { #[error("Unknown connection '{0}'")] UnknownConnection(String), + #[error("Cannot update connection '{0}'")] + CannotUpdateConnection(String), + #[error("Unknown device '{0}'")] + UnknownDevice(String), #[error("Invalid connection UUID: '{0}'")] InvalidUuid(String), #[error("Invalid IP address: '{0}'")] diff --git a/rust/agama-server/src/network/model.rs b/rust/agama-server/src/network/model.rs index 0c09bd2d05..c9dc7376ce 100644 --- a/rust/agama-server/src/network/model.rs +++ b/rust/agama-server/src/network/model.rs @@ -3,8 +3,11 @@ //! * This module contains the types that represent the network concepts. They are supposed to be //! agnostic from the real network service (e.g., NetworkManager). use crate::network::error::NetworkStateError; -use agama_lib::network::types::{BondMode, DeviceType, SSID}; +use agama_lib::network::settings::{BondSettings, NetworkConnection, WirelessSettings}; +use agama_lib::network::types::{BondMode, DeviceState, DeviceType, Status, SSID}; use cidr::IpInet; +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, skip_serializing_none, DisplayFromStr}; use std::{ collections::HashMap, default::Default, @@ -16,8 +19,29 @@ use thiserror::Error; use uuid::Uuid; use zbus::zvariant::Value; -#[derive(Default, Clone, Debug)] +#[derive(PartialEq)] +pub struct StateConfig { + pub access_points: bool, + pub devices: bool, + pub connections: bool, + pub general_state: bool, +} + +impl Default for StateConfig { + fn default() -> Self { + Self { + access_points: true, + devices: true, + connections: true, + general_state: true, + } + } +} + +#[derive(Default, Clone, Debug, utoipa::ToSchema)] pub struct NetworkState { + pub general_state: GeneralState, + pub access_points: Vec, pub devices: Vec, pub connections: Vec, } @@ -25,10 +49,19 @@ pub struct NetworkState { impl NetworkState { /// Returns a NetworkState struct with the given devices and connections. /// + /// * `general_state`: General network configuration + /// * `access_points`: Access points to include in the state. /// * `devices`: devices to include in the state. /// * `connections`: connections to include in the state. - pub fn new(devices: Vec, connections: Vec) -> Self { + pub fn new( + general_state: GeneralState, + access_points: Vec, + devices: Vec, + connections: Vec, + ) -> Self { Self { + general_state, + access_points, devices, connections, } @@ -79,6 +112,13 @@ impl NetworkState { self.connections.iter_mut().find(|c| c.id == id) } + /// Get a device by name as mutable + /// + /// * `name`: device name + pub fn get_device_mut(&mut self, name: &str) -> Option<&mut Device> { + self.devices.iter_mut().find(|c| c.name == name) + } + pub fn get_controlled_by(&mut self, uuid: Uuid) -> Vec<&Connection> { let uuid = Some(uuid); self.connections @@ -116,15 +156,38 @@ impl NetworkState { /// Removes a connection from the state. /// /// Additionally, it registers the connection to be removed when the changes are applied. - pub fn remove_connection(&mut self, uuid: Uuid) -> Result<(), NetworkStateError> { - let Some(conn) = self.get_connection_by_uuid_mut(uuid) else { - return Err(NetworkStateError::UnknownConnection(uuid.to_string())); + pub fn remove_connection(&mut self, id: &str) -> Result<(), NetworkStateError> { + let Some(conn) = self.get_connection_mut(id) else { + return Err(NetworkStateError::UnknownConnection(id.to_string())); }; conn.remove(); Ok(()) } + pub fn add_device(&mut self, device: Device) -> Result<(), NetworkStateError> { + self.devices.push(device); + Ok(()) + } + + pub fn update_device(&mut self, name: &str, device: Device) -> Result<(), NetworkStateError> { + let Some(old_device) = self.get_device_mut(name) else { + return Err(NetworkStateError::UnknownDevice(device.name.clone())); + }; + *old_device = device; + + Ok(()) + } + + pub fn remove_device(&mut self, name: &str) -> Result<(), NetworkStateError> { + let Some(position) = self.devices.iter().position(|d| &d.name == name) else { + return Err(NetworkStateError::UnknownDevice(name.to_string())); + }; + + self.devices.remove(position); + Ok(()) + } + /// Sets a controller's ports. /// /// If the connection is not a controller, returns an error. @@ -276,9 +339,8 @@ mod tests { fn test_remove_connection() { let mut state = NetworkState::default(); let conn0 = Connection::new("eth0".to_string(), DeviceType::Ethernet); - let uuid = conn0.uuid; state.add_connection(conn0).unwrap(); - state.remove_connection(uuid).unwrap(); + state.remove_connection("eth0".as_ref()).unwrap(); let found = state.get_connection("eth0").unwrap(); assert!(found.is_removed()); } @@ -286,7 +348,7 @@ mod tests { #[test] fn test_remove_unknown_connection() { let mut state = NetworkState::default(); - let error = state.remove_connection(Uuid::new_v4()).unwrap_err(); + let error = state.remove_connection("unknown".as_ref()).unwrap_err(); assert!(matches!(error, NetworkStateError::UnknownConnection(_))); } @@ -367,18 +429,56 @@ mod tests { } } +/// Network state +#[serde_as] +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct GeneralState { + pub hostname: String, + pub connectivity: bool, + pub wireless_enabled: bool, + pub networking_enabled: bool, // pub network_state: NMSTATE +} + +/// Access Point +#[serde_as] +#[derive(Default, Debug, Clone, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AccessPoint { + #[serde_as(as = "DisplayFromStr")] + pub ssid: SSID, + pub hw_address: String, + pub strength: u8, + pub flags: u32, + pub rsn_flags: u32, + pub wpa_flags: u32, +} + /// Network device -#[derive(Debug, Clone)] +#[serde_as] +#[skip_serializing_none] +#[derive(Default, Debug, Clone, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] pub struct Device { pub name: String, + #[serde(rename = "type")] pub type_: DeviceType, + #[serde_as(as = "DisplayFromStr")] + pub mac_address: MacAddress, + pub ip_config: Option, + // Connection.id + pub connection: Option, + pub state: DeviceState, } -/// Represents an availble network connection. -#[derive(Debug, Clone, PartialEq)] +/// Represents a known network connection. +#[serde_as] +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] pub struct Connection { pub id: String, pub uuid: Uuid, + #[serde_as(as = "DisplayFromStr")] pub mac_address: MacAddress, pub firewall_zone: Option, pub ip_config: IpConfig, @@ -461,7 +561,92 @@ impl Default for Connection { } } -#[derive(Default, Debug, PartialEq, Clone)] +impl TryFrom for Connection { + type Error = NetworkStateError; + + fn try_from(conn: NetworkConnection) -> Result { + let id = conn.clone().id; + let mut connection = Connection::new(id, conn.device_type()); + + if let Some(method) = conn.clone().method4 { + let method: Ipv4Method = method.parse().unwrap(); + connection.ip_config.method4 = method; + } + + if let Some(method) = conn.method6 { + let method: Ipv6Method = method.parse().unwrap(); + connection.ip_config.method6 = method; + } + + if let Some(status) = conn.status { + connection.status = status; + } + + if let Some(wireless_config) = conn.wireless { + let config = WirelessConfig::try_from(wireless_config)?; + connection.config = config.into(); + } + + if let Some(bond_config) = conn.bond { + let config = BondConfig::try_from(bond_config)?; + connection.config = config.into(); + } + + connection.ip_config.addresses = conn.addresses; + connection.ip_config.nameservers = conn.nameservers; + connection.ip_config.gateway4 = conn.gateway4; + connection.ip_config.gateway6 = conn.gateway6; + connection.interface = conn.interface; + + Ok(connection) + } +} + +impl TryFrom for NetworkConnection { + type Error = NetworkStateError; + + fn try_from(conn: Connection) -> Result { + let id = conn.clone().id; + let mac = conn.mac_address.to_string(); + let method4 = Some(conn.ip_config.method4.to_string()); + let method6 = Some(conn.ip_config.method6.to_string()); + let mac_address = (!mac.is_empty()).then_some(mac); + let nameservers = conn.ip_config.nameservers; + let addresses = conn.ip_config.addresses; + let gateway4 = conn.ip_config.gateway4; + let gateway6 = conn.ip_config.gateway6; + let interface = conn.interface; + let status = Some(conn.status); + + let mut connection = NetworkConnection { + id, + status, + method4, + method6, + gateway4, + gateway6, + nameservers, + mac_address, + interface, + addresses, + ..Default::default() + }; + + match conn.config { + ConnectionConfig::Wireless(config) => { + connection.wireless = Some(WirelessSettings::try_from(config)?); + } + ConnectionConfig::Bond(config) => { + connection.bond = Some(BondSettings::try_from(config)?); + } + _ => {} + } + + Ok(connection) + } +} + +#[derive(Default, Debug, PartialEq, Clone, Serialize)] pub enum ConnectionConfig { #[default] Ethernet, @@ -474,7 +659,7 @@ pub enum ConnectionConfig { Infiniband(InfinibandConfig), } -#[derive(Default, Debug, PartialEq, Clone)] +#[derive(Default, Debug, PartialEq, Clone, Serialize)] pub enum PortConfig { #[default] None, @@ -497,7 +682,7 @@ impl From for ConnectionConfig { #[error("Invalid MAC address: {0}")] pub struct InvalidMacAddress(String); -#[derive(Debug, Default, Clone, PartialEq)] +#[derive(Debug, Default, Clone, PartialEq, Serialize)] pub enum MacAddress { MacAddress(macaddr::MacAddr6), Preserve, @@ -557,19 +742,15 @@ impl From for zbus::fdo::Error { } } -#[derive(Debug, Default, Clone, Copy, PartialEq)] -pub enum Status { - #[default] - Up, - Down, - Removed, -} - -#[derive(Default, Debug, PartialEq, Clone)] +#[skip_serializing_none] +#[derive(Default, Debug, PartialEq, Clone, Serialize)] +#[serde(rename_all = "camelCase")] pub struct IpConfig { pub method4: Ipv4Method, pub method6: Ipv6Method, + #[serde(skip_serializing_if = "Vec::is_empty")] pub addresses: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] pub nameservers: Vec, pub gateway4: Option, pub gateway6: Option, @@ -577,11 +758,16 @@ pub struct IpConfig { pub routes6: Option>, } -#[derive(Debug, Default, PartialEq, Clone)] +#[skip_serializing_none] +#[derive(Debug, Default, PartialEq, Clone, Serialize)] pub struct MatchConfig { + #[serde(skip_serializing_if = "Vec::is_empty")] pub driver: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] pub interface: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] pub path: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] pub kernel: Vec, } @@ -589,7 +775,8 @@ pub struct MatchConfig { #[error("Unknown IP configuration method name: {0}")] pub struct UnknownIpMethod(String); -#[derive(Debug, Default, Copy, Clone, PartialEq)] +#[derive(Debug, Default, Copy, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] pub enum Ipv4Method { #[default] Disabled = 0, @@ -624,7 +811,8 @@ impl FromStr for Ipv4Method { } } -#[derive(Debug, Default, Copy, Clone, PartialEq)] +#[derive(Debug, Default, Copy, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] pub enum Ipv6Method { #[default] Disabled = 0, @@ -671,10 +859,13 @@ impl From for zbus::fdo::Error { } } -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, PartialEq, Clone, Serialize)] +#[serde(rename_all = "camelCase")] pub struct IpRoute { pub destination: IpInet, + #[serde(skip_serializing_if = "Option::is_none")] pub next_hop: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub metric: Option, } @@ -697,7 +888,7 @@ impl From<&IpRoute> for HashMap<&str, Value<'_>> { } } -#[derive(Debug, Default, PartialEq, Clone)] +#[derive(Debug, Default, PartialEq, Clone, Serialize)] pub enum VlanProtocol { #[default] IEEE802_1Q, @@ -730,22 +921,30 @@ impl fmt::Display for VlanProtocol { } } -#[derive(Debug, Default, PartialEq, Clone)] +#[derive(Debug, Default, PartialEq, Clone, Serialize)] pub struct VlanConfig { pub parent: String, pub id: u32, pub protocol: VlanProtocol, } -#[derive(Debug, Default, PartialEq, Clone)] +#[serde_as] +#[derive(Debug, Default, PartialEq, Clone, Serialize)] +#[serde(rename_all = "camelCase")] pub struct WirelessConfig { pub mode: WirelessMode, + #[serde_as(as = "DisplayFromStr")] pub ssid: SSID, + #[serde(skip_serializing_if = "Option::is_none")] pub password: Option, pub security: SecurityProtocol, + #[serde(skip_serializing_if = "Option::is_none")] pub band: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub channel: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub bssid: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub wep_security: Option, pub hidden: bool, } @@ -761,7 +960,37 @@ impl TryFrom for WirelessConfig { } } -#[derive(Debug, Default, Clone, Copy, PartialEq)] +impl TryFrom for WirelessConfig { + type Error = NetworkStateError; + + fn try_from(settings: WirelessSettings) -> Result { + let ssid = SSID(settings.ssid.as_bytes().into()); + let mode = WirelessMode::try_from(settings.mode.as_str())?; + let security = SecurityProtocol::try_from(settings.security.as_str())?; + Ok(WirelessConfig { + ssid, + mode, + security, + password: Some(settings.password), + ..Default::default() + }) + } +} + +impl TryFrom for WirelessSettings { + type Error = NetworkStateError; + + fn try_from(wireless: WirelessConfig) -> Result { + Ok(WirelessSettings { + ssid: wireless.ssid.to_string(), + mode: wireless.mode.to_string(), + security: wireless.security.to_string(), + password: wireless.password.unwrap_or_default(), + }) + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize)] pub enum WirelessMode { Unknown = 0, AdHoc = 1, @@ -799,7 +1028,7 @@ impl fmt::Display for WirelessMode { } } -#[derive(Debug, Clone, Copy, Default, PartialEq)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)] pub enum SecurityProtocol { #[default] WEP, // No encryption or WEP ("none") @@ -845,15 +1074,16 @@ impl TryFrom<&str> for SecurityProtocol { } } -#[derive(Debug, Default, PartialEq, Clone)] +#[derive(Debug, Default, PartialEq, Clone, Serialize)] pub struct WEPSecurity { pub auth_alg: WEPAuthAlg, pub wep_key_type: WEPKeyType, + #[serde(skip_serializing_if = "Vec::is_empty")] pub keys: Vec, pub wep_key_index: u32, } -#[derive(Debug, Default, PartialEq, Clone)] +#[derive(Debug, Default, PartialEq, Clone, Serialize)] pub enum WEPKeyType { #[default] Unknown = 0, @@ -874,7 +1104,7 @@ impl TryFrom for WEPKeyType { } } -#[derive(Debug, Default, PartialEq, Clone)] +#[derive(Debug, Default, PartialEq, Clone, Serialize)] pub enum WEPAuthAlg { #[default] Unset, @@ -909,7 +1139,7 @@ impl fmt::Display for WEPAuthAlg { } } -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] pub enum WirelessBand { A, // 5GHz BG, // 2.4GHz @@ -937,7 +1167,7 @@ impl TryFrom<&str> for WirelessBand { } } -#[derive(Debug, Default, Clone, PartialEq)] +#[derive(Debug, Default, Clone, PartialEq, Serialize)] pub struct BondOptions(pub HashMap); impl TryFrom<&str> for BondOptions { @@ -970,7 +1200,7 @@ impl fmt::Display for BondOptions { } } -#[derive(Debug, Default, PartialEq, Clone)] +#[derive(Debug, Default, PartialEq, Clone, Serialize)] pub struct BondConfig { pub mode: BondMode, pub options: BondOptions, @@ -987,30 +1217,64 @@ impl TryFrom for BondConfig { } } -#[derive(Debug, Default, PartialEq, Clone)] +impl TryFrom for BondConfig { + type Error = NetworkStateError; + + fn try_from(settings: BondSettings) -> Result { + let mode = BondMode::try_from(settings.mode.as_str()) + .map_err(|_| NetworkStateError::InvalidBondMode(settings.mode))?; + let mut options = BondOptions::default(); + if let Some(setting_options) = settings.options { + options = BondOptions::try_from(setting_options.as_str())?; + } + + Ok(BondConfig { mode, options }) + } +} + +impl TryFrom for BondSettings { + type Error = NetworkStateError; + + fn try_from(bond: BondConfig) -> Result { + Ok(BondSettings { + mode: bond.mode.to_string(), + options: Some(bond.options.to_string()), + ..Default::default() + }) + } +} + +#[derive(Debug, Default, PartialEq, Clone, Serialize)] pub struct BridgeConfig { pub stp: bool, + #[serde(skip_serializing_if = "Option::is_none")] pub priority: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub forward_delay: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub hello_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub max_age: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub ageing_time: Option, } -#[derive(Debug, Default, PartialEq, Clone)] +#[derive(Debug, Default, PartialEq, Clone, Serialize)] pub struct BridgePortConfig { + #[serde(skip_serializing_if = "Option::is_none")] pub priority: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub path_cost: Option, } -#[derive(Default, Debug, PartialEq, Clone)] +#[derive(Default, Debug, PartialEq, Clone, Serialize)] pub struct InfinibandConfig { pub p_key: Option, pub parent: Option, pub transport_mode: InfinibandTransportMode, } -#[derive(Default, Debug, PartialEq, Clone)] +#[derive(Default, Debug, PartialEq, Clone, Serialize)] pub enum InfinibandTransportMode { #[default] Datagram, @@ -1042,3 +1306,17 @@ impl fmt::Display for InfinibandTransportMode { write!(f, "{}", name) } } + +/// Represents a network change. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum NetworkChange { + /// A new device has been added. + DeviceAdded(Device), + /// A device has been removed. + DeviceRemoved(String), + /// The device has been updated. The String corresponds to the + /// original device name, which is especially useful if the + /// device gets renamed. + DeviceUpdated(String, Device), +} diff --git a/rust/agama-server/src/network/nm.rs b/rust/agama-server/src/network/nm.rs index e841e0f407..9f844f2cc0 100644 --- a/rust/agama-server/src/network/nm.rs +++ b/rust/agama-server/src/network/nm.rs @@ -5,11 +5,14 @@ //! internally, so the API is focused on Agama's use cases. mod adapter; +mod builder; mod client; mod dbus; mod error; mod model; mod proxies; +mod watcher; pub use adapter::NetworkManagerAdapter; pub use client::NetworkManagerClient; +pub use watcher::NetworkManagerWatcher; diff --git a/rust/agama-server/src/network/nm/adapter.rs b/rust/agama-server/src/network/nm/adapter.rs index 0df720e5ba..af7b525c16 100644 --- a/rust/agama-server/src/network/nm/adapter.rs +++ b/rust/agama-server/src/network/nm/adapter.rs @@ -1,22 +1,27 @@ use crate::network::{ - model::{Connection, NetworkState}, - nm::NetworkManagerClient, + adapter::Watcher, + model::{Connection, NetworkState, StateConfig}, + nm::{NetworkManagerClient, NetworkManagerWatcher}, Adapter, NetworkAdapterError, }; use agama_lib::error::ServiceError; use async_trait::async_trait; +use core::time; use log; +use std::thread; /// An adapter for NetworkManager pub struct NetworkManagerAdapter<'a> { client: NetworkManagerClient<'a>, + connection: zbus::Connection, } impl<'a> NetworkManagerAdapter<'a> { /// Returns the adapter for system's NetworkManager. pub async fn from_system() -> Result, ServiceError> { - let client = NetworkManagerClient::from_system().await?; - Ok(Self { client }) + let connection = zbus::Connection::system().await?; + let client = NetworkManagerClient::new(connection.clone()).await?; + Ok(Self { client, connection }) } /// Determines whether the write operation is supported for a connection @@ -29,18 +34,51 @@ impl<'a> NetworkManagerAdapter<'a> { #[async_trait] impl<'a> Adapter for NetworkManagerAdapter<'a> { - async fn read(&self) -> Result { - let devices = self + async fn read(&self, config: StateConfig) -> Result { + let general_state = self .client - .devices() + .general_state() .await .map_err(NetworkAdapterError::Read)?; - let connections = self - .client - .connections() - .await - .map_err(NetworkAdapterError::Read)?; - Ok(NetworkState::new(devices, connections)) + + let mut state = NetworkState::default(); + + if config.general_state { + state.general_state = general_state.clone(); + } + + if config.devices { + state.devices = self + .client + .devices() + .await + .map_err(NetworkAdapterError::Read)?; + } + + if config.connections { + state.connections = self + .client + .connections() + .await + .map_err(NetworkAdapterError::Read)?; + } + + if config.access_points && general_state.wireless_enabled { + if !config.devices && !config.connections { + self.client + .request_scan() + .await + .map_err(NetworkAdapterError::Read)?; + thread::sleep(time::Duration::from_secs(1)); + }; + state.access_points = self + .client + .access_points() + .await + .map_err(NetworkAdapterError::Read)?; + } + + Ok(state) } /// Writes the connections to NetworkManager. @@ -51,13 +89,34 @@ impl<'a> Adapter for NetworkManagerAdapter<'a> { /// /// * `network`: network model. async fn write(&self, network: &NetworkState) -> Result<(), NetworkAdapterError> { - let old_state = self.read().await?; + let old_state = self.read(StateConfig::default()).await?; let checkpoint = self .client .create_checkpoint() .await .map_err(NetworkAdapterError::Checkpoint)?; + log::info!("Updating the general state {:?}", &network.general_state); + + let result = self + .client + .update_general_state(&network.general_state) + .await; + + if let Err(e) = result { + self.client + .rollback_checkpoint(&checkpoint.as_ref()) + .await + .map_err(NetworkAdapterError::Checkpoint)?; + + log::error!( + "Could not update the general state {:?}: {}", + &network.general_state, + &e + ); + return Err(NetworkAdapterError::Write(e)); + } + for conn in ordered_connections(network) { if !Self::is_writable(conn) { continue; @@ -67,6 +126,13 @@ impl<'a> Adapter for NetworkManagerAdapter<'a> { if old_conn == conn { continue; } + } else if conn.is_removed() { + log::info!( + "Connection {} ({}) does not need to be removed", + conn.id, + conn.uuid + ); + continue; } log::info!("Updating connection {} ({})", conn.id, conn.uuid); @@ -88,12 +154,17 @@ impl<'a> Adapter for NetworkManagerAdapter<'a> { return Err(NetworkAdapterError::Write(e)); } } + self.client .destroy_checkpoint(&checkpoint.as_ref()) .await .map_err(NetworkAdapterError::Checkpoint)?; Ok(()) } + + fn watcher(&self) -> Option> { + Some(Box::new(NetworkManagerWatcher::new(&self.connection))) + } } /// Returns the connections in the order they should be processed. diff --git a/rust/agama-server/src/network/nm/builder.rs b/rust/agama-server/src/network/nm/builder.rs new file mode 100644 index 0000000000..737e33f117 --- /dev/null +++ b/rust/agama-server/src/network/nm/builder.rs @@ -0,0 +1,236 @@ +//! Conversion mechanism between proxies and model structs. + +use crate::network::{ + model::{Device, IpConfig, IpRoute, MacAddress}, + nm::{ + model::NmDeviceType, + proxies::{DeviceProxy, IP4ConfigProxy, IP6ConfigProxy}, + }, +}; +use agama_lib::{ + error::ServiceError, + network::types::{DeviceState, DeviceType}, +}; +use anyhow::Context; +use cidr::IpInet; +use std::{collections::HashMap, net::IpAddr, str::FromStr}; + +/// Builder to create a [Device] from its corresponding NetworkManager D-Bus representation. +pub struct DeviceFromProxyBuilder<'a> { + connection: zbus::Connection, + proxy: &'a DeviceProxy<'a>, +} + +impl<'a> DeviceFromProxyBuilder<'a> { + pub fn new(connection: &zbus::Connection, proxy: &'a DeviceProxy<'a>) -> Self { + Self { + connection: connection.clone(), + proxy, + } + } + + /// Creates a [Device] starting on the [DeviceProxy]. + pub async fn build(&self) -> Result { + let device_type = NmDeviceType(self.proxy.device_type().await?); + // TODO: we need to check the errors hierarchy to not abuse ServiceError. + let type_: DeviceType = device_type + .try_into() + .context("Unsupported device type: {device_type}")?; + + let state = self.proxy.state().await? as u8; + let state: DeviceState = state + .try_into() + .context("Unsupported device state: {state}")?; + + let mut device = Device { + name: self.proxy.interface().await?, + type_, + state, + ..Default::default() + }; + + if state == DeviceState::Activated { + device.ip_config = self.build_ip_config().await?; + } + + device.mac_address = self.mac_address_from_dbus(self.proxy.hw_address().await?.as_str()); + if let Ok((connection, _)) = self.proxy.get_applied_connection(0).await { + device.connection = self.connection_id(connection); + } + + Ok(device) + } + + async fn build_ip_config(&self) -> Result, ServiceError> { + let ip4_path = self.proxy.ip4_config().await?; + let ip6_path = self.proxy.ip6_config().await?; + + let ip4_proxy = IP4ConfigProxy::builder(&self.connection) + .path(ip4_path.as_str())? + .build() + .await; + + let Ok(ip4_proxy) = ip4_proxy else { + return Ok(None); + }; + + let ip6_proxy = IP6ConfigProxy::builder(&self.connection) + .path(ip6_path.as_str())? + .build() + .await; + + let Ok(ip6_proxy) = ip6_proxy else { + return Ok(None); + }; + + let result = self + .build_ip_config_from_proxies(ip4_proxy, ip6_proxy) + .await + .ok(); + Ok(result) + } + + async fn build_ip_config_from_proxies( + &self, + ip4_proxy: IP4ConfigProxy<'_>, + ip6_proxy: IP6ConfigProxy<'_>, + ) -> Result { + let address_data = ip4_proxy.address_data().await?; + let nameserver_data = ip4_proxy.nameserver_data().await?; + let mut addresses: Vec = vec![]; + let mut nameservers: Vec = vec![]; + + for addr in address_data { + if let Some(address) = self.address_with_prefix_from_dbus(addr) { + addresses.push(address) + } + } + + let address_data = ip6_proxy.address_data().await?; + for addr in address_data { + if let Some(address) = self.address_with_prefix_from_dbus(addr) { + addresses.push(address) + } + } + + for nameserver in nameserver_data { + if let Some(address) = self.nameserver_from_dbus(nameserver) { + nameservers.push(address) + } + } + // FIXME: Convert from Vec to [u8; 16] and take into account big vs little endian order, + // in IP6Config there is no nameserver-data. + // + // let nameserver_data = ip6_proxy.nameservers().await?; + + let route_data = ip4_proxy.route_data().await?; + let mut routes4: Vec = vec![]; + if !route_data.is_empty() { + for route in route_data { + if let Some(route) = self.route_from_dbus(route) { + routes4.push(route) + } + } + } + + let mut routes6: Vec = vec![]; + let route_data = ip6_proxy.route_data().await?; + if !route_data.is_empty() { + for route in route_data { + if let Some(route) = self.route_from_dbus(route) { + routes6.push(route) + } + } + } + + let ip4_gateway = ip4_proxy.gateway().await?; + let ip6_gateway = ip6_proxy.gateway().await?; + + let mut ip_config = IpConfig { + addresses, + nameservers, + ..Default::default() + }; + + if !ip4_gateway.is_empty() { + ip_config.gateway4 = Some(ip4_gateway.parse().unwrap()); + }; + if !ip6_gateway.is_empty() { + ip_config.gateway6 = Some(ip6_gateway.parse().unwrap()); + }; + + if !routes4.is_empty() { + ip_config.routes4 = Some(routes4); + } + + if !routes6.is_empty() { + ip_config.routes6 = Some(routes6); + } + + Ok(ip_config) + } + + pub fn address_with_prefix_from_dbus( + &self, + address_data: HashMap, + ) -> Option { + let addr_str: &str = address_data.get("address")?.downcast_ref()?; + let prefix: &u32 = address_data.get("prefix")?.downcast_ref()?; + let prefix = *prefix as u8; + let address = IpInet::new(addr_str.parse().unwrap(), prefix).ok()?; + Some(address) + } + + pub fn nameserver_from_dbus( + &self, + nameserver_data: HashMap, + ) -> Option { + let addr_str: &str = nameserver_data.get("address")?.downcast_ref()?; + Some(addr_str.parse().unwrap()) + } + + pub fn route_from_dbus( + &self, + route_data: HashMap, + ) -> Option { + let dest_str: &str = route_data.get("dest")?.downcast_ref()?; + let prefix: u8 = *route_data.get("prefix")?.downcast_ref::()? as u8; + let destination = IpInet::new(dest_str.parse().unwrap(), prefix).ok()?; + let mut new_route = IpRoute { + destination, + next_hop: None, + metric: None, + }; + + if let Some(next_hop) = route_data.get("next-hop") { + let next_hop_str: &str = next_hop.downcast_ref()?; + new_route.next_hop = Some(IpAddr::from_str(next_hop_str).unwrap()); + } + if let Some(metric) = route_data.get("metric") { + let metric: u32 = *metric.downcast_ref()?; + new_route.metric = Some(metric); + } + + Some(new_route) + } + + fn mac_address_from_dbus(&self, mac: &str) -> MacAddress { + match MacAddress::from_str(mac) { + Ok(mac) => mac, + Err(_) => { + log::warn!("Unable to parse mac {}", &mac); + MacAddress::Unset + } + } + } + + pub fn connection_id( + &self, + connection_data: HashMap>, + ) -> Option { + let connection = connection_data.get("connection")?; + let id: &str = connection.get("id")?.downcast_ref()?; + + Some(id.to_string()) + } +} diff --git a/rust/agama-server/src/network/nm/client.rs b/rust/agama-server/src/network/nm/client.rs index 8396d9d649..9e3fc06fe6 100644 --- a/rust/agama-server/src/network/nm/client.rs +++ b/rust/agama-server/src/network/nm/client.rs @@ -1,14 +1,19 @@ //! NetworkManager client. use std::collections::HashMap; +use super::builder::DeviceFromProxyBuilder; use super::dbus::{ cleanup_dbus_connection, connection_from_dbus, connection_to_dbus, controller_from_dbus, merge_dbus_connections, }; use super::model::NmDeviceType; -use super::proxies::{ConnectionProxy, DeviceProxy, NetworkManagerProxy, SettingsProxy}; -use crate::network::model::{Connection, Device}; +use super::proxies::{ + AccessPointProxy, ActiveConnectionProxy, ConnectionProxy, DeviceProxy, NetworkManagerProxy, + SettingsProxy, WirelessProxy, +}; +use crate::network::model::{AccessPoint, Connection, Device, GeneralState}; use agama_lib::error::ServiceError; +use agama_lib::network::types::{DeviceType, SSID}; use log; use uuid::Uuid; use zbus; @@ -24,12 +29,6 @@ pub struct NetworkManagerClient<'a> { } impl<'a> NetworkManagerClient<'a> { - /// Creates a NetworkManagerClient connecting to the system bus. - pub async fn from_system() -> Result, ServiceError> { - let connection = zbus::Connection::system().await?; - Self::new(connection).await - } - /// Creates a NetworkManagerClient using the given D-Bus connection. /// /// * `connection`: D-Bus connection. @@ -41,6 +40,102 @@ impl<'a> NetworkManagerClient<'a> { connection, }) } + /// Returns the general state + pub async fn general_state(&self) -> Result { + let proxy = SettingsProxy::new(&self.connection).await?; + let hostname = proxy.hostname().await?; + let wireless_enabled = self.nm_proxy.wireless_enabled().await?; + let networking_enabled = self.nm_proxy.networking_enabled().await?; + // TODO:: Allow to set global DNS configuration + // let global_dns_configuration = self.nm_proxy.global_dns_configuration().await?; + // Fixme: save as NMConnectivityState enum + let connectivity = self.nm_proxy.connectivity().await? == 4; + + Ok(GeneralState { + hostname, + wireless_enabled, + networking_enabled, + connectivity, + }) + } + + /// Updates the general state + pub async fn update_general_state(&self, state: &GeneralState) -> Result<(), ServiceError> { + let wireless_enabled = self.nm_proxy.wireless_enabled().await?; + + if wireless_enabled != state.wireless_enabled { + self.nm_proxy + .set_wireless_enabled(state.wireless_enabled) + .await?; + }; + + Ok(()) + } + + /// Returns the list of access points. + pub async fn request_scan(&self) -> Result<(), ServiceError> { + for path in &self.nm_proxy.get_devices().await? { + let proxy = DeviceProxy::builder(&self.connection) + .path(path.as_str())? + .build() + .await?; + + let device_type = NmDeviceType(proxy.device_type().await?).try_into(); + if let Ok(DeviceType::Wireless) = device_type { + let wproxy = WirelessProxy::builder(&self.connection) + .path(path.as_str())? + .build() + .await?; + wproxy.request_scan(HashMap::new()).await?; + } + } + + Ok(()) + } + + /// Returns the list of access points. + pub async fn access_points(&self) -> Result, ServiceError> { + let mut points = vec![]; + for path in &self.nm_proxy.get_devices().await? { + let proxy = DeviceProxy::builder(&self.connection) + .path(path.as_str())? + .build() + .await?; + + let device_type = NmDeviceType(proxy.device_type().await?).try_into(); + if let Ok(DeviceType::Wireless) = device_type { + let wproxy = WirelessProxy::builder(&self.connection) + .path(path.as_str())? + .build() + .await?; + + for ap_path in wproxy.access_points().await? { + let wproxy = AccessPointProxy::builder(&self.connection) + .path(ap_path.as_str())? + .build() + .await?; + + let ssid = SSID(wproxy.ssid().await?); + let hw_address = wproxy.hw_address().await?; + let strength = wproxy.strength().await?; + let flags = wproxy.flags().await?; + let rsn_flags = wproxy.rsn_flags().await?; + let wpa_flags = wproxy.wpa_flags().await?; + + points.push(AccessPoint { + ssid, + hw_address, + strength, + flags, + rsn_flags, + wpa_flags, + }) + } + } + } + + Ok(points) + } /// Returns the list of network devices. pub async fn devices(&self) -> Result, ServiceError> { @@ -51,20 +146,13 @@ impl<'a> NetworkManagerClient<'a> { .build() .await?; - let device_name = proxy.interface().await?; - let device_type = NmDeviceType(proxy.device_type().await?); - if let Ok(device_type) = device_type.try_into() { - devs.push(Device { - name: device_name, - type_: device_type, - }); + if let Ok(device) = DeviceFromProxyBuilder::new(&self.connection, &proxy) + .build() + .await + { + devs.push(device); } else { - // TODO: use a logger - log::warn!( - "Ignoring network device '{}' (unsupported type '{}')", - &device_name, - &device_type - ); + tracing::warn!("Ignoring network device on path {}", &path); } } @@ -86,13 +174,17 @@ impl<'a> NetworkManagerClient<'a> { .await?; let settings = proxy.get_settings().await?; - if let Some(connection) = connection_from_dbus(settings.clone()) { + if let Some(mut connection) = connection_from_dbus(settings.clone()) { if let Some(controller) = controller_from_dbus(&settings) { controlled_by.insert(connection.uuid, controller.to_string()); } if let Some(iname) = &connection.interface { uuids_map.insert(iname.to_string(), connection.uuid); } + + if self.settings_active_connection(path).await?.is_none() { + connection.set_down() + } connections.push(connection); } } @@ -197,17 +289,19 @@ impl<'a> NetworkManagerClient<'a> { /// * `path`: D-Bus patch of the connection. async fn deactivate_connection(&self, path: OwnedObjectPath) -> Result<(), ServiceError> { let proxy = NetworkManagerProxy::new(&self.connection).await?; - match proxy.deactivate_connection(&path.as_ref()).await { - Err(e) => { + + if let Some(active_connection) = self.settings_active_connection(path.clone()).await? { + if let Err(e) = proxy + .deactivate_connection(&active_connection.as_ref()) + .await + { // Ignore ConnectionNotActive error since this just means the state is already correct - if e.to_string().contains("ConnectionNotActive") { - Ok(()) - } else { - Err(ServiceError::DBus(e)) + if !e.to_string().contains("ConnectionNotActive") { + return Err(ServiceError::DBus(e)); } } - _ => Ok(()), } + Ok(()) } async fn get_connection_proxy(&self, uuid: Uuid) -> Result { @@ -220,4 +314,23 @@ impl<'a> NetworkManagerClient<'a> { .await?; Ok(proxy) } + + async fn settings_active_connection( + &self, + path: OwnedObjectPath, + ) -> Result, ServiceError> { + for active_path in &self.nm_proxy.active_connections().await? { + let proxy = ActiveConnectionProxy::builder(&self.connection) + .path(active_path.as_str())? + .build() + .await?; + + let connection = proxy.connection().await?; + if path == connection { + return Ok(Some(active_path.to_owned())); + }; + } + + Ok(None) + } } diff --git a/rust/agama-server/src/network/nm/dbus.rs b/rust/agama-server/src/network/nm/dbus.rs index 1d60f07f71..d80b1c645b 100644 --- a/rust/agama-server/src/network/nm/dbus.rs +++ b/rust/agama-server/src/network/nm/dbus.rs @@ -800,24 +800,29 @@ fn wireless_config_from_dbus(conn: &OwnedNestedHash) -> Option { let key_mgmt: &str = security.get("key-mgmt")?.downcast_ref()?; wireless_config.security = NmKeyManagement(key_mgmt.to_string()).try_into().ok()?; - let wep_key_type = security - .get("wep-key-type") - .and_then(|alg| WEPKeyType::try_from(*alg.downcast_ref::()?).ok()) - .unwrap_or_default(); - let auth_alg = security - .get("auth-alg") - .and_then(|alg| WEPAuthAlg::try_from(alg.downcast_ref()?).ok()) - .unwrap_or_default(); - let wep_key_index = security - .get("wep-tx-keyidx") - .and_then(|idx| idx.downcast_ref::().cloned()) - .unwrap_or_default(); - wireless_config.wep_security = Some(WEPSecurity { - wep_key_type, - auth_alg, - wep_key_index, - ..Default::default() - }); + match wireless_config.security { + SecurityProtocol::WEP => { + let wep_key_type = security + .get("wep-key-type") + .and_then(|alg| WEPKeyType::try_from(*alg.downcast_ref::()?).ok()) + .unwrap_or_default(); + let auth_alg = security + .get("auth-alg") + .and_then(|alg| WEPAuthAlg::try_from(alg.downcast_ref()?).ok()) + .unwrap_or_default(); + let wep_key_index = security + .get("wep-tx-keyidx") + .and_then(|idx| idx.downcast_ref::().cloned()) + .unwrap_or_default(); + wireless_config.wep_security = Some(WEPSecurity { + wep_key_type, + auth_alg, + wep_key_index, + ..Default::default() + }); + } + _ => wireless_config.wep_security = None, + } } Some(wireless_config) @@ -1107,10 +1112,7 @@ mod test { Some(macaddr::MacAddr6::from_str("12:34:56:78:9A:BC").unwrap()) ); assert!(!wireless.hidden); - let wep_security = wireless.wep_security.as_ref().unwrap(); - assert_eq!(wep_security.wep_key_type, WEPKeyType::Key); - assert_eq!(wep_security.auth_alg, WEPAuthAlg::Open); - assert_eq!(wep_security.wep_key_index, 1); + assert_eq!(wireless.wep_security, None); } } diff --git a/rust/agama-server/src/network/nm/proxies.rs b/rust/agama-server/src/network/nm/proxies.rs index fd933a3d9d..0449e8acb7 100644 --- a/rust/agama-server/src/network/nm/proxies.rs +++ b/rust/agama-server/src/network/nm/proxies.rs @@ -252,6 +252,114 @@ trait NetworkManager { fn wwan_hardware_enabled(&self) -> zbus::Result; } +#[dbus_proxy( + interface = "org.freedesktop.NetworkManager.AccessPoint", + default_service = "org.freedesktop.NetworkManager", + default_path = "/org/freedesktop/NetworkManager/AccessPoint/1" +)] +trait AccessPoint { + /// Flags property + #[dbus_proxy(property)] + fn flags(&self) -> zbus::Result; + + /// Frequency property + #[dbus_proxy(property)] + fn frequency(&self) -> zbus::Result; + + /// HwAddress property + #[dbus_proxy(property)] + fn hw_address(&self) -> zbus::Result; + + /// LastSeen property + #[dbus_proxy(property)] + fn last_seen(&self) -> zbus::Result; + + /// MaxBitrate property + #[dbus_proxy(property)] + fn max_bitrate(&self) -> zbus::Result; + + /// Mode property + #[dbus_proxy(property)] + fn mode(&self) -> zbus::Result; + + /// RsnFlags property + #[dbus_proxy(property)] + fn rsn_flags(&self) -> zbus::Result; + + /// Ssid property + #[dbus_proxy(property)] + fn ssid(&self) -> zbus::Result>; + + /// Strength property + #[dbus_proxy(property)] + fn strength(&self) -> zbus::Result; + + /// WpaFlags property + #[dbus_proxy(property)] + fn wpa_flags(&self) -> zbus::Result; +} +/// # DBus interface proxies for: `org.freedesktop.NetworkManager.Device.Wireless` +/// +/// This code was generated by `zbus-xmlgen` `3.1.0` from DBus introspection data. +#[dbus_proxy( + interface = "org.freedesktop.NetworkManager.Device.Wireless", + default_service = "org.freedesktop.NetworkManager", + default_path = "/org/freedesktop/NetworkManager/Devices/5" +)] +trait Wireless { + /// GetAllAccessPoints method + fn get_all_access_points(&self) -> zbus::Result>; + + /// RequestScan method + fn request_scan( + &self, + options: std::collections::HashMap<&str, zbus::zvariant::Value<'_>>, + ) -> zbus::Result<()>; + + /// AccessPointAdded signal + #[dbus_proxy(signal)] + fn access_point_added(&self, access_point: zbus::zvariant::ObjectPath<'_>) -> zbus::Result<()>; + + /// AccessPointRemoved signal + #[dbus_proxy(signal)] + fn access_point_removed( + &self, + access_point: zbus::zvariant::ObjectPath<'_>, + ) -> zbus::Result<()>; + + /// AccessPoints property + #[dbus_proxy(property)] + fn access_points(&self) -> zbus::Result>; + + /// ActiveAccessPoint property + #[dbus_proxy(property)] + fn active_access_point(&self) -> zbus::Result; + + /// Bitrate property + #[dbus_proxy(property)] + fn bitrate(&self) -> zbus::Result; + + /// HwAddress property + #[dbus_proxy(property)] + fn hw_address(&self) -> zbus::Result; + + /// LastScan property + #[dbus_proxy(property)] + fn last_scan(&self) -> zbus::Result; + + /// Mode property + #[dbus_proxy(property)] + fn mode(&self) -> zbus::Result; + + /// PermHwAddress property + #[dbus_proxy(property)] + fn perm_hw_address(&self) -> zbus::Result; + + /// WirelessCapabilities property + #[dbus_proxy(property)] + fn wireless_capabilities(&self) -> zbus::Result; +} + /// # DBus interface proxies for: `org.freedesktop.NetworkManager.Device` /// /// This code was generated by `zbus-xmlgen` `3.1.0` from DBus introspection data. @@ -582,3 +690,195 @@ trait Connection { #[dbus_proxy(property)] fn unsaved(&self) -> zbus::Result; } + +#[dbus_proxy( + interface = "org.freedesktop.NetworkManager.Connection.Active", + default_service = "org.freedesktop.NetworkManager", + default_path = "/org/freedesktop/NetworkManager/ActiveConnection/1", + gen_blocking = false +)] +trait ActiveConnection { + /// Connection property + #[dbus_proxy(property)] + fn connection(&self) -> zbus::Result; + + /// Controller property + #[dbus_proxy(property)] + fn controller(&self) -> zbus::Result; + + /// Default property + #[dbus_proxy(property)] + fn default(&self) -> zbus::Result; + + /// Default6 property + #[dbus_proxy(property)] + fn default6(&self) -> zbus::Result; + + /// Devices property + #[dbus_proxy(property)] + fn devices(&self) -> zbus::Result>; + + /// Dhcp4Config property + #[dbus_proxy(property)] + fn dhcp4_config(&self) -> zbus::Result; + + /// Dhcp6Config property + #[dbus_proxy(property)] + fn dhcp6_config(&self) -> zbus::Result; + + /// Id property + #[dbus_proxy(property)] + fn id(&self) -> zbus::Result; + + /// Ip4Config property + #[dbus_proxy(property)] + fn ip4_config(&self) -> zbus::Result; + + /// Ip6Config property + #[dbus_proxy(property)] + fn ip6_config(&self) -> zbus::Result; + + /// Master property + #[dbus_proxy(property)] + fn master(&self) -> zbus::Result; + + /// SpecificObject property + #[dbus_proxy(property)] + fn specific_object(&self) -> zbus::Result; + + /// State property + #[dbus_proxy(property)] + fn state(&self) -> zbus::Result; + + /// StateFlags property + #[dbus_proxy(property)] + fn state_flags(&self) -> zbus::Result; + + /// Type property + #[dbus_proxy(property)] + fn type_(&self) -> zbus::Result; + + /// Uuid property + #[dbus_proxy(property)] + fn uuid(&self) -> zbus::Result; + + /// Vpn property + #[dbus_proxy(property)] + fn vpn(&self) -> zbus::Result; +} + +#[dbus_proxy( + interface = "org.freedesktop.NetworkManager.IP4Config", + default_service = "org.freedesktop.NetworkManager", + default_path = "/org/freedesktop/NetworkManager/IP4Config/1" +)] +trait IP4Config { + /// AddressData property + #[dbus_proxy(property)] + fn address_data( + &self, + ) -> zbus::Result>>; + + /// Addresses property + #[dbus_proxy(property)] + fn addresses(&self) -> zbus::Result>>; + + /// DnsOptions property + #[dbus_proxy(property)] + fn dns_options(&self) -> zbus::Result>; + + /// DnsPriority property + #[dbus_proxy(property)] + fn dns_priority(&self) -> zbus::Result; + + /// Domains property + #[dbus_proxy(property)] + fn domains(&self) -> zbus::Result>; + + /// Gateway property + #[dbus_proxy(property)] + fn gateway(&self) -> zbus::Result; + + /// NameserverData property + #[dbus_proxy(property)] + fn nameserver_data( + &self, + ) -> zbus::Result>>; + + /// Nameservers property + #[dbus_proxy(property)] + fn nameservers(&self) -> zbus::Result>; + + /// RouteData property + #[dbus_proxy(property)] + fn route_data( + &self, + ) -> zbus::Result>>; + + /// Routes property + #[dbus_proxy(property)] + fn routes(&self) -> zbus::Result>>; + + /// Searches property + #[dbus_proxy(property)] + fn searches(&self) -> zbus::Result>; + + /// WinsServerData property + #[dbus_proxy(property)] + fn wins_server_data(&self) -> zbus::Result>; + + /// WinsServers property + #[dbus_proxy(property)] + fn wins_servers(&self) -> zbus::Result>; +} + +#[dbus_proxy( + interface = "org.freedesktop.NetworkManager.IP6Config", + default_service = "org.freedesktop.NetworkManager", + default_path = "/org/freedesktop/NetworkManager/IP6Config/1" +)] +trait IP6Config { + /// AddressData property + #[dbus_proxy(property)] + fn address_data( + &self, + ) -> zbus::Result>>; + + /// Addresses property + #[dbus_proxy(property)] + fn addresses(&self) -> zbus::Result, u32, Vec)>>; + + /// DnsOptions property + #[dbus_proxy(property)] + fn dns_options(&self) -> zbus::Result>; + + /// DnsPriority property + #[dbus_proxy(property)] + fn dns_priority(&self) -> zbus::Result; + + /// Domains property + #[dbus_proxy(property)] + fn domains(&self) -> zbus::Result>; + + /// Gateway property + #[dbus_proxy(property)] + fn gateway(&self) -> zbus::Result; + + /// Nameservers property + #[dbus_proxy(property)] + fn nameservers(&self) -> zbus::Result>>; + + /// RouteData property + #[dbus_proxy(property)] + fn route_data( + &self, + ) -> zbus::Result>>; + + /// Routes property + #[dbus_proxy(property)] + fn routes(&self) -> zbus::Result, u32, Vec, u32)>>; + + /// Searches property + #[dbus_proxy(property)] + fn searches(&self) -> zbus::Result>; +} diff --git a/rust/agama-server/src/network/nm/watcher.rs b/rust/agama-server/src/network/nm/watcher.rs new file mode 100644 index 0000000000..df7222bdd2 --- /dev/null +++ b/rust/agama-server/src/network/nm/watcher.rs @@ -0,0 +1,482 @@ +//! Implements the mechanism to listen for NetworkManager changes. +//! +//! Monitors NetworkManager's D-Bus service and emit [actions](crate::network::Action] to update +//! the NetworkSystem state when devices or active connections change. + +use crate::network::{ + adapter::Watcher, model::Device, nm::proxies::DeviceProxy, Action, NetworkAdapterError, +}; +use agama_lib::error::ServiceError; +use async_trait::async_trait; +use futures_util::ready; +use pin_project::pin_project; +use std::{ + collections::{hash_map::Entry, HashMap}, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; +use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; +use tokio_stream::{Stream, StreamExt, StreamMap}; +use zbus::{ + fdo::{InterfacesAdded, InterfacesRemoved, PropertiesChanged}, + zvariant::OwnedObjectPath, + MatchRule, Message, MessageStream, MessageType, +}; + +use super::{builder::DeviceFromProxyBuilder, proxies::NetworkManagerProxy}; + +/// Implements a [crate::network::adapter::Watcher] for NetworkManager. +/// +/// This process is composed of the following pieces: +/// +/// * A stream of potentially useful D-Bus signals (see [DeviceChangedStream]). +/// * A dispatcher that receives the signals from the stream and turns them into +/// [network system actions](crate::network::Action). +/// +/// To avoid deadlocks, the stream runs on a separate Tokio task and it communicates +/// with the dispatcher through a multi-producer single-consumer (mpsc) channel. +/// +/// At this point, it detects the following changes: +/// +/// * A device is added, changed or removed. +/// * The status of a device changes. +/// * The IPv4 or IPv6 configuration changes. +pub struct NetworkManagerWatcher { + connection: zbus::Connection, +} + +impl NetworkManagerWatcher { + /// Builds a new watcher over a D-Bus connection. + pub fn new(connection: &zbus::Connection) -> Self { + Self { + connection: connection.clone(), + } + } +} + +#[async_trait] +impl Watcher for NetworkManagerWatcher { + async fn run( + self: Box, + actions: UnboundedSender, + ) -> Result<(), NetworkAdapterError> { + let (tx, rx) = unbounded_channel(); + + // Process the DeviceChangedStream in a separate task. + let connection = self.connection.clone(); + tokio::spawn(async move { + let mut stream = DeviceChangedStream::new(&connection).await.unwrap(); + + while let Some(change) = stream.next().await { + if let Err(e) = tx.send(change) { + tracing::error!("Could not dispatch a network change: {e}"); + } + } + }); + + // Turn the changes into actions in a separate task. + let connection = self.connection.clone(); + let mut dispatcher = ActionDispatcher::new(connection, rx, actions); + dispatcher.run().await.map_err(NetworkAdapterError::Watcher) + } +} + +/// Receives the updates and turns them into [network actions](crate::network::Action). +/// +/// See [ActionDispatcher::run] for further details. +struct ActionDispatcher<'a> { + connection: zbus::Connection, + proxies: ProxiesRegistry<'a>, + updates_rx: UnboundedReceiver, + actions_tx: UnboundedSender, +} + +impl<'a> ActionDispatcher<'a> { + /// Returns a new dispatcher. + /// + /// * `connection`: D-Bus connection to NetworkManager. + /// * `updates_rx`: Channel to receive the updates. + /// * `actions_tx`: Channel to dispatch the network actions. + pub fn new( + connection: zbus::Connection, + updates_rx: UnboundedReceiver, + actions_tx: UnboundedSender, + ) -> Self { + Self { + proxies: ProxiesRegistry::new(&connection), + connection, + updates_rx, + actions_tx, + } + } + + /// Processes the updates. + /// + /// It runs until the updates channel is closed. + pub async fn run(&mut self) -> Result<(), ServiceError> { + self.read_devices().await?; + while let Some(update) = self.updates_rx.recv().await { + let result = match update { + DeviceChange::DeviceAdded(path) => self.handle_device_added(path).await, + DeviceChange::DeviceUpdated(path) => self.handle_device_updated(path).await, + DeviceChange::DeviceRemoved(path) => self.handle_device_removed(path).await, + DeviceChange::IP4ConfigChanged(path) => self.handle_ip4_config_changed(path).await, + DeviceChange::IP6ConfigChanged(path) => self.handle_ip6_config_changed(path).await, + }; + + if let Err(error) = result { + tracing::warn!("Could not process a network update: {error}]") + } + } + Ok(()) + } + + /// Reads the devices. + async fn read_devices(&mut self) -> Result<(), ServiceError> { + let nm_proxy = NetworkManagerProxy::new(&self.connection).await?; + for path in nm_proxy.get_devices().await? { + self.proxies.find_or_add_device(&path).await?; + } + Ok(()) + } + + /// Handles the case where a new device appears. + /// + /// * `path`: D-Bus object path of the new device. + async fn handle_device_added(&mut self, path: OwnedObjectPath) -> Result<(), ServiceError> { + let (_, proxy) = self.proxies.find_or_add_device(&path).await?; + if let Ok(device) = Self::device_from_proxy(&self.connection, proxy.clone()).await { + _ = self.actions_tx.send(Action::AddDevice(Box::new(device))); + } + // TODO: report an error if the device cannot get generated + + Ok(()) + } + + /// Handles the case where a device is updated. + /// + /// * `path`: D-Bus object path of the updated device. + async fn handle_device_updated(&mut self, path: OwnedObjectPath) -> Result<(), ServiceError> { + let (old_name, proxy) = self.proxies.find_or_add_device(&path).await?; + let device = Self::device_from_proxy(&self.connection, proxy.clone()).await?; + let new_name = device.name.clone(); + _ = self + .actions_tx + .send(Action::UpdateDevice(old_name.to_string(), Box::new(device))); + self.proxies.update_device_name(&path, &new_name); + Ok(()) + } + + /// Handles the case where a device is removed. + /// + /// * `path`: D-Bus object path of the removed device. + async fn handle_device_removed(&mut self, path: OwnedObjectPath) -> Result<(), ServiceError> { + if let Some((name, _)) = self.proxies.remove_device(&path) { + _ = self.actions_tx.send(Action::RemoveDevice(name)); + } + Ok(()) + } + + /// Handles the case where the IPv4 configuration changes. + /// + /// * `path`: D-Bus object path of the changed IP configuration. + async fn handle_ip4_config_changed( + &mut self, + path: OwnedObjectPath, + ) -> Result<(), ServiceError> { + if let Some((name, proxy)) = self.proxies.find_device_for_ip4(&path).await { + let device = Self::device_from_proxy(&self.connection, proxy.clone()).await?; + _ = self + .actions_tx + .send(Action::UpdateDevice(name.to_string(), Box::new(device))); + } + Ok(()) + } + + /// Handles the case where the IPv6 configuration changes. + /// + /// * `path`: D-Bus object path of the changed IP configuration. + async fn handle_ip6_config_changed( + &mut self, + path: OwnedObjectPath, + ) -> Result<(), ServiceError> { + if let Some((name, proxy)) = self.proxies.find_device_for_ip6(&path).await { + let device = Self::device_from_proxy(&self.connection, proxy.clone()).await?; + _ = self + .actions_tx + .send(Action::UpdateDevice(name.to_string(), Box::new(device))); + } + Ok(()) + } + + async fn device_from_proxy( + connection: &zbus::Connection, + proxy: DeviceProxy<'_>, + ) -> Result { + let builder = DeviceFromProxyBuilder::new(&connection, &proxy); + Ok(builder.build().await?) + } +} + +/// Stream of device-related events. +/// +/// This stream listens for many NetworkManager events that are related to network devices (state, +/// IP configuration, etc.) and converts them into variants of the [DeviceChange] enum. +/// +/// It is implemented as a struct because it needs to keep the ObjectManagerProxy alive. +#[pin_project] +struct DeviceChangedStream { + connection: zbus::Connection, + #[pin] + inner: StreamMap<&'static str, MessageStream>, +} + +impl DeviceChangedStream { + /// Builds a new stream using the given D-Bus connection. + /// + /// * `connection`: D-Bus connection. + pub async fn new(connection: &zbus::Connection) -> Result { + let connection = connection.clone(); + let mut inner = StreamMap::new(); + inner.insert( + "object_manager", + build_added_and_removed_stream(&connection).await?, + ); + inner.insert( + "properties", + build_properties_changed_stream(&connection).await?, + ); + Ok(Self { connection, inner }) + } + + fn handle_added(message: InterfacesAdded) -> Option { + let args = message.args().ok()?; + let interfaces: Vec = args + .interfaces_and_properties() + .keys() + .into_iter() + .map(|i| i.to_string()) + .collect(); + + if interfaces.contains(&"org.freedesktop.NetworkManager.Device".to_string()) { + let path = OwnedObjectPath::from(args.object_path().clone()); + return Some(DeviceChange::DeviceAdded(path)); + } + + None + } + + fn handle_removed(message: InterfacesRemoved) -> Option { + let args = message.args().ok()?; + + if args + .interfaces + .contains(&"org.freedesktop.NetworkManager.Device") + { + let path = OwnedObjectPath::from(args.object_path().clone()); + return Some(DeviceChange::DeviceRemoved(path)); + } + + None + } + + fn handle_changed(message: PropertiesChanged) -> Option { + const IP_CONFIG_PROPS: &[&str] = &["AddressData", "Gateway", "NameserverData", "RouteData"]; + const DEVICE_PROPS: &[&str] = &["DeviceType", "HwAddress", "Interface", "State"]; + + let path = OwnedObjectPath::from(message.path()?); + let args = message.args().ok()?; + + match args.interface_name.as_str() { + "org.freedesktop.NetworkManager.IP4Config" => { + if Self::include_properties(IP_CONFIG_PROPS, &args.changed_properties) { + return Some(DeviceChange::IP4ConfigChanged(path)); + } + } + "org.freedesktop.NetworkManager.IP6Config" => { + if Self::include_properties(IP_CONFIG_PROPS, &args.changed_properties) { + return Some(DeviceChange::IP6ConfigChanged(path)); + } + } + "org.freedesktop.NetworkManager.Device" => { + if Self::include_properties(DEVICE_PROPS, &args.changed_properties) { + return Some(DeviceChange::DeviceUpdated(path)); + } + } + _ => {} + }; + None + } + + fn include_properties( + wanted: &[&str], + changed: &HashMap<&'_ str, zbus::zvariant::Value<'_>>, + ) -> bool { + let properties: Vec<_> = changed.keys().collect(); + wanted.iter().any(|i| properties.contains(&i)) + } + + fn handle_message(message: Result, zbus::Error>) -> Option { + let Ok(message) = message else { + return None; + }; + + if let Some(added) = InterfacesAdded::from_message(message.clone()) { + return Self::handle_added(added); + } + + if let Some(removed) = InterfacesRemoved::from_message(message.clone()) { + return Self::handle_removed(removed); + } + + if let Some(changed) = PropertiesChanged::from_message(message.clone()) { + return Self::handle_changed(changed); + } + + None + } +} + +impl Stream for DeviceChangedStream { + type Item = DeviceChange; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut pinned = self.project(); + Poll::Ready(loop { + let item = ready!(pinned.inner.as_mut().poll_next(cx)); + let next_value = match item { + Some((_, message)) => Self::handle_message(message), + _ => None, + }; + if next_value.is_some() { + break next_value; + } + }) + } +} + +async fn build_added_and_removed_stream( + connection: &zbus::Connection, +) -> Result { + let rule = MatchRule::builder() + .msg_type(MessageType::Signal) + .path("/org/freedesktop")? + .interface("org.freedesktop.DBus.ObjectManager")? + .build(); + let stream = MessageStream::for_match_rule(rule, &connection, Some(1)).await?; + Ok(stream) +} + +/// Returns a stream of properties changes to be used by DeviceChangedStream. +/// +/// It listens for changes in several objects that are related to a network device. +async fn build_properties_changed_stream( + connection: &zbus::Connection, +) -> Result { + let rule = MatchRule::builder() + .msg_type(MessageType::Signal) + .interface("org.freedesktop.DBus.Properties")? + .member("PropertiesChanged")? + .build(); + let stream = MessageStream::for_match_rule(rule, &connection, Some(1)).await?; + Ok(stream) +} + +#[derive(Debug, Clone)] +enum DeviceChange { + DeviceAdded(OwnedObjectPath), + DeviceUpdated(OwnedObjectPath), + DeviceRemoved(OwnedObjectPath), + IP4ConfigChanged(OwnedObjectPath), + IP6ConfigChanged(OwnedObjectPath), +} + +/// Ancillary class to track the devices and their related D-Bus objects. +struct ProxiesRegistry<'a> { + connection: zbus::Connection, + // the String is the device name like eth0 + devices: HashMap)>, +} + +impl<'a> ProxiesRegistry<'a> { + pub fn new(connection: &zbus::Connection) -> Self { + Self { + connection: connection.clone(), + devices: HashMap::new(), + } + } + + /// Finds or adds a device to the registry. + /// + /// * `path`: D-Bus object path. + pub async fn find_or_add_device( + &mut self, + path: &OwnedObjectPath, + ) -> Result<&(String, DeviceProxy<'a>), ServiceError> { + // Cannot use entry(...).or_insert_with(...) because of the async call. + match self.devices.entry(path.clone()) { + Entry::Vacant(entry) => { + let proxy = DeviceProxy::builder(&self.connection.clone()) + .path(path.clone())? + .build() + .await?; + let name = proxy.interface().await?; + + Ok(entry.insert((name, proxy))) + } + Entry::Occupied(entry) => Ok(entry.into_mut()), + } + } + + /// Removes a device from the registry. + /// + /// * `path`: D-Bus object path. + pub fn remove_device(&mut self, path: &OwnedObjectPath) -> Option<(String, DeviceProxy)> { + self.devices.remove(&path) + } + + //// Updates a device name. + /// + /// * `path`: D-Bus object path. + /// * `new_name`: New device name. + pub fn update_device_name(&mut self, path: &OwnedObjectPath, new_name: &str) { + if let Some(value) = self.devices.get_mut(path) { + value.0 = new_name.to_string(); + }; + } + + //// For the device corresponding to a given IPv4 configuration. + /// + /// * `ip4_config_path`: D-Bus object path of the IPv4 configuration. + pub async fn find_device_for_ip4( + &self, + ip4_config_path: &OwnedObjectPath, + ) -> Option<&(String, DeviceProxy<'_>)> { + for (_, device) in &self.devices { + if let Ok(path) = device.1.ip4_config().await { + if path == *ip4_config_path { + return Some(&device); + } + } + } + None + } + + //// For the device corresponding to a given IPv6 configuration. + /// + /// * `ip6_config_path`: D-Bus object path of the IPv6 configuration. + pub async fn find_device_for_ip6( + &self, + ip4_config_path: &OwnedObjectPath, + ) -> Option<&(String, DeviceProxy<'_>)> { + for (_, device) in &self.devices { + if let Ok(path) = device.1.ip4_config().await { + if path == *ip4_config_path { + return Some(&device); + } + } + } + None + } +} diff --git a/rust/agama-server/src/network/system.rs b/rust/agama-server/src/network/system.rs index a347caca6a..45af45279a 100644 --- a/rust/agama-server/src/network/system.rs +++ b/rust/agama-server/src/network/system.rs @@ -1,84 +1,315 @@ -use super::{error::NetworkStateError, NetworkAdapterError}; -use crate::network::{dbus::Tree, model::Connection, Action, Adapter, NetworkState}; -use agama_lib::network::types::DeviceType; +use super::{ + error::NetworkStateError, + model::{AccessPoint, Device, NetworkChange, StateConfig}, + NetworkAdapterError, +}; +use crate::network::{ + dbus::Tree, + model::{Connection, GeneralState}, + Action, Adapter, NetworkState, +}; +use agama_lib::{error::ServiceError, network::types::DeviceType}; use std::{error::Error, sync::Arc}; use tokio::sync::{ - mpsc::{self, UnboundedReceiver, UnboundedSender}, + broadcast::{self, Receiver}, + mpsc::{self, error::SendError, UnboundedReceiver, UnboundedSender}, + oneshot::{self, error::RecvError}, Mutex, }; use uuid::Uuid; use zbus::zvariant::OwnedObjectPath; -/// Represents the network system using holding the state and setting up the D-Bus tree. -pub struct NetworkSystem { - /// Network state - pub state: NetworkState, - /// Side of the channel to send actions. - actions_tx: UnboundedSender, - actions_rx: UnboundedReceiver, - tree: Arc>, - /// Adapter to read/write the network state. +#[derive(thiserror::Error, Debug)] +pub enum NetworkSystemError { + #[error("Network state error: {0}")] + State(#[from] NetworkStateError), + #[error("Could not talk to the network system: {0}")] + InputError(#[from] SendError), + #[error("Could not read an answer from the network system: {0}")] + OutputError(#[from] RecvError), + #[error("D-Bus service error: {0}")] + ServiceError(#[from] ServiceError), + #[error("Network backend error: {0}")] + AdapterError(#[from] NetworkAdapterError), +} + +/// Represents the network configuration service. +/// +/// It offers an API to start the service and interact with it by using message +/// passing like the example below. +/// +/// ```no_run +/// # use agama_server::network::{Action, NetworkManagerAdapter, NetworkSystem}; +/// # use agama_lib::connection; +/// # use tokio::sync::oneshot; +/// +/// # tokio_test::block_on(async { +/// let adapter = NetworkManagerAdapter::from_system() +/// .await +/// .expect("Could not connect to NetworkManager."); +/// let dbus = connection() +/// .await +/// .expect("Could not connect to Agama's D-Bus server."); +/// let network = NetworkSystem::new(dbus, adapter); +/// +/// // Start the networking service and get the client for communication. +/// let client = network.start() +/// .await +/// .expect("Could not start the networking configuration system."); +/// +/// // Perform some action, like getting the list of devices. +/// let devices = client.get_devices().await +/// .expect("Could not get the list of devices."); +/// # }); +/// ``` +pub struct NetworkSystem { + connection: zbus::Connection, adapter: T, } -impl NetworkSystem { - pub fn new(conn: zbus::Connection, adapter: T) -> Self { - let (actions_tx, actions_rx) = mpsc::unbounded_channel(); - let tree = Tree::new(conn, actions_tx.clone()); +impl NetworkSystem { + /// Returns a new instance of the network configuration system. + /// + /// This function does not start the system. To get it running, you must call + /// the [start](Self::start) method. + /// + /// * `connection`: D-Bus connection to publish the network tree. + /// * `adapter`: networking configuration adapter. + pub fn new(connection: zbus::Connection, adapter: T) -> Self { Self { - state: NetworkState::default(), - actions_tx, - actions_rx, - tree: Arc::new(Mutex::new(tree)), + connection, adapter, } } - /// Writes the network configuration. - pub async fn write(&mut self) -> Result<(), NetworkAdapterError> { - self.adapter.write(&self.state).await?; - self.state = self.adapter.read().await?; - Ok(()) + /// Starts the network configuration service and returns a client for communication purposes. + /// + /// This function starts the server (using [NetworkSystemServer]) on a separate + /// task. All the communication is performed through the returned [NetworkSystemClient]. + pub async fn start(self) -> Result { + let mut state = self.adapter.read(StateConfig::default()).await?; + let (actions_tx, actions_rx) = mpsc::unbounded_channel(); + let (updates_tx, _updates_rx) = broadcast::channel(1024); + + if let Some(watcher) = self.adapter.watcher() { + let actions_tx_clone = actions_tx.clone(); + tokio::spawn(async move { + watcher.run(actions_tx_clone).await.unwrap(); + }); + } + + let mut tree = Tree::new(self.connection, actions_tx.clone()); + tree.set_connections(&mut state.connections).await?; + tree.set_devices(&state.devices).await?; + + let updates_tx_clone = updates_tx.clone(); + tokio::spawn(async move { + let mut server = NetworkSystemServer { + state, + input: actions_rx, + output: updates_tx_clone, + adapter: self.adapter, + tree: Arc::new(Mutex::new(tree)), + }; + + server.listen().await; + }); + + Ok(NetworkSystemClient { + actions: actions_tx, + updates: updates_tx, + }) } +} - /// Returns a clone of the - /// [UnboundedSender](https://docs.rs/tokio/latest/tokio/sync/mpsc/struct.UnboundedSender.html) - /// to execute [actions](Action). - pub fn actions_tx(&self) -> UnboundedSender { - self.actions_tx.clone() +/// Client to interact with the NetworkSystem once it is running. +/// +/// It hides the details of the message-passing behind a convenient API. +#[derive(Clone)] +pub struct NetworkSystemClient { + actions: UnboundedSender, + updates: broadcast::Sender, +} + +// TODO: add a NetworkSystemError type +impl NetworkSystemClient { + pub fn subscribe(&self) -> Receiver { + self.updates.subscribe() } - /// Populates the D-Bus tree with the known devices and connections. - pub async fn setup(&mut self) -> Result<(), Box> { - self.state = self.adapter.read().await?; - let mut tree = self.tree.lock().await; - tree.set_connections(&mut self.state.connections).await?; - tree.set_devices(&self.state.devices).await?; + /// Returns the general state. + pub async fn get_state(&self) -> Result { + let (tx, rx) = oneshot::channel(); + self.actions.send(Action::GetGeneralState(tx))?; + Ok(rx.await?) + } + + /// Updates the network general state. + pub fn update_state(&self, state: GeneralState) -> Result<(), NetworkSystemError> { + self.actions.send(Action::UpdateGeneralState(state))?; Ok(()) } + /// Returns the collection of network devices. + pub async fn get_devices(&self) -> Result, NetworkSystemError> { + let (tx, rx) = oneshot::channel(); + self.actions.send(Action::GetDevices(tx))?; + Ok(rx.await?) + } + + /// Returns the collection of network connections. + pub async fn get_connections(&self) -> Result, NetworkSystemError> { + let (tx, rx) = oneshot::channel(); + self.actions.send(Action::GetConnections(tx))?; + Ok(rx.await?) + } + + /// Adds a new connection. + pub async fn add_connection(&self, connection: Connection) -> Result<(), NetworkSystemError> { + let (tx, rx) = oneshot::channel(); + self.actions + .send(Action::NewConnection(Box::new(connection.clone()), tx))?; + let result = rx.await?; + Ok(result?) + } + + /// Returns the connection with the given ID. + /// + /// * `id`: Connection ID. + pub async fn get_connection(&self, id: &str) -> Result, NetworkSystemError> { + let (tx, rx) = oneshot::channel(); + self.actions + .send(Action::GetConnection(id.to_string(), tx))?; + let result = rx.await?; + Ok(result) + } + + /// Updates the connection. + /// + /// * `connection`: Updated connection. + pub async fn update_connection( + &self, + connection: Connection, + ) -> Result<(), NetworkSystemError> { + let (tx, rx) = oneshot::channel(); + self.actions + .send(Action::UpdateConnection(Box::new(connection), tx))?; + let result = rx.await?; + Ok(result?) + } + + /// Removes the connection with the given ID. + /// + /// * `id`: Connection ID. + pub async fn remove_connection(&self, id: &str) -> Result<(), NetworkSystemError> { + let (tx, rx) = oneshot::channel(); + self.actions + .send(Action::RemoveConnection(id.to_string(), tx))?; + let result = rx.await?; + Ok(result?) + } + + /// Applies the network configuration. + pub async fn apply(&self) -> Result<(), NetworkSystemError> { + let (tx, rx) = oneshot::channel(); + self.actions.send(Action::Apply(tx))?; + let result = rx.await?; + Ok(result?) + } + + /// Returns the collection of access points. + pub async fn get_access_points(&self) -> Result, NetworkSystemError> { + let (tx, rx) = oneshot::channel(); + self.actions.send(Action::GetAccessPoints(tx))?; + let access_points = rx.await?; + Ok(access_points) + } + + pub async fn wifi_scan(&self) -> Result<(), NetworkSystemError> { + let (tx, rx) = oneshot::channel(); + self.actions.send(Action::RefreshScan(tx)).unwrap(); + let result = rx.await?; + Ok(result?) + } +} + +struct NetworkSystemServer { + state: NetworkState, + input: UnboundedReceiver, + output: broadcast::Sender, + adapter: T, + tree: Arc>, +} + +impl NetworkSystemServer { /// Process incoming actions. /// /// This function is expected to be executed on a separate thread. pub async fn listen(&mut self) { - while let Some(action) = self.actions_rx.recv().await { - if let Err(error) = self.dispatch_action(action).await { - eprintln!("Could not process the action: {}", error); + while let Some(action) = self.input.recv().await { + match self.dispatch_action(action).await { + Ok(Some(update)) => { + _ = self.output.send(update); + } + Err(error) => { + eprintln!("Could not process the action: {}", error); + } + _ => {} } } } /// Dispatch an action. - pub async fn dispatch_action(&mut self, action: Action) -> Result<(), Box> { + pub async fn dispatch_action( + &mut self, + action: Action, + ) -> Result, Box> { match action { Action::AddConnection(name, ty, tx) => { let result = self.add_connection_action(name, ty).await; tx.send(result).unwrap(); } - Action::GetConnection(uuid, tx) => { + Action::RefreshScan(tx) => { + let state = self + .adapter + .read(StateConfig { + access_points: true, + ..Default::default() + }) + .await?; + self.state.general_state = state.general_state; + self.state.access_points = state.access_points; + tx.send(Ok(())).unwrap(); + } + Action::GetAccessPoints(tx) => { + tx.send(self.state.access_points.clone()).unwrap(); + } + Action::NewConnection(conn, tx) => { + tx.send(self.state.add_connection(*conn)).unwrap(); + } + Action::GetGeneralState(tx) => { + let config = self.state.general_state.clone(); + tx.send(config.clone()).unwrap(); + } + Action::GetConnection(id, tx) => { + let conn = self.state.get_connection(id.as_ref()); + tx.send(conn.cloned()).unwrap(); + } + Action::GetConnectionByUuid(uuid, tx) => { let conn = self.state.get_connection_by_uuid(uuid); tx.send(conn.cloned()).unwrap(); } + Action::GetConnections(tx) => { + let connections = self + .state + .connections + .clone() + .into_iter() + .filter(|c| !c.is_removed()) + .collect(); + + tx.send(connections).unwrap(); + } Action::GetConnectionPath(uuid, tx) => { let tree = self.tree.lock().await; let path = tree.connection_path(uuid); @@ -92,6 +323,30 @@ impl NetworkSystem { let result = self.get_controller_action(uuid); tx.send(result).unwrap() } + Action::GetDevice(name, tx) => { + let device = self.state.get_device(name.as_str()); + tx.send(device.cloned()).unwrap(); + } + Action::AddDevice(device) => { + self.state.add_device(*device.clone())?; + return Ok(Some(NetworkChange::DeviceAdded(*device))); + } + Action::UpdateDevice(name, device) => { + self.state.update_device(&name, *device.clone())?; + return Ok(Some(NetworkChange::DeviceUpdated(name, *device))); + } + Action::RemoveDevice(name) => { + self.state.remove_device(&name)?; + return Ok(Some(NetworkChange::DeviceRemoved(name))); + } + Action::GetDevicePath(name, tx) => { + let tree = self.tree.lock().await; + let path = tree.device_path(name.as_str()); + tx.send(path).unwrap(); + } + Action::GetDevices(tx) => { + tx.send(self.state.devices.clone()).unwrap(); + } Action::GetDevicesPaths(tx) => { let tree = self.tree.lock().await; tx.send(tree.devices_paths()).unwrap(); @@ -104,20 +359,24 @@ impl NetworkSystem { let result = self.set_ports_action(uuid, *ports); rx.send(result).unwrap(); } - Action::UpdateConnection(conn) => { - self.state.update_connection(*conn)?; + Action::UpdateConnection(conn, tx) => { + let result = self.state.update_connection(*conn); + tx.send(result).unwrap(); + } + Action::UpdateGeneralState(general_state) => { + self.state.general_state = general_state; } - Action::RemoveConnection(uuid) => { - let mut tree = self.tree.lock().await; - tree.remove_connection(uuid).await?; - self.state.remove_connection(uuid)?; + Action::RemoveConnection(id, tx) => { + let result = self.state.remove_connection(id.as_str()); + + tx.send(result).unwrap(); } Action::Apply(tx) => { let result = self.write().await; let failed = result.is_err(); tx.send(result).unwrap(); if failed { - return Ok(()); + return Ok(None); } // TODO: re-creating the tree is kind of brute-force and it sends signals about @@ -136,7 +395,7 @@ impl NetworkSystem { } } - Ok(()) + Ok(None) } async fn add_connection_action( @@ -192,4 +451,11 @@ impl NetworkSystem { let tree = self.tree.lock().await; tree.connection_path(conn.uuid) } + + /// Writes the network configuration. + pub async fn write(&mut self) -> Result<(), NetworkAdapterError> { + self.adapter.write(&self.state).await?; + self.state = self.adapter.read(StateConfig::default()).await?; + Ok(()) + } } diff --git a/rust/agama-server/src/network/web.rs b/rust/agama-server/src/network/web.rs new file mode 100644 index 0000000000..d3b1a19d9e --- /dev/null +++ b/rust/agama-server/src/network/web.rs @@ -0,0 +1,286 @@ +//! This module implements the web API for the network module. + +use crate::{ + error::Error, + web::{Event, EventsSender}, +}; +use anyhow::Context; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{delete, get, put}, + Json, Router, +}; + +use super::{ + error::NetworkStateError, + model::{AccessPoint, GeneralState}, + system::{NetworkSystemClient, NetworkSystemError}, + Adapter, +}; + +use crate::network::{model::Connection, model::Device, NetworkSystem}; +use agama_lib::{error::ServiceError, network::settings::NetworkConnection}; + +use serde_json::json; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum NetworkError { + #[error("Unknown connection id: {0}")] + UnknownConnection(String), + #[error("Cannot translate: {0}")] + CannotTranslate(#[from] Error), + #[error("Cannot add new connection: {0}")] + CannotAddConnection(String), + #[error("Cannot update configuration: {0}")] + CannotUpdate(String), + #[error("Cannot apply configuration")] + CannotApplyConfig, + // TODO: to be removed after adapting to the NetworkSystemServer API + #[error("Network state error: {0}")] + Error(#[from] NetworkStateError), + #[error("Network system error: {0}")] + SystemError(#[from] NetworkSystemError), +} + +impl IntoResponse for NetworkError { + fn into_response(self) -> Response { + let body = json!({ + "error": self.to_string() + }); + (StatusCode::BAD_REQUEST, Json(body)).into_response() + } +} + +#[derive(Clone)] +struct NetworkServiceState { + network: NetworkSystemClient, +} + +/// Sets up and returns the axum service for the network module. +/// +/// * `dbus`: zbus Connection. +pub async fn network_service( + dbus: zbus::Connection, + adapter: T, + events: EventsSender, +) -> Result { + let network = NetworkSystem::new(dbus.clone(), adapter); + // FIXME: we are somehow abusing ServiceError. The HTTP/JSON API should have its own + // error type. + let client = network + .start() + .await + .context("Could not start the network configuration service.")?; + + let mut changes = client.subscribe(); + tokio::spawn(async move { + loop { + match changes.recv().await { + Ok(message) => { + if let Err(e) = events.send(Event::NetworkChange { change: message }) { + eprintln!("Could not send the event: {}", e); + } + } + Err(e) => { + eprintln!("Could not send the event: {}", e); + } + } + } + }); + + let state = NetworkServiceState { network: client }; + + Ok(Router::new() + .route("/state", get(general_state).put(update_general_state)) + .route("/connections", get(connections).post(add_connection)) + .route( + "/connections/:id", + delete(delete_connection).put(update_connection), + ) + .route("/connections/:id/connect", get(connect)) + .route("/connections/:id/disconnect", get(disconnect)) + .route("/devices", get(devices)) + .route("/system/apply", put(apply)) + .route("/wifi", get(wifi_networks)) + .with_state(state)) +} + +#[utoipa::path(get, path = "/network/state", responses( + (status = 200, description = "Get general network config", body = GenereralState) +))] +async fn general_state( + State(state): State, +) -> Result, NetworkError> { + let general_state = state.network.get_state().await?; + Ok(Json(general_state)) +} + +#[utoipa::path(put, path = "/network/state", responses( + (status = 200, description = "Update general network config", body = GenereralState) +))] +async fn update_general_state( + State(state): State, + Json(value): Json, +) -> Result, NetworkError> { + state.network.update_state(value)?; + let state = state.network.get_state().await?; + Ok(Json(state)) +} + +#[utoipa::path(get, path = "/network/wifi", responses( + (status = 200, description = "List of wireless networks", body = Vec) +))] +async fn wifi_networks( + State(state): State, +) -> Result>, NetworkError> { + state.network.wifi_scan().await?; + let access_points = state.network.get_access_points().await?; + + let mut networks = vec![]; + for ap in access_points { + if !ap.ssid.to_string().is_empty() { + networks.push(ap); + } + } + + Ok(Json(networks)) +} + +#[utoipa::path(get, path = "/network/devices", responses( + (status = 200, description = "List of devices", body = Vec) +))] +async fn devices( + State(state): State, +) -> Result>, NetworkError> { + Ok(Json(state.network.get_devices().await?)) +} + +#[utoipa::path(get, path = "/network/connections", responses( + (status = 200, description = "List of known connections", body = Vec) +))] +async fn connections( + State(state): State, +) -> Result>, NetworkError> { + let connections = state.network.get_connections().await?; + let connections = connections + .iter() + .map(|c| NetworkConnection::try_from(c.clone()).unwrap()) + .collect(); + Ok(Json(connections)) +} + +#[utoipa::path(post, path = "/network/connections", responses( + (status = 200, description = "Add a new connection", body = Connection) +))] +async fn add_connection( + State(state): State, + Json(conn): Json, +) -> Result, NetworkError> { + let conn = Connection::try_from(conn)?; + let id = conn.id.clone(); + + state.network.add_connection(conn).await?; + match state.network.get_connection(&id).await? { + None => Err(NetworkError::CannotAddConnection(id.clone())), + Some(conn) => Ok(Json(conn)), + } +} + +#[utoipa::path(delete, path = "/network/connections/:id", responses( + (status = 200, description = "Delete connection", body = Connection) +))] +async fn delete_connection( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + if state.network.remove_connection(&id).await.is_ok() { + StatusCode::NO_CONTENT + } else { + StatusCode::NOT_FOUND + } +} + +#[utoipa::path(put, path = "/network/connections/:id", responses( + (status = 204, description = "Update connection", body = Connection) +))] +async fn update_connection( + State(state): State, + Path(id): Path, + Json(conn): Json, +) -> Result { + let orig_conn = state + .network + .get_connection(&id) + .await? + .ok_or_else(|| NetworkError::UnknownConnection(id.clone()))?; + let mut conn = Connection::try_from(conn)?; + if orig_conn.id != id { + // FIXME: why? + return Err(NetworkError::UnknownConnection(id)); + } else { + conn.uuid = orig_conn.uuid; + } + + state.network.update_connection(conn).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path(get, path = "/network/connections/:id/connect", responses( + (status = 204, description = "Connect to the given connection", body = String) +))] +async fn connect( + State(state): State, + Path(id): Path, +) -> Result { + let Some(mut conn) = state.network.get_connection(&id).await? else { + return Err(NetworkError::UnknownConnection(id)); + }; + conn.set_up(); + + state + .network + .update_connection(conn) + .await + .map_err(|_| NetworkError::CannotApplyConfig)?; + + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path(get, path = "/network/connections/:id/disconnect", responses( + (status = 204, description = "Connect to the given connection", body = String) +))] +async fn disconnect( + State(state): State, + Path(id): Path, +) -> Result { + let Some(mut conn) = state.network.get_connection(&id).await? else { + return Err(NetworkError::UnknownConnection(id)); + }; + conn.set_down(); + + state + .network + .update_connection(conn) + .await + .map_err(|_| NetworkError::CannotApplyConfig)?; + + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path(put, path = "/network/system/apply", responses( + (status = 204, description = "Apply configuration") +))] +async fn apply( + State(state): State, +) -> Result { + state + .network + .apply() + .await + .map_err(|_| NetworkError::CannotApplyConfig)?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/rust/agama-server/src/questions.rs b/rust/agama-server/src/questions.rs index fda860bf6d..64b510623d 100644 --- a/rust/agama-server/src/questions.rs +++ b/rust/agama-server/src/questions.rs @@ -1,11 +1,19 @@ use std::collections::HashMap; -use crate::error::Error; use agama_lib::questions::{self, GenericQuestion, WithPassword}; use log; use zbus::{dbus_interface, fdo::ObjectManager, zvariant::ObjectPath, Connection}; mod answers; +pub mod web; + +#[derive(thiserror::Error, Debug)] +pub enum QuestionsError { + #[error("Could not read the answers file: {0}")] + IO(std::io::Error), + #[error("Could not deserialize the answers file: {0}")] + Deserialize(serde_yaml::Error), +} #[derive(Clone, Debug)] struct GenericQuestionObject(questions::GenericQuestion); @@ -48,7 +56,7 @@ impl GenericQuestionObject { } #[dbus_interface(property)] - pub fn set_answer(&mut self, value: &str) -> Result<(), zbus::fdo::Error> { + pub fn set_answer(&mut self, value: &str) -> zbus::fdo::Result<()> { // TODO verify if answer exists in options or if it is valid in other way self.0.answer = value.to_string(); @@ -143,7 +151,7 @@ impl Questions { options: Vec<&str>, default_option: &str, data: HashMap, - ) -> Result { + ) -> zbus::fdo::Result { log::info!("Creating new question with text: {}.", text); let id = self.last_id; self.last_id += 1; // TODO use some thread safety @@ -176,7 +184,7 @@ impl Questions { options: Vec<&str>, default_option: &str, data: HashMap, - ) -> Result { + ) -> zbus::fdo::Result { log::info!("Creating new question with password with text: {}.", text); let id = self.last_id; self.last_id += 1; // TODO use some thread safety @@ -213,7 +221,7 @@ impl Questions { } /// Removes question at given object path - async fn delete(&mut self, question: ObjectPath<'_>) -> Result<(), Error> { + async fn delete(&mut self, question: ObjectPath<'_>) -> zbus::fdo::Result<()> { // TODO: error checking let id: u32 = question.rsplit('/').next().unwrap().parse().unwrap(); let qtype = self.questions.get(&id).unwrap(); @@ -266,16 +274,12 @@ impl Questions { } } - fn add_answer_file(&mut self, path: String) -> Result<(), Error> { + fn add_answer_file(&mut self, path: String) -> zbus::fdo::Result<()> { log::info!("Adding answer file {}", path); - let answers = answers::Answers::new_from_file(path.as_str()); - match answers { - Ok(answers) => { - self.answer_strategies.push(Box::new(answers)); - Ok(()) - } - Err(e) => Err(e.into()), - } + let answers = answers::Answers::new_from_file(path.as_str()) + .map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?; + self.answer_strategies.push(Box::new(answers)); + Ok(()) } } diff --git a/rust/agama-server/src/questions/answers.rs b/rust/agama-server/src/questions/answers.rs index 60ade14fa0..c774950f94 100644 --- a/rust/agama-server/src/questions/answers.rs +++ b/rust/agama-server/src/questions/answers.rs @@ -1,9 +1,10 @@ use std::collections::HashMap; use agama_lib::questions::GenericQuestion; -use anyhow::Context; use serde::{Deserialize, Serialize}; +use super::QuestionsError; + /// Data structure for single yaml answer. For variables specification see /// corresponding [agama_lib::questions::GenericQuestion] fields. /// The *matcher* part is: `class`, `text`, `data`. @@ -60,10 +61,9 @@ pub struct Answers { } impl Answers { - pub fn new_from_file(path: &str) -> anyhow::Result { - let f = std::fs::File::open(path).context(format!("Failed to open {}", path))?; - let result: Self = - serde_yaml::from_reader(f).context(format!("Failed to parse values at {}", path))?; + pub fn new_from_file(path: &str) -> Result { + let f = std::fs::File::open(path).map_err(QuestionsError::IO)?; + let result: Self = serde_yaml::from_reader(f).map_err(QuestionsError::Deserialize)?; Ok(result) } diff --git a/rust/agama-server/src/questions/web.rs b/rust/agama-server/src/questions/web.rs new file mode 100644 index 0000000000..24e68658b1 --- /dev/null +++ b/rust/agama-server/src/questions/web.rs @@ -0,0 +1,268 @@ +//! This module implements the web API for the questions module. +//! +//! The module offers two public functions: +//! +//! * `questions_service` which returns the Axum service. +//! * `questions_stream` which offers an stream that emits questions related signals. + +use crate::{error::Error, web::Event}; +use agama_lib::{ + error::ServiceError, + proxies::{GenericQuestionProxy, QuestionWithPasswordProxy}, +}; +use anyhow::Context; +use axum::{ + extract::{Path, State}, + routing::{get, put}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, pin::Pin}; +use tokio_stream::{Stream, StreamExt}; +use zbus::{ + fdo::ObjectManagerProxy, + names::{InterfaceName, OwnedInterfaceName}, + zvariant::{ObjectPath, OwnedObjectPath}, +}; + +// TODO: move to lib +#[derive(Clone)] +struct QuestionsClient<'a> { + connection: zbus::Connection, + objects_proxy: ObjectManagerProxy<'a>, +} + +impl<'a> QuestionsClient<'a> { + pub async fn new(dbus: zbus::Connection) -> Result { + let question_path = + OwnedObjectPath::from(ObjectPath::try_from("/org/opensuse/Agama1/Questions")?); + Ok(Self { + connection: dbus.clone(), + objects_proxy: ObjectManagerProxy::builder(&dbus) + .path(question_path)? + .destination("org.opensuse.Agama1")? + .build() + .await?, + }) + } + + pub async fn questions(&self) -> Result, ServiceError> { + let objects = self + .objects_proxy + .get_managed_objects() + .await + .context("failed to get managed object with Object Manager")?; + let mut result: Vec = Vec::with_capacity(objects.len()); + let password_interface = OwnedInterfaceName::from( + InterfaceName::from_static_str("org.opensuse.Agama1.Questions.WithPassword") + .context("Failed to create interface name for question with password")?, + ); + for (path, interfaces_hash) in objects.iter() { + if interfaces_hash.contains_key(&password_interface) { + result.push(self.create_question_with_password(path).await?) + } else { + result.push(self.create_generic_question(path).await?) + } + } + Ok(result) + } + + async fn create_generic_question( + &self, + path: &OwnedObjectPath, + ) -> Result { + let dbus_question = GenericQuestionProxy::builder(&self.connection) + .path(path)? + .cache_properties(zbus::CacheProperties::No) + .build() + .await?; + let result = Question { + generic: GenericQuestion { + id: dbus_question.id().await?, + class: dbus_question.class().await?, + text: dbus_question.text().await?, + options: dbus_question.options().await?, + default_option: dbus_question.default_option().await?, + data: dbus_question.data().await?, + }, + with_password: None, + }; + + Ok(result) + } + + async fn create_question_with_password( + &self, + path: &OwnedObjectPath, + ) -> Result { + let dbus_question = QuestionWithPasswordProxy::builder(&self.connection) + .path(path)? + .cache_properties(zbus::CacheProperties::No) + .build() + .await?; + let mut result = self.create_generic_question(path).await?; + result.with_password = Some(QuestionWithPassword { + password: dbus_question.password().await?, + }); + + Ok(result) + } + + pub async fn answer(&self, id: u32, answer: Answer) -> Result<(), ServiceError> { + let question_path = OwnedObjectPath::from( + ObjectPath::try_from(format!("/org/opensuse/Agama1/Questions/{}", id)) + .context("Failed to create dbus path")?, + ); + if let Some(password) = answer.with_password { + let dbus_password = QuestionWithPasswordProxy::builder(&self.connection) + .path(&question_path)? + .cache_properties(zbus::CacheProperties::No) + .build() + .await?; + dbus_password + .set_password(password.password.as_str()) + .await? + } + let dbus_generic = GenericQuestionProxy::builder(&self.connection) + .path(&question_path)? + .cache_properties(zbus::CacheProperties::No) + .build() + .await?; + dbus_generic + .set_answer(answer.generic.answer.as_str()) + .await?; + Ok(()) + } +} + +#[derive(Clone)] +struct QuestionsState<'a> { + questions: QuestionsClient<'a>, +} + +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Question { + generic: GenericQuestion, + with_password: Option, +} + +/// Facade of agama_lib::questions::GenericQuestion +/// For fields details see it. +/// Reason why it does not use directly GenericQuestion from lib +/// is that it contain both question and answer. It works for dbus +/// API which has both as attributes, but web API separate +/// question and its answer. So here it is split into GenericQuestion +/// and GenericAnswer +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct GenericQuestion { + id: u32, + class: String, + text: String, + options: Vec, + default_option: String, + data: HashMap, +} + +/// Facade of agama_lib::questions::WithPassword +/// For fields details see it. +/// Reason why it does not use directly WithPassword from lib +/// is that it is not composition as used here, but more like +/// child of generic question and contain reference to Base. +/// Here for web API we want to have in json that separation that would +/// allow to compose any possible future specialization of question +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct QuestionWithPassword { + password: String, +} + +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Answer { + generic: GenericAnswer, + with_password: Option, +} + +/// Answer needed for GenericQuestion +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct GenericAnswer { + answer: String, +} + +/// Answer needed for Password specific questions. +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PasswordAnswer { + password: String, +} + +/// Sets up and returns the axum service for the questions module. +pub async fn questions_service(dbus: zbus::Connection) -> Result { + let questions = QuestionsClient::new(dbus.clone()).await?; + let state = QuestionsState { questions }; + let router = Router::new() + .route("/", get(list_questions)) + .route("/:id/answer", put(answer)) + .with_state(state); + Ok(router) +} + +pub async fn questions_stream( + dbus: zbus::Connection, +) -> Result + Send>>, Error> { + let question_path = OwnedObjectPath::from( + ObjectPath::try_from("/org/opensuse/Agama1/Questions") + .context("failed to create object path")?, + ); + let proxy = ObjectManagerProxy::builder(&dbus) + .path(question_path) + .context("Failed to create object manager path")? + .destination("org.opensuse.Agama1")? + .build() + .await + .context("Failed to create Object MAnager proxy")?; + let add_stream = proxy + .receive_interfaces_added() + .await? + .then(|_| async move { Event::QuestionsChanged }); + let remove_stream = proxy + .receive_interfaces_removed() + .await? + .then(|_| async move { Event::QuestionsChanged }); + let stream = StreamExt::merge(add_stream, remove_stream); + Ok(Box::pin(stream)) +} + +/// Returns the list of questions that waits for answer. +/// +/// * `state`: service state. +#[utoipa::path(get, path = "/questions", responses( + (status = 200, description = "List of open questions", body = Vec), + (status = 400, description = "The D-Bus service could not perform the action") +))] +async fn list_questions( + State(state): State>, +) -> Result>, Error> { + Ok(Json(state.questions.questions().await?)) +} + +/// Provide answer to question. +/// +/// * `state`: service state. +/// * `questions_id`: id of question +/// * `answer`: struct with answer and possible other data needed for answer like password +#[utoipa::path(put, path = "/questions/:id/answer", responses( + (status = 200, description = "answer question"), + (status = 400, description = "The D-Bus service could not perform the action") +))] +async fn answer( + State(state): State>, + Path(question_id): Path, + Json(answer): Json, +) -> Result<(), Error> { + state.questions.answer(question_id, answer).await?; + Ok(()) +} diff --git a/rust/agama-server/src/software.rs b/rust/agama-server/src/software.rs index b882542d90..d7099be9bf 100644 --- a/rust/agama-server/src/software.rs +++ b/rust/agama-server/src/software.rs @@ -1,2 +1,2 @@ pub mod web; -pub use web::{software_service, software_stream}; +pub use web::{software_service, software_streams}; diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs index d8575a3339..7181fbf370 100644 --- a/rust/agama-server/src/software/web.rs +++ b/rust/agama-server/src/software/web.rs @@ -1,14 +1,20 @@ -//! This module implements the web API for the software module. +//! This module implements the web API for the software service. //! //! The module offers two public functions: //! //! * `software_service` which returns the Axum service. //! * `software_stream` which offers an stream that emits the software events coming from D-Bus. -use crate::{error::Error, web::Event}; +use crate::{ + error::Error, + web::{ + common::{issues_router, progress_router, service_status_router, EventStreams}, + Event, + }, +}; use agama_lib::{ error::ServiceError, - product::{Product, ProductClient}, + product::{proxies::RegistrationProxy, Product, ProductClient, RegistrationRequirement}, software::{ proxies::{Software1Proxy, SoftwareProductProxy}, Pattern, SelectedBy, SoftwareClient, UnknownSelectedBy, @@ -17,14 +23,12 @@ use agama_lib::{ use axum::{ extract::State, http::StatusCode, - response::{IntoResponse, Response}, + response::IntoResponse, routing::{get, post, put}, Json, Router, }; use serde::{Deserialize, Serialize}; -use serde_json::json; use std::collections::HashMap; -use thiserror::Error; use tokio_stream::{Stream, StreamExt}; #[derive(Clone)] @@ -33,35 +37,45 @@ struct SoftwareState<'a> { software: SoftwareClient<'a>, } +/// Software service configuration (product, patterns, etc.). #[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct SoftwareConfig { - patterns: Option>, + /// A map where the keys are the pattern names and the values whether to install them or not. + patterns: Option>, + /// Name of the product to install. product: Option, } -#[derive(Error, Debug)] -pub enum SoftwareError { - #[error("Software service error: {0}")] - Error(#[from] ServiceError), -} - -impl IntoResponse for SoftwareError { - fn into_response(self) -> Response { - let body = json!({ - "error": self.to_string() - }); - (StatusCode::BAD_REQUEST, Json(body)).into_response() - } -} - /// Returns an stream that emits software related events coming from D-Bus. /// +/// It emits the Event::ProductChanged and Event::PatternsChanged events. +/// /// * `connection`: D-Bus connection to listen for events. -pub async fn software_stream(dbus: zbus::Connection) -> Result, Error> { - Ok(StreamExt::merge( - product_changed_stream(dbus.clone()).await?, - patterns_changed_stream(dbus.clone()).await?, - )) +pub async fn software_streams(dbus: zbus::Connection) -> Result { + let result: EventStreams = vec![ + ( + "patterns_changed", + Box::pin(patterns_changed_stream(dbus.clone()).await?), + ), + ( + "product_changed", + Box::pin(product_changed_stream(dbus.clone()).await?), + ), + ( + "registration_requirement_changed", + Box::pin(registration_requirement_changed_stream(dbus.clone()).await?), + ), + ( + "registration_code_changed", + Box::pin(registration_code_changed_stream(dbus.clone()).await?), + ), + ( + "registration_email_changed", + Box::pin(registration_email_changed_stream(dbus.clone()).await?), + ), + ]; + + Ok(result) } async fn product_changed_stream( @@ -100,7 +114,63 @@ async fn patterns_changed_stream( } None }) - .filter_map(|e| e.map(Event::PatternsChanged)); + .filter_map(|e| e.map(|patterns| Event::SoftwareProposalChanged { patterns })); + Ok(stream) +} + +async fn registration_requirement_changed_stream( + dbus: zbus::Connection, +) -> Result, Error> { + // TODO: move registration requirement to product in dbus and so just one event will be needed. + let proxy = RegistrationProxy::new(&dbus).await?; + let stream = proxy + .receive_requirement_changed() + .await + .then(|change| async move { + if let Ok(id) = change.get().await { + // unwrap is safe as possible numbers is send by our controlled dbus + return Some(Event::RegistrationRequirementChanged { + requirement: id.try_into().unwrap(), + }); + } + None + }) + .filter_map(|e| e); + Ok(stream) +} + +async fn registration_email_changed_stream( + dbus: zbus::Connection, +) -> Result, Error> { + let proxy = RegistrationProxy::new(&dbus).await?; + let stream = proxy + .receive_email_changed() + .await + .then(|change| async move { + if let Ok(_id) = change.get().await { + // TODO: add to stream also proxy and return whole cached registration info + return Some(Event::RegistrationChanged); + } + None + }) + .filter_map(|e| e); + Ok(stream) +} + +async fn registration_code_changed_stream( + dbus: zbus::Connection, +) -> Result, Error> { + let proxy = RegistrationProxy::new(&dbus).await?; + let stream = proxy + .receive_reg_code_changed() + .await + .then(|change| async move { + if let Ok(_id) = change.get().await { + return Some(Event::RegistrationChanged); + } + None + }) + .filter_map(|e| e); Ok(stream) } @@ -120,15 +190,32 @@ fn reason_to_selected_by( /// Sets up and returns the axum service for the software module. pub async fn software_service(dbus: zbus::Connection) -> Result { + const DBUS_SERVICE: &str = "org.opensuse.Agama.Software1"; + const DBUS_PATH: &str = "/org/opensuse/Agama/Software1"; + const DBUS_PRODUCT_PATH: &str = "/org/opensuse/Agama/Software1/Product"; + + let status_router = service_status_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; + let progress_router = progress_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; + let software_issues = issues_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; + let product_issues = issues_router(&dbus, DBUS_SERVICE, DBUS_PRODUCT_PATH).await?; + let product = ProductClient::new(dbus.clone()).await?; let software = SoftwareClient::new(dbus).await?; let state = SoftwareState { product, software }; let router = Router::new() .route("/patterns", get(patterns)) .route("/products", get(products)) + .route( + "/registration", + get(get_registration).post(register).delete(deregister), + ) .route("/proposal", get(proposal)) .route("/config", put(set_config).get(get_config)) .route("/probe", post(probe)) + .merge(status_router) + .merge(progress_router) + .nest("/issues/product", product_issues) + .nest("/issues/software", software_issues) .with_state(state); Ok(router) } @@ -140,50 +227,113 @@ pub async fn software_service(dbus: zbus::Connection) -> Result), (status = 400, description = "The D-Bus service could not perform the action") ))] -async fn products( - State(state): State>, -) -> Result>, SoftwareError> { +async fn products(State(state): State>) -> Result>, Error> { let products = state.product.products().await?; Ok(Json(products)) } -/// Represents a pattern. +/// Information about registration configuration (product, patterns, etc.). +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RegistrationInfo { + /// Registration key. Empty value mean key not used or not registered. + key: String, + /// Registration email. Empty value mean email not used or not registered. + email: String, + /// if registration is required, optional or not needed for current product. + /// Change only if selected product is changed. + requirement: RegistrationRequirement, +} + +/// returns registration info /// -/// It augments the information coming from the D-Bus client. -#[derive(Serialize, utoipa::ToSchema)] -pub struct PatternEntry { - #[serde(flatten)] - pattern: Pattern, - selected_by: SelectedBy, +/// * `state`: service state. +#[utoipa::path(get, path = "/software/registration", responses( + (status = 200, description = "registration configuration", body = RegistrationInfo), + (status = 400, description = "The D-Bus service could not perform the action") +))] +async fn get_registration( + State(state): State>, +) -> Result, Error> { + let result = RegistrationInfo { + key: state.product.registration_code().await?, + email: state.product.email().await?, + requirement: state.product.registration_requirement().await?, + }; + Ok(Json(result)) +} + +/// Software service configuration (product, patterns, etc.). +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct RegistrationParams { + /// Registration key. + key: String, + /// Registration email. + email: String, +} + +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct FailureDetails { + /// ID of error. See dbus API for possible values + id: u32, + /// human readable error string intended to be displayed to user + message: String, +} +/// Register product +/// +/// * `state`: service state. +#[utoipa::path(post, path = "/software/registration", responses( + (status = 204, description = "registration successfull"), + (status = 422, description = "Registration failed. Details are in body", body=FailureDetails), + (status = 400, description = "The D-Bus service could not perform the action") +))] +async fn register( + State(state): State>, + Json(config): Json, +) -> Result { + let (id, message) = state.product.register(&config.key, &config.email).await?; + let details = FailureDetails { id, message }; + if id == 0 { + Ok((StatusCode::NO_CONTENT, ().into_response())) + } else { + Ok(( + StatusCode::UNPROCESSABLE_ENTITY, + Json(details).into_response(), + )) + } +} + +/// Deregister product +/// +/// * `state`: service state. +#[utoipa::path(delete, path = "/software/registration", responses( + (status = 200, description = "deregistration successfull"), + (status = 422, description = "De-registration failed. Details are in body", body=FailureDetails), + (status = 400, description = "The D-Bus service could not perform the action") +))] +async fn deregister(State(state): State>) -> Result { + let (id, message) = state.product.deregister().await?; + let details = FailureDetails { id, message }; + if id == 0 { + Ok((StatusCode::NO_CONTENT, ().into_response())) + } else { + Ok(( + StatusCode::UNPROCESSABLE_ENTITY, + Json(details).into_response(), + )) + } } /// Returns the list of software patterns. /// /// * `state`: service state. #[utoipa::path(get, path = "/software/patterns", responses( - (status = 200, description = "List of known software patterns", body = Vec), + (status = 200, description = "List of known software patterns", body = Vec), (status = 400, description = "The D-Bus service could not perform the action") ))] -async fn patterns( - State(state): State>, -) -> Result>, SoftwareError> { +async fn patterns(State(state): State>) -> Result>, Error> { let patterns = state.software.patterns(true).await?; - let selected = state.software.selected_patterns().await?; - let items = patterns - .into_iter() - .map(|pattern| { - let selected_by: SelectedBy = selected - .get(&pattern.id) - .copied() - .unwrap_or(SelectedBy::None); - PatternEntry { - pattern, - selected_by, - } - }) - .collect(); - - Ok(Json(items)) + Ok(Json(patterns)) } /// Sets the software configuration. @@ -197,13 +347,13 @@ async fn patterns( async fn set_config( State(state): State>, Json(config): Json, -) -> Result<(), SoftwareError> { +) -> Result<(), Error> { if let Some(product) = config.product { state.product.select_product(&product).await?; } if let Some(patterns) = config.patterns { - state.software.select_patterns(&patterns).await?; + state.software.select_patterns(patterns).await?; } Ok(()) @@ -216,14 +366,23 @@ async fn set_config( (status = 200, description = "Software configuration", body = SoftwareConfig), (status = 400, description = "The D-Bus service could not perform the action") ))] -async fn get_config( - State(state): State>, -) -> Result, SoftwareError> { +async fn get_config(State(state): State>) -> Result, Error> { let product = state.product.product().await?; - let patterns = state.software.user_selected_patterns().await?; + let product = if product.is_empty() { + None + } else { + Some(product) + }; + let patterns = state + .software + .user_selected_patterns() + .await? + .into_iter() + .map(|p| (p, true)) + .collect(); let config = SoftwareConfig { patterns: Some(patterns), - product: Some(product), + product, }; Ok(Json(config)) } @@ -234,6 +393,9 @@ pub struct SoftwareProposal { /// Space required for installation. It is returned as a formatted string which includes /// a number and a unit (e.g., "GiB"). size: String, + /// Patterns selection. It is respresented as a hash map where the key is the pattern's name + /// and the value why the pattern is selected. + patterns: HashMap, } /// Returns the proposal information. @@ -243,11 +405,10 @@ pub struct SoftwareProposal { get, path = "/software/proposal", responses( (status = 200, description = "Software proposal", body = SoftwareProposal) ))] -async fn proposal( - State(state): State>, -) -> Result, SoftwareError> { +async fn proposal(State(state): State>) -> Result, Error> { let size = state.software.used_disk_space().await?; - let proposal = SoftwareProposal { size }; + let patterns = state.software.selected_patterns().await?; + let proposal = SoftwareProposal { size, patterns }; Ok(Json(proposal)) } @@ -260,7 +421,7 @@ async fn proposal( (status = 400, description = "The D-Bus service could not perform the action ") ))] -async fn probe(State(state): State>) -> Result, SoftwareError> { +async fn probe(State(state): State>) -> Result, Error> { state.software.probe().await?; Ok(Json(())) } diff --git a/rust/agama-server/src/storage.rs b/rust/agama-server/src/storage.rs new file mode 100644 index 0000000000..22dd60eeea --- /dev/null +++ b/rust/agama-server/src/storage.rs @@ -0,0 +1,2 @@ +pub mod web; +pub use web::{storage_service, storage_streams}; diff --git a/rust/agama-server/src/storage/web.rs b/rust/agama-server/src/storage/web.rs new file mode 100644 index 0000000000..15c14e3487 --- /dev/null +++ b/rust/agama-server/src/storage/web.rs @@ -0,0 +1,93 @@ +//! This module implements the web API for the storage service. +//! +//! The module offers two public functions: +//! +//! * `storage_service` which returns the Axum service. +//! * `storage_stream` which offers an stream that emits the storage events coming from D-Bus. + +use std::collections::HashMap; + +use agama_lib::{ + error::ServiceError, + storage::{ + client::{Action, Volume}, + device::Device, + StorageClient, + }, +}; +use anyhow::anyhow; +use axum::{ + extract::{Query, State}, + routing::get, + Json, Router, +}; + +use crate::{ + error::Error, + web::{ + common::{issues_router, progress_router, service_status_router, EventStreams}, + Event, + }, +}; + +pub async fn storage_streams(dbus: zbus::Connection) -> Result { + let result: EventStreams = vec![]; // TODO: + Ok(result) +} + +#[derive(Clone)] +struct StorageState<'a> { + client: StorageClient<'a>, +} + +/// Sets up and returns the axum service for the software module. +pub async fn storage_service(dbus: zbus::Connection) -> Result { + const DBUS_SERVICE: &str = "org.opensuse.Agama.Storage1"; + const DBUS_PATH: &str = "/org/opensuse/Agama/Storage1"; + + let status_router = service_status_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; + let progress_router = progress_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; + let issues_router = issues_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; + + let client = StorageClient::new(dbus.clone()).await?; + let state = StorageState { client }; + let router = Router::new() + .route("/devices/dirty", get(devices_dirty)) + .route("/devices/system", get(system_devices)) + .route("/devices/result", get(staging_devices)) + .route("/product/volume_for", get(volume_for)) + .route("/proposal/actions", get(actions)) + .merge(status_router) + .merge(progress_router) + .nest("/issues", issues_router) + .with_state(state); + Ok(router) +} + +async fn devices_dirty(State(state): State>) -> Result, Error> { + Ok(Json(state.client.devices_dirty_bit().await?)) +} + +async fn system_devices(State(state): State>) -> Result>, Error> { + Ok(Json(state.client.system_devices().await?)) +} + +async fn staging_devices( + State(state): State>, +) -> Result>, Error> { + Ok(Json(state.client.staging_devices().await?)) +} + +async fn actions(State(state): State>) -> Result>, Error> { + Ok(Json(state.client.actions().await?)) +} + +async fn volume_for( + State(state): State>, + Query(params): Query>, +) -> Result, Error> { + let mount_path = params + .get("mount_path") + .ok_or(anyhow!("Missing mount_path parameter"))?; + Ok(Json(state.client.volume_for(mount_path).await?)) +} diff --git a/rust/agama-server/src/users.rs b/rust/agama-server/src/users.rs new file mode 100644 index 0000000000..76ddbc68ee --- /dev/null +++ b/rust/agama-server/src/users.rs @@ -0,0 +1,2 @@ +pub mod web; +pub use web::{users_service, users_streams}; diff --git a/rust/agama-server/src/users/web.rs b/rust/agama-server/src/users/web.rs new file mode 100644 index 0000000000..ea5aaf2909 --- /dev/null +++ b/rust/agama-server/src/users/web.rs @@ -0,0 +1,226 @@ +//! +//! The module offers two public functions: +//! +//! * `users_service` which returns the Axum service. +//! * `users_stream` which offers an stream that emits the users events coming from D-Bus. + +use crate::{ + error::Error, + web::{ + common::{service_status_router, validation_router, EventStreams}, + Event, + }, +}; +use agama_lib::{ + error::ServiceError, + users::{proxies::Users1Proxy, FirstUser, UsersClient}, +}; +use axum::{extract::State, routing::get, Json, Router}; +use serde::{Deserialize, Serialize}; +use tokio_stream::{Stream, StreamExt}; + +#[derive(Clone)] +struct UsersState<'a> { + users: UsersClient<'a>, +} + +/// Returns streams that emits users related events coming from D-Bus. +/// +/// It emits the Event::RootPasswordChange, Event::RootSSHKeyChanged and Event::FirstUserChanged events. +/// +/// * `connection`: D-Bus connection to listen for events. +pub async fn users_streams(dbus: zbus::Connection) -> Result { + const FIRST_USER_ID: &str = "first_user"; + const ROOT_PASSWORD_ID: &str = "root_password"; + const ROOT_SSHKEY_ID: &str = "root_sshkey"; + // here we have three streams, but only two events. Reason is + // that we have three streams from dbus about property change + // and unify two root user properties into single event to http API + let result: EventStreams = vec![ + ( + FIRST_USER_ID, + Box::pin(first_user_changed_stream(dbus.clone()).await?), + ), + ( + ROOT_PASSWORD_ID, + Box::pin(root_password_changed_stream(dbus.clone()).await?), + ), + ( + ROOT_SSHKEY_ID, + Box::pin(root_ssh_key_changed_stream(dbus.clone()).await?), + ), + ]; + + Ok(result) +} + +async fn first_user_changed_stream( + dbus: zbus::Connection, +) -> Result + Send, Error> { + let proxy = Users1Proxy::new(&dbus).await?; + let stream = proxy + .receive_first_user_changed() + .await + .then(|change| async move { + if let Ok(user) = change.get().await { + let user_struct = FirstUser { + full_name: user.0, + user_name: user.1, + password: user.2, + autologin: user.3, + data: user.4, + }; + return Some(Event::FirstUserChanged(user_struct)); + } + None + }) + .filter_map(|e| e); + Ok(stream) +} + +async fn root_password_changed_stream( + dbus: zbus::Connection, +) -> Result + Send, Error> { + let proxy = Users1Proxy::new(&dbus).await?; + let stream = proxy + .receive_root_password_set_changed() + .await + .then(|change| async move { + if let Ok(is_set) = change.get().await { + return Some(Event::RootChanged { + password: Some(is_set), + sshkey: None, + }); + } + None + }) + .filter_map(|e| e); + Ok(stream) +} + +async fn root_ssh_key_changed_stream( + dbus: zbus::Connection, +) -> Result + Send, Error> { + let proxy = Users1Proxy::new(&dbus).await?; + let stream = proxy + .receive_root_sshkey_changed() + .await + .then(|change| async move { + if let Ok(key) = change.get().await { + return Some(Event::RootChanged { + password: None, + sshkey: Some(key), + }); + } + None + }) + .filter_map(|e| e); + Ok(stream) +} + +/// Sets up and returns the axum service for the users module. +pub async fn users_service(dbus: zbus::Connection) -> Result { + const DBUS_SERVICE: &str = "org.opensuse.Agama.Manager1"; + const DBUS_PATH: &str = "/org/opensuse/Agama/Users1"; + + let users = UsersClient::new(dbus.clone()).await?; + let state = UsersState { users }; + let validation_router = validation_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; + let status_router = service_status_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; + let router = Router::new() + .route( + "/first", + get(get_user_config) + .put(set_first_user) + .delete(remove_first_user), + ) + .route("/root", get(get_root_config).patch(patch_root)) + .merge(validation_router) + .merge(status_router) + .with_state(state); + Ok(router) +} + +/// Removes the first user settings +#[utoipa::path(delete, path = "/users/first", responses( + (status = 200, description = "Removes the first user"), + (status = 400, description = "The D-Bus service could not perform the action"), +))] +async fn remove_first_user(State(state): State>) -> Result<(), Error> { + state.users.remove_first_user().await?; + Ok(()) +} + +#[utoipa::path(put, path = "/users/first", responses( + (status = 200, description = "Sets the first user"), + (status = 400, description = "The D-Bus service could not perform the action"), +))] +async fn set_first_user( + State(state): State>, + Json(config): Json, +) -> Result<(), Error> { + state.users.set_first_user(&config).await?; + Ok(()) +} + +#[utoipa::path(get, path = "/users/first", responses( + (status = 200, description = "Configuration for the first user", body = FirstUser), + (status = 400, description = "The D-Bus service could not perform the action"), +))] +async fn get_user_config(State(state): State>) -> Result, Error> { + Ok(Json(state.users.first_user().await?)) +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RootPatchSettings { + /// empty string here means remove ssh key for root + pub sshkey: Option, + /// empty string here means remove password for root + pub password: Option, + /// specify if patched password is provided in encrypted form + pub password_encrypted: Option, +} + +#[utoipa::path(patch, path = "/users/root", responses( + (status = 200, description = "Root configuration is modified", body = RootPatchSettings), + (status = 400, description = "The D-Bus service could not perform the action"), +))] +async fn patch_root( + State(state): State>, + Json(config): Json, +) -> Result<(), Error> { + if let Some(key) = config.sshkey { + state.users.set_root_sshkey(&key).await?; + } + if let Some(password) = config.password { + if password.is_empty() { + state.users.remove_root_password().await?; + } else { + state + .users + .set_root_password(&password, config.password_encrypted == Some(true)) + .await?; + } + } + Ok(()) +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +pub struct RootConfig { + /// returns if password for root is set or not + password: bool, + /// empty string mean no sshkey is specified + sshkey: String, +} + +#[utoipa::path(get, path = "/users/root", responses( + (status = 200, description = "Configuration for the root user", body = RootConfig), + (status = 400, description = "The D-Bus service could not perform the action"), +))] +async fn get_root_config(State(state): State>) -> Result, Error> { + let password = state.users.is_root_password().await?; + let sshkey = state.users.root_ssh_key().await?; + let config = RootConfig { password, sshkey }; + Ok(Json(config)) +} diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index b088528589..5b03f4b4f7 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -4,44 +4,68 @@ //! * Emit relevant events via websocket. //! * Serve the code for the web user interface (not implemented yet). -use self::progress::EventsProgressPresenter; use crate::{ error::Error, l10n::web::l10n_service, - software::web::{software_service, software_stream}, + manager::web::{manager_service, manager_stream}, + network::{web::network_service, NetworkManagerAdapter}, + questions::web::{questions_service, questions_stream}, + software::web::{software_service, software_streams}, + storage::web::{storage_service, storage_streams}, + users::web::{users_service, users_streams}, + web::common::{issues_stream, progress_stream, service_status_stream}, }; use axum::Router; mod auth; +pub mod common; mod config; mod docs; mod event; mod http; -mod progress; mod service; mod state; mod ws; -use agama_lib::{connection, error::ServiceError, progress::ProgressMonitor}; +use agama_lib::{connection, error::ServiceError}; pub use auth::generate_token; pub use config::ServiceConfig; pub use docs::ApiDoc; pub use event::{Event, EventsReceiver, EventsSender}; pub use service::MainServiceBuilder; -use tokio_stream::StreamExt; +use std::path::Path; +use tokio_stream::{StreamExt, StreamMap}; /// Returns a service that implements the web-based Agama API. /// /// * `config`: service configuration. -/// * `events`: D-Bus connection. -pub async fn service( +/// * `events`: channel to send the events through the WebSocket. +/// * `dbus`: D-Bus connection. +/// * `web_ui_dir`: public directory containing the web UI. +pub async fn service

( config: ServiceConfig, events: EventsSender, dbus: zbus::Connection, -) -> Result { - let router = MainServiceBuilder::new(events.clone()) - .add_service("/l10n", l10n_service(events.clone())) - .add_service("/software", software_service(dbus).await?) + web_ui_dir: P, +) -> Result +where + P: AsRef, +{ + let network_adapter = NetworkManagerAdapter::from_system() + .await + .expect("Could not connect to NetworkManager to read the configuration"); + + let router = MainServiceBuilder::new(events.clone(), web_ui_dir) + .add_service("/l10n", l10n_service(dbus.clone(), events.clone()).await?) + .add_service("/manager", manager_service(dbus.clone()).await?) + .add_service("/software", software_service(dbus.clone()).await?) + .add_service("/storage", storage_service(dbus.clone()).await?) + .add_service( + "/network", + network_service(dbus.clone(), network_adapter, events).await?, + ) + .add_service("/questions", questions_service(dbus.clone()).await?) + .add_service("/users", users_service(dbus.clone()).await?) .with_config(config) .build(); Ok(router) @@ -53,14 +77,7 @@ pub async fn service( /// /// * `events`: channel to send the events to. pub async fn run_monitor(events: EventsSender) -> Result<(), ServiceError> { - let presenter = EventsProgressPresenter::new(events.clone()); let connection = connection().await?; - let mut monitor = ProgressMonitor::new(connection.clone()).await?; - tokio::spawn(async move { - if let Err(error) = monitor.run(presenter).await { - eprintln!("Could not monitor the D-Bus server: {}", error); - } - }); tokio::spawn(run_events_monitor(connection, events.clone())); Ok(()) @@ -70,11 +87,78 @@ pub async fn run_monitor(events: EventsSender) -> Result<(), ServiceError> { /// /// * `connection`: D-Bus connection. /// * `events`: channel to send the events to. -pub async fn run_events_monitor(dbus: zbus::Connection, events: EventsSender) -> Result<(), Error> { - let stream = software_stream(dbus).await?; +async fn run_events_monitor(dbus: zbus::Connection, events: EventsSender) -> Result<(), Error> { + let mut stream = StreamMap::new(); + + stream.insert("manager", manager_stream(dbus.clone()).await?); + stream.insert( + "manager-status", + service_status_stream( + dbus.clone(), + "org.opensuse.Agama.Manager1", + "/org/opensuse/Agama/Manager1", + ) + .await?, + ); + stream.insert( + "manager-progress", + progress_stream( + dbus.clone(), + "org.opensuse.Agama.Manager1", + "/org/opensuse/Agama/Manager1", + ) + .await?, + ); + for (id, user_stream) in users_streams(dbus.clone()).await? { + stream.insert(id, user_stream); + } + for (id, storage_stream) in storage_streams(dbus.clone()).await? { + stream.insert(id, storage_stream); + } + for (id, software_stream) in software_streams(dbus.clone()).await? { + stream.insert(id, software_stream); + } + stream.insert( + "software-status", + service_status_stream( + dbus.clone(), + "org.opensuse.Agama.Software1", + "/org/opensuse/Agama/Software1", + ) + .await?, + ); + stream.insert( + "software-progress", + progress_stream( + dbus.clone(), + "org.opensuse.Agama.Software1", + "/org/opensuse/Agama/Software1", + ) + .await?, + ); + stream.insert("questions", questions_stream(dbus.clone()).await?); + stream.insert( + "software-issues", + issues_stream( + dbus.clone(), + "org.opensuse.Agama.Software1", + "/org/opensuse/Agama/Software1", + ) + .await?, + ); + stream.insert( + "software-product-issues", + issues_stream( + dbus.clone(), + "org.opensuse.Agama.Software1", + "/org/opensuse/Agama/Software1/Product", + ) + .await?, + ); + tokio::pin!(stream); let e = events.clone(); - while let Some(event) = stream.next().await { + while let Some((_, event)) = stream.next().await { _ = e.send(event); } Ok(()) diff --git a/rust/agama-server/src/web/auth.rs b/rust/agama-server/src/web/auth.rs index 5674bdc753..36fad9a04e 100644 --- a/rust/agama-server/src/web/auth.rs +++ b/rust/agama-server/src/web/auth.rs @@ -9,7 +9,7 @@ use axum::{ Json, RequestPartsExt, }; use axum_extra::{ - headers::{authorization::Bearer, Authorization}, + headers::{self, authorization::Bearer}, TypedHeader, }; use chrono::{Duration, Utc}; @@ -50,6 +50,18 @@ pub struct TokenClaims { exp: i64, } +impl TokenClaims { + /// Builds claims for a given token. + /// + /// * `token`: token to extract the claims from. + /// * `secret`: secret to decode the token. + pub fn from_token(token: &str, secret: &str) -> Result { + let decoding = DecodingKey::from_secret(secret.as_ref()); + let token_data = jsonwebtoken::decode(&token, &decoding, &Validation::default())?; + Ok(token_data.claims) + } +} + impl Default for TokenClaims { fn default() -> Self { let exp = Utc::now() + Duration::days(1); @@ -67,15 +79,24 @@ impl FromRequestParts for TokenClaims { parts: &mut request::Parts, state: &ServiceState, ) -> Result { - let TypedHeader(Authorization(bearer)) = parts - .extract::>>() + let token = match parts + .extract::>>() .await - .map_err(|_| AuthError::MissingToken)?; - - let decoding = DecodingKey::from_secret(state.config.jwt_secret.as_ref()); - let token_data = jsonwebtoken::decode(bearer.token(), &decoding, &Validation::default())?; + { + Ok(TypedHeader(headers::Authorization(bearer))) => bearer.token().to_owned(), + Err(_) => { + let cookie = parts + .extract::>() + .await + .map_err(|_| AuthError::MissingToken)?; + cookie + .get("agamaToken") + .ok_or(AuthError::MissingToken)? + .to_owned() + } + }; - Ok(token_data.claims) + TokenClaims::from_token(&token, &state.config.jwt_secret) } } diff --git a/rust/agama-server/src/web/common.rs b/rust/agama-server/src/web/common.rs new file mode 100644 index 0000000000..06da4f88e5 --- /dev/null +++ b/rust/agama-server/src/web/common.rs @@ -0,0 +1,461 @@ +//! This module defines functions to be used accross all services. + +use std::{pin::Pin, task::Poll}; + +use agama_lib::{ + error::ServiceError, + progress::Progress, + proxies::{IssuesProxy, ProgressProxy, ServiceStatusProxy, ValidationProxy}, +}; +use axum::{extract::State, routing::get, Json, Router}; +use pin_project::pin_project; +use serde::Serialize; +use tokio_stream::{Stream, StreamExt}; +use zbus::PropertyStream; + +use crate::error::Error; + +use super::Event; + +pub type EventStreams = Vec<(&'static str, Pin + Send>>)>; + +/// Builds a router to the `org.opensuse.Agama1.ServiceStatus` interface of the +/// given D-Bus object. +/// +/// ```no_run +/// # use axum::{extract::State, routing::get, Json, Router}; +/// # use agama_lib::connection; +/// # use agama_server::web::common::service_status_router; +/// # use tokio_test; +/// +/// # tokio_test::block_on(async { +/// async fn hello(state: State) {}; +/// +/// #[derive(Clone)] +/// struct HelloWorldState {}; +/// +/// let dbus = connection().await.unwrap(); +/// let status_router = service_status_router( +/// &dbus, "org.opensuse.HelloWorld", "/org/opensuse/hello" +/// ).await.unwrap(); +/// let router: Router = Router::new() +/// .route("/hello", get(hello)) +/// .merge(status_router) +/// .with_state(HelloWorldState {}); +/// }); +/// ``` +/// +/// * `dbus`: D-Bus connection. +/// * `destination`: D-Bus service name. +/// * `path`: D-Bus object path. +pub async fn service_status_router( + dbus: &zbus::Connection, + destination: &str, + path: &str, +) -> Result, ServiceError> { + let proxy = build_service_status_proxy(dbus, destination, path).await?; + let state = ServiceStatusState { proxy }; + Ok(Router::new() + .route("/status", get(service_status)) + .with_state(state)) +} + +async fn service_status(State(state): State>) -> Json { + Json(ServiceStatus { + current: state.proxy.current().await.unwrap(), + }) +} + +#[derive(Clone)] +struct ServiceStatusState<'a> { + proxy: ServiceStatusProxy<'a>, +} + +#[derive(Clone, Serialize)] +struct ServiceStatus { + /// Current service status. + current: u32, +} + +/// Builds a stream of the changes in the the `org.opensuse.Agama1.ServiceStatus` +/// interface of the given D-Bus object. +/// +/// * `dbus`: D-Bus connection. +/// * `destination`: D-Bus service name. +/// * `path`: D-Bus object path. +pub async fn service_status_stream( + dbus: zbus::Connection, + destination: &'static str, + path: &'static str, +) -> Result + Send>>, Error> { + let proxy = build_service_status_proxy(&dbus, destination, path).await?; + let stream = proxy + .receive_current_changed() + .await + .then(move |change| async move { + if let Ok(status) = change.get().await { + Some(Event::ServiceStatusChanged { + service: destination.to_string(), + status, + }) + } else { + None + } + }) + .filter_map(|e| e); + Ok(Box::pin(stream)) +} + +async fn build_service_status_proxy<'a>( + dbus: &zbus::Connection, + destination: &str, + path: &str, +) -> Result, zbus::Error> { + let proxy = ServiceStatusProxy::builder(dbus) + .destination(destination.to_string())? + .path(path.to_string())? + .build() + .await?; + Ok(proxy) +} + +/// Builds a router to the `org.opensuse.Agama1.Progress` +/// interface of the given D-Bus object. +/// +/// ```no_run +/// # use axum::{extract::State, routing::get, Json, Router}; +/// # use agama_lib::connection; +/// # use agama_server::web::common::progress_router; +/// # use tokio_test; +/// +/// # tokio_test::block_on(async { +/// async fn hello(state: State) {}; +/// +/// #[derive(Clone)] +/// struct HelloWorldState {}; +/// +/// let dbus = connection().await.unwrap(); +/// let progress_router = progress_router( +/// &dbus, "org.opensuse.HelloWorld", "/org/opensuse/hello" +/// ).await.unwrap(); +/// let router: Router = Router::new() +/// .route("/hello", get(hello)) +/// .merge(progress_router) +/// .with_state(HelloWorldState {}); +/// }); +/// ``` +/// +/// * `dbus`: D-Bus connection. +/// * `destination`: D-Bus service name. +/// * `path`: D-Bus object path. +pub async fn progress_router( + dbus: &zbus::Connection, + destination: &str, + path: &str, +) -> Result, ServiceError> { + let proxy = build_progress_proxy(dbus, destination, path).await?; + let state = ProgressState { proxy }; + Ok(Router::new() + .route("/progress", get(progress)) + .with_state(state)) +} + +#[derive(Clone)] +struct ProgressState<'a> { + proxy: ProgressProxy<'a>, +} + +async fn progress(State(state): State>) -> Result, Error> { + let proxy = state.proxy; + let progress = Progress::from_proxy(&proxy).await?; + Ok(Json(progress)) +} + +#[pin_project] +pub struct ProgressStream<'a> { + #[pin] + inner: PropertyStream<'a, (u32, String)>, + proxy: ProgressProxy<'a>, +} + +pub async fn progress_stream<'a>( + dbus: zbus::Connection, + destination: &'static str, + path: &'static str, +) -> Result + Send>>, zbus::Error> { + let proxy = build_progress_proxy(&dbus, destination, path).await?; + Ok(Box::pin(ProgressStream::new(proxy).await)) +} + +impl<'a> ProgressStream<'a> { + pub async fn new(proxy: ProgressProxy<'a>) -> Self { + let stream = proxy.receive_current_step_changed().await; + ProgressStream { + inner: stream, + proxy, + } + } +} + +impl<'a> Stream for ProgressStream<'a> { + type Item = Event; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let pinned = self.project(); + match pinned.inner.poll_next(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(_change) => match Progress::from_cached_proxy(pinned.proxy) { + Some(progress) => { + let event = Event::Progress { + progress, + service: pinned.proxy.destination().to_string(), + }; + Poll::Ready(Some(event)) + } + _ => Poll::Pending, + }, + } + } +} + +async fn build_progress_proxy<'a>( + dbus: &zbus::Connection, + destination: &str, + path: &str, +) -> Result, zbus::Error> { + let proxy = ProgressProxy::builder(dbus) + .destination(destination.to_string())? + .path(path.to_string())? + .build() + .await?; + Ok(proxy) +} + +/// Builds a router to the `org.opensuse.Agama1.Issues` interface of a given +/// D-Bus object. +/// +/// ```no_run +/// # use axum::{extract::State, routing::get, Json, Router}; +/// # use agama_lib::connection; +/// # use agama_server::web::common::{issues_router, service_status_router}; +/// # use tokio_test; +/// +/// # tokio_test::block_on(async { +/// async fn hello(state: State) {}; +/// +/// #[derive(Clone)] +/// struct HelloWorldState {}; +/// +/// let dbus = connection().await.unwrap(); +/// let issues_router = issues_router( +/// &dbus, "org.opensuse.HelloWorld", "/org/opensuse/hello" +/// ).await.unwrap(); +/// let router: Router = Router::new() +/// .route("/hello", get(hello)) +/// .merge(issues_router) +/// .with_state(HelloWorldState {}); +/// }); +/// ``` +/// +/// * `dbus`: D-Bus connection. +/// * `destination`: D-Bus service name. +/// * `path`: D-Bus object path. +pub async fn issues_router( + dbus: &zbus::Connection, + destination: &str, + path: &str, +) -> Result, ServiceError> { + let proxy = build_issues_proxy(dbus, destination, path).await?; + let state = IssuesState { proxy }; + Ok(Router::new().route("/", get(issues)).with_state(state)) +} + +async fn issues(State(state): State>) -> Result>, Error> { + let issues = state.proxy.all().await?; + let issues: Vec = issues.into_iter().map(Issue::from_tuple).collect(); + Ok(Json(issues)) +} + +#[derive(Clone)] +struct IssuesState<'a> { + proxy: IssuesProxy<'a>, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Issue { + description: String, + details: Option, + source: u32, + severity: u32, +} + +impl Issue { + pub fn from_tuple( + (description, details, source, severity): (String, String, u32, u32), + ) -> Self { + let details = if details.is_empty() { + None + } else { + Some(details) + }; + + Self { + description, + details, + source, + severity, + } + } +} + +/// Builds a stream of the changes in the the `org.opensuse.Agama1.Issues` +/// interface of the given D-Bus object. +/// +/// * `dbus`: D-Bus connection. +/// * `destination`: D-Bus service name. +/// * `path`: D-Bus object path. +pub async fn issues_stream( + dbus: zbus::Connection, + destination: &'static str, + path: &'static str, +) -> Result + Send>>, Error> { + let proxy = build_issues_proxy(&dbus, destination, path).await?; + let stream = proxy + .receive_all_changed() + .await + .then(move |change| async move { + if let Ok(issues) = change.get().await { + let issues = issues.into_iter().map(Issue::from_tuple).collect(); + Some(Event::IssuesChanged { + service: destination.to_string(), + path: path.to_string(), + issues, + }) + } else { + None + } + }) + .filter_map(|e| e); + Ok(Box::pin(stream)) +} + +async fn build_issues_proxy<'a>( + dbus: &zbus::Connection, + destination: &str, + path: &str, +) -> Result, zbus::Error> { + let proxy = IssuesProxy::builder(dbus) + .destination(destination.to_string())? + .path(path.to_string())? + .build() + .await?; + Ok(proxy) +} + +/// Builds a router to the `org.opensuse.Agama1.Validation` interface of a given +/// D-Bus object. +/// +/// ```no_run +/// # use axum::{extract::State, routing::get, Json, Router}; +/// # use agama_lib::connection; +/// # use agama_server::web::common::validation_router; +/// # use tokio_test; +/// +/// # tokio_test::block_on(async { +/// async fn hello(state: State) {}; +/// +/// #[derive(Clone)] +/// struct HelloWorldState {}; +/// +/// let dbus = connection().await.unwrap(); +/// let validation_routes = validation_router( +/// &dbus, "org.opensuse.HelloWorld", "/org/opensuse/hello" +/// ).await.unwrap(); +/// let router: Router = Router::new() +/// .route("/hello", get(hello)) +/// .merge(validation_routes) +/// .with_state(HelloWorldState {}); +/// }); +/// ``` +/// +/// * `dbus`: D-Bus connection. +/// * `destination`: D-Bus service name. +/// * `path`: D-Bus object path. +pub async fn validation_router( + dbus: &zbus::Connection, + destination: &str, + path: &str, +) -> Result, ServiceError> { + let proxy = build_validation_proxy(dbus, destination, path).await?; + let state = ValidationState { proxy }; + Ok(Router::new() + .route("/validation", get(validation)) + .with_state(state)) +} + +#[derive(Clone, Serialize, utoipa::ToSchema)] +pub struct ValidationResult { + valid: bool, + errors: Vec, +} + +async fn validation( + State(state): State>, +) -> Result, Error> { + let validation = ValidationResult { + valid: state.proxy.valid().await?, + errors: state.proxy.errors().await?, + }; + Ok(Json(validation)) +} + +#[derive(Clone)] +struct ValidationState<'a> { + proxy: ValidationProxy<'a>, +} + +/// Builds a stream of the changes in the the `org.opensuse.Agama1.Validation` +/// interface of the given D-Bus object. +/// +/// * `dbus`: D-Bus connection. +/// * `destination`: D-Bus service name. +/// * `path`: D-Bus object path. +pub async fn validation_stream( + dbus: zbus::Connection, + destination: &'static str, + path: &'static str, +) -> Result + Send>>, Error> { + let proxy = build_validation_proxy(&dbus, destination, path).await?; + let stream = proxy + .receive_errors_changed() + .await + .then(move |change| async move { + if let Ok(errors) = change.get().await { + Some(Event::ValidationChanged { + service: destination.to_string(), + path: path.to_string(), + errors, + }) + } else { + None + } + }) + .filter_map(|e| e); + Ok(Box::pin(stream)) +} + +async fn build_validation_proxy<'a>( + dbus: &zbus::Connection, + destination: &str, + path: &str, +) -> Result, zbus::Error> { + let proxy = ValidationProxy::builder(dbus) + .destination(destination.to_string())? + .path(path.to_string())? + .build() + .await?; + Ok(proxy) +} diff --git a/rust/agama-server/src/web/docs.rs b/rust/agama-server/src/web/docs.rs index d5e1249e1c..8d625b0c7e 100644 --- a/rust/agama-server/src/web/docs.rs +++ b/rust/agama-server/src/web/docs.rs @@ -1,5 +1,4 @@ use utoipa::OpenApi; - #[derive(OpenApi)] #[openapi( info(description = "Agama web API description"), @@ -9,22 +8,48 @@ use utoipa::OpenApi; crate::l10n::web::locales, crate::l10n::web::set_config, crate::l10n::web::timezones, + crate::network::web::devices, + crate::network::web::connections, crate::software::web::get_config, crate::software::web::patterns, - crate::software::web::patterns, crate::software::web::set_config, + crate::manager::web::probe_action, + crate::manager::web::install_action, + crate::manager::web::finish_action, + crate::manager::web::installer_status, + crate::questions::web::list_questions, + crate::questions::web::answer, + crate::users::web::get_root_config, + crate::users::web::get_user_config, + crate::users::web::set_first_user, + crate::users::web::remove_first_user, + crate::users::web::patch_root, super::http::ping, ), components( schemas(agama_lib::product::Product), schemas(agama_lib::software::Pattern), + schemas(agama_lib::manager::InstallationPhase), schemas(crate::l10n::Keymap), schemas(crate::l10n::LocaleEntry), schemas(crate::l10n::TimezoneEntry), schemas(crate::l10n::web::LocaleConfig), - schemas(crate::software::web::PatternEntry), + schemas(crate::network::model::NetworkState), + schemas(crate::network::model::Device), + schemas(crate::network::model::Connection), + schemas(agama_lib::network::types::DeviceType), schemas(crate::software::web::SoftwareConfig), schemas(crate::software::web::SoftwareProposal), + schemas(crate::manager::web::InstallerStatus), + schemas(crate::questions::web::Question), + schemas(crate::questions::web::GenericQuestion), + schemas(crate::questions::web::QuestionWithPassword), + schemas(crate::questions::web::Answer), + schemas(crate::questions::web::GenericAnswer), + schemas(crate::questions::web::PasswordAnswer), + schemas(agama_lib::users::FirstUser), + schemas(crate::users::web::RootConfig), + schemas(crate::users::web::RootPatchSettings), schemas(super::http::PingResponse), ) )] diff --git a/rust/agama-server/src/web/event.rs b/rust/agama-server/src/web/event.rs index a84956c2f3..5c8450ce0d 100644 --- a/rust/agama-server/src/web/event.rs +++ b/rust/agama-server/src/web/event.rs @@ -1,15 +1,68 @@ -use agama_lib::{progress::Progress, software::SelectedBy}; +use crate::{l10n::web::LocaleConfig, network::model::NetworkChange}; +use agama_lib::{ + manager::InstallationPhase, product::RegistrationRequirement, progress::Progress, + software::SelectedBy, users::FirstUser, +}; use serde::Serialize; use std::collections::HashMap; use tokio::sync::broadcast::{Receiver, Sender}; -#[derive(Clone, Serialize)] +use super::common::Issue; + +#[derive(Clone, Debug, Serialize)] #[serde(tag = "type")] pub enum Event { - LocaleChanged { locale: String }, - Progress(Progress), - ProductChanged { id: String }, - PatternsChanged(HashMap), + L10nConfigChanged(LocaleConfig), + LocaleChanged { + locale: String, + }, + Progress { + service: String, + #[serde(flatten)] + progress: Progress, + }, + ProductChanged { + id: String, + }, + RegistrationRequirementChanged { + requirement: RegistrationRequirement, + }, + RegistrationChanged, + FirstUserChanged(FirstUser), + RootChanged { + password: Option, + sshkey: Option, + }, + NetworkChange { + #[serde(flatten)] + change: NetworkChange, + }, + // TODO: it should include the full software proposal or, at least, + // all the relevant changes. + SoftwareProposalChanged { + patterns: HashMap, + }, + QuestionsChanged, + InstallationPhaseChanged { + phase: InstallationPhase, + }, + BusyServicesChanged { + services: Vec, + }, + ServiceStatusChanged { + service: String, + status: u32, + }, + IssuesChanged { + service: String, + path: String, + issues: Vec, + }, + ValidationChanged { + service: String, + path: String, + errors: Vec, + }, } pub type EventsSender = Sender; diff --git a/rust/agama-server/src/web/http.rs b/rust/agama-server/src/web/http.rs index 54da54a3ae..92a89956ed 100644 --- a/rust/agama-server/src/web/http.rs +++ b/rust/agama-server/src/web/http.rs @@ -1,10 +1,17 @@ -//! Implements the handlers for the HTTP-based API. +//! Implements the basic handlers for the HTTP-based API (login, logout, ping, etc.). use super::{ - auth::{generate_token, AuthError}, + auth::{generate_token, AuthError, TokenClaims}, state::ServiceState, }; -use axum::{extract::State, Json}; +use axum::{ + body::Body, + extract::{Query, State}, + http::{header, HeaderMap, HeaderValue, StatusCode}, + response::IntoResponse, + Json, +}; +use axum_extra::extract::cookie::CookieJar; use pam::Client; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -36,13 +43,13 @@ pub struct LoginRequest { pub password: String, } -#[utoipa::path(get, path = "/authenticate", responses( - (status = 200, description = "The user have been successfully authenticated", body = AuthResponse) +#[utoipa::path(post, path = "/api/auth", responses( + (status = 200, description = "The user has been successfully authenticated.", body = AuthResponse) ))] -pub async fn authenticate( +pub async fn login( State(state): State, Json(login): Json, -) -> Result, AuthError> { +) -> Result { let mut pam_client = Client::with_password("agama")?; pam_client .conversation_mut() @@ -50,5 +57,136 @@ pub async fn authenticate( pam_client.authenticate()?; let token = generate_token(&state.config.jwt_secret); - Ok(Json(AuthResponse { token })) + let content = Json(AuthResponse { + token: token.to_owned(), + }); + + let mut headers = HeaderMap::new(); + let cookie = auth_cookie_from_token(&token); + headers.insert( + header::SET_COOKIE, + cookie.parse().expect("could not build a valid cookie"), + ); + + Ok((headers, content)) +} + +#[derive(Clone, Deserialize, utoipa::ToSchema)] +pub struct LoginFromQueryParams { + /// Token to use for authentication. + token: String, +} + +#[utoipa::path(get, path = "/login", responses( + (status = 301, description = "Injects the authentication cookie if correct and redirects to the web UI") +))] +pub async fn login_from_query( + State(state): State, + Query(params): Query, +) -> impl IntoResponse { + let mut headers = HeaderMap::new(); + + if TokenClaims::from_token(¶ms.token, &state.config.jwt_secret).is_ok() { + let cookie = auth_cookie_from_token(¶ms.token); + headers.insert( + header::SET_COOKIE, + cookie.parse().expect("could not build a valid cookie"), + ); + } + + headers.insert(header::LOCATION, HeaderValue::from_static("/")); + (StatusCode::TEMPORARY_REDIRECT, headers) +} + +#[utoipa::path(delete, path = "/api/auth", responses( + (status = 204, description = "The user has been logged out.") +))] +pub async fn logout(_claims: TokenClaims) -> Result { + let mut headers = HeaderMap::new(); + let cookie = "agamaToken=deleted; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:00 GMT".to_string(); + headers.insert( + header::SET_COOKIE, + cookie.parse().expect("could not build a valid cookie"), + ); + Ok(headers) +} + +/// Check whether the user is authenticated. +#[utoipa::path(get, path = "/api/auth", responses( + (status = 200, description = "The user is authenticated."), + (status = 400, description = "The user is not authenticated.") +))] +pub async fn session(_claims: TokenClaims) -> Result<(), AuthError> { + Ok(()) +} + +/// Creates the cookie containing the authentication token. +/// +/// It is a session token (no expiration date) so it should be gone +/// when the browser is closed. +/// +/// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie +/// for further information. +/// +/// * `token`: authentication token. +fn auth_cookie_from_token(token: &str) -> String { + format!("agamaToken={}; HttpOnly", &token) +} + +// builds a response tuple for translation redirection +fn redirect_to_file(file: &str) -> (StatusCode, HeaderMap, Body) { + tracing::info!("Redirecting to translation file {}", file); + + let mut response_headers = HeaderMap::new(); + // translation found, redirect to the real file + response_headers.insert( + header::LOCATION, + // if the file exists then the name is a valid value and unwrapping is safe + HeaderValue::from_str(file).unwrap(), + ); + + ( + StatusCode::TEMPORARY_REDIRECT, + response_headers, + Body::empty(), + ) +} + +// handle the /po.js request +// the requested language (locale) is sent in the "agamaLang" HTTP cookie +// this reimplements the Cockpit translation support +pub async fn po(State(state): State, jar: CookieJar) -> impl IntoResponse { + if let Some(cookie) = jar.get("agamaLang") { + tracing::info!("Language cookie: {}", cookie.value()); + // try parsing the cookie + if let Some((lang, region)) = cookie.value().split_once('-') { + // first try language + country + let target_file = format!("po.{}_{}.js", lang, region.to_uppercase()); + if state.public_dir.join(&target_file).exists() { + return redirect_to_file(&target_file); + } else { + // then try the language only + let target_file = format!("po.{}.js", lang); + if state.public_dir.join(&target_file).exists() { + return redirect_to_file(&target_file); + }; + } + } else { + // use the cookie as is + let target_file = format!("po.{}.js", cookie.value()); + if state.public_dir.join(&target_file).exists() { + return redirect_to_file(&target_file); + } + } + } + + tracing::info!("Translation not found"); + // fallback, return empty javascript translations if the language is not supported + let mut response_headers = HeaderMap::new(); + response_headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/javascript"), + ); + + (StatusCode::OK, response_headers, Body::empty()) } diff --git a/rust/agama-server/src/web/progress.rs b/rust/agama-server/src/web/progress.rs deleted file mode 100644 index c892edd8ed..0000000000 --- a/rust/agama-server/src/web/progress.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Implements a mechanism to monitor track service progress. - -use super::event::{Event, EventsSender}; -use agama_lib::progress::{Progress, ProgressPresenter}; -use async_trait::async_trait; - -// let presenter = EventsProgressPresenter::new(socket); -// let mut monitor = ProgressMonitor::new(connection).await.unwrap(); -// _ = monitor.run(presenter).await; - -/// Experimental ProgressPresenter to emit progress events over a Events. -pub struct EventsProgressPresenter(EventsSender); - -impl EventsProgressPresenter { - pub fn new(events: EventsSender) -> Self { - Self(events) - } - - pub async fn report_progress(&mut self, progress: &Progress) { - _ = self.0.send(Event::Progress(progress.clone())) - // _ = self.events.send(Message::Text(payload)).await; - } -} - -#[async_trait] -impl ProgressPresenter for EventsProgressPresenter { - async fn start(&mut self, progress: &Progress) { - self.report_progress(progress).await; - } - - async fn update_main(&mut self, progress: &Progress) { - self.report_progress(progress).await; - } - - async fn update_detail(&mut self, progress: &Progress) { - self.report_progress(progress).await; - } - - async fn finish(&mut self) {} -} diff --git a/rust/agama-server/src/web/service.rs b/rust/agama-server/src/web/service.rs index d2590bc688..31af807388 100644 --- a/rust/agama-server/src/web/service.rs +++ b/rust/agama-server/src/web/service.rs @@ -1,30 +1,55 @@ +use super::http::{login, login_from_query, logout, session}; use super::{auth::TokenClaims, config::ServiceConfig, state::ServiceState, EventsSender}; use axum::{ + body::Body, extract::Request, middleware, - response::IntoResponse, + response::{IntoResponse, Response}, routing::{get, post}, Router, }; -use std::convert::Infallible; +use std::time::Duration; +use std::{ + convert::Infallible, + path::{Path, PathBuf}, +}; use tower::Service; -use tower_http::{compression::CompressionLayer, trace::TraceLayer}; +use tower_http::{compression::CompressionLayer, services::ServeDir, trace::TraceLayer}; +use tracing::Span; +/// Builder for Agama main service. +/// +/// It is responsible for building an axum service which includes: +/// +/// * A static assets directory (`public_dir`). +/// * A websocket at the `/ws` path. +/// * An authentication endpoint at `/auth`. +/// * A 'ping' endpoint at '/ping'. +/// * A number of authenticated services that are added using the `add_service` function. pub struct MainServiceBuilder { config: ServiceConfig, events: EventsSender, - router: Router, + api_router: Router, + public_dir: PathBuf, } impl MainServiceBuilder { - pub fn new(events: EventsSender) -> Self { - let router = Router::new().route("/ws", get(super::ws::ws_handler)); + /// Returns a new service builder. + /// + /// * `events`: channel to send events through the WebSocket. + /// * `public_dir`: path to the public directory. + pub fn new

(events: EventsSender, public_dir: P) -> Self + where + P: AsRef, + { + let api_router = Router::new().route("/ws", get(super::ws::ws_handler)); let config = ServiceConfig::default(); Self { events, - router, + api_router, config, + public_dir: PathBuf::from(public_dir.as_ref()), } } @@ -32,6 +57,10 @@ impl MainServiceBuilder { Self { config, ..self } } + /// Add an authenticated service. + /// + /// * `path`: Path to mount the service under `/api`. + /// * `service`: Service to mount on the given `path`. pub fn add_service(self, path: &str, service: T) -> Self where T: Service + Clone + Send + 'static, @@ -39,7 +68,7 @@ impl MainServiceBuilder { T::Future: Send + 'static, { Self { - router: self.router.nest_service(path, service), + api_router: self.api_router.nest_service(path, service), ..self } } @@ -48,14 +77,36 @@ impl MainServiceBuilder { let state = ServiceState { config: self.config, events: self.events, + public_dir: self.public_dir.clone(), }; - self.router + + let api_router = self + .api_router .route_layer(middleware::from_extractor_with_state::( state.clone(), )) .route("/ping", get(super::http::ping)) - .route("/authenticate", post(super::http::authenticate)) - .layer(TraceLayer::new_for_http()) + .route("/auth", post(login).get(session).delete(logout)); + + tracing::info!("Serving static files from {}", self.public_dir.display()); + let serve = ServeDir::new(self.public_dir).precompressed_gzip(); + + Router::new() + .nest_service("/", serve) + .route("/login", get(login_from_query)) + .route("/po.js", get(super::http::po)) + .nest("/api", api_router) + .layer( + TraceLayer::new_for_http() + .on_request(|request: &Request, _span: &Span| { + tracing::info!("request: {} {}", request.method(), request.uri().path()) + }) + .on_response( + |response: &Response, latency: Duration, _span: &Span| { + tracing::info!("response: {} {:?}", response.status(), latency) + }, + ), + ) .layer(CompressionLayer::new().br(true)) .with_state(state) } diff --git a/rust/agama-server/src/web/state.rs b/rust/agama-server/src/web/state.rs index c35592b8c5..01cdf6f625 100644 --- a/rust/agama-server/src/web/state.rs +++ b/rust/agama-server/src/web/state.rs @@ -1,6 +1,7 @@ //! Implements the web service state. use super::{config::ServiceConfig, EventsSender}; +use std::path::PathBuf; /// Web service state. /// @@ -9,4 +10,5 @@ use super::{config::ServiceConfig, EventsSender}; pub struct ServiceState { pub config: ServiceConfig, pub events: EventsSender, + pub public_dir: PathBuf, } diff --git a/rust/agama-server/tests/l10n.rs b/rust/agama-server/tests/l10n.rs index 75d0e76109..8509776867 100644 --- a/rust/agama-server/tests/l10n.rs +++ b/rust/agama-server/tests/l10n.rs @@ -1,34 +1,39 @@ pub mod common; +use std::error::Error; + use agama_server::l10n::web::l10n_service; use axum::{ body::Body, http::{Request, StatusCode}, Router, }; -use common::body_to_string; +use common::{body_to_string, DBusServer}; use tokio::{sync::broadcast::channel, test}; use tower::ServiceExt; -fn build_service() -> Router { +async fn build_service(dbus: zbus::Connection) -> Router { let (tx, _) = channel(16); - l10n_service(tx) + l10n_service(dbus, tx).await.unwrap() } #[test] -async fn test_get_config() { - let service = build_service(); +async fn test_get_config() -> Result<(), Box> { + let dbus_server = DBusServer::new().start().await?; + let service = build_service(dbus_server.connection()).await; let request = Request::builder() .uri("/config") .body(Body::empty()) .unwrap(); let response = service.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); + Ok(()) } #[test] -async fn test_locales() { - let service = build_service(); +async fn test_locales() -> Result<(), Box> { + let dbus_server = DBusServer::new().start().await?; + let service = build_service(dbus_server.connection()).await; let request = Request::builder() .uri("/locales") .body(Body::empty()) @@ -37,11 +42,13 @@ async fn test_locales() { assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; assert!(body.contains(r#""language":"English""#)); + Ok(()) } #[test] -async fn test_keymaps() { - let service = build_service(); +async fn test_keymaps() -> Result<(), Box> { + let dbus_server = DBusServer::new().start().await?; + let service = build_service(dbus_server.connection()).await; let request = Request::builder() .uri("/keymaps") .body(Body::empty()) @@ -49,12 +56,14 @@ async fn test_keymaps() { let response = service.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""layout":"us""#)); + assert!(body.contains(r#""id":"us""#)); + Ok(()) } #[test] -async fn test_timezones() { - let service = build_service(); +async fn test_timezones() -> Result<(), Box> { + let dbus_server = DBusServer::new().start().await?; + let service = build_service(dbus_server.connection()).await; let request = Request::builder() .uri("/timezones") .body(Body::empty()) @@ -63,4 +72,32 @@ async fn test_timezones() { assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; assert!(body.contains(r#""code":"Atlantic/Canary""#)); + Ok(()) +} + +#[test] +async fn test_set_config_locales() -> Result<(), Box> { + let dbus_server = DBusServer::new().start().await?; + let service = build_service(dbus_server.connection()).await; + + let content = "{\"locales\":[\"es_ES.UTF-8\"]}"; + let body = Body::from(content); + let request = Request::patch("/config") + .header("Content-Type", "application/json") + .body(body)?; + let response = service.clone().oneshot(request).await?; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + // check whether the value changed + let request = Request::get("/config") + .header("Content-Type", "application/json") + .body(Body::empty())?; + let response = service.oneshot(request).await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_string(response.into_body()).await; + assert!(body.contains(r#""locales":["es_ES.UTF-8"]"#)); + + // TODO: check whether the D-Bus value was synchronized + + Ok(()) } diff --git a/rust/agama-server/tests/network.rs b/rust/agama-server/tests/network.rs index e437331d9d..c29411c11d 100644 --- a/rust/agama-server/tests/network.rs +++ b/rust/agama-server/tests/network.rs @@ -8,7 +8,7 @@ use agama_lib::network::{ }; use agama_server::network::{ self, - model::{self, Ipv4Method, Ipv6Method}, + model::{self, GeneralState, Ipv4Method, Ipv6Method, StateConfig}, Adapter, NetworkAdapterError, NetworkService, NetworkState, }; use async_trait::async_trait; @@ -21,7 +21,7 @@ pub struct NetworkTestAdapter(network::NetworkState); #[async_trait] impl Adapter for NetworkTestAdapter { - async fn read(&self) -> Result { + async fn read(&self, _: StateConfig) -> Result { Ok(self.0.clone()) } @@ -34,12 +34,15 @@ impl Adapter for NetworkTestAdapter { async fn test_read_connections() -> Result<(), Box> { let mut server = DBusServer::new().start().await?; + let general_state = GeneralState::default(); + let device = model::Device { name: String::from("eth0"), type_: DeviceType::Ethernet, + ..Default::default() }; let eth0 = model::Connection::new("eth0".to_string(), DeviceType::Ethernet); - let state = NetworkState::new(vec![device], vec![eth0]); + let state = NetworkState::new(general_state, vec![], vec![device], vec![eth0]); let adapter = NetworkTestAdapter(state); NetworkService::start(&server.connection(), adapter).await?; @@ -143,12 +146,14 @@ async fn test_add_bond_connection() -> Result<(), Box> { async fn test_update_connection() -> Result<(), Box> { let mut server = DBusServer::new().start().await?; + let general_state = GeneralState::default(); let device = model::Device { name: String::from("eth0"), type_: DeviceType::Ethernet, + ..Default::default() }; let eth0 = model::Connection::new("eth0".to_string(), DeviceType::Ethernet); - let state = NetworkState::new(vec![device], vec![eth0]); + let state = NetworkState::new(general_state, vec![], vec![device], vec![eth0]); let adapter = NetworkTestAdapter(state); NetworkService::start(&server.connection(), adapter).await?; diff --git a/rust/agama-server/tests/network_service.rs b/rust/agama-server/tests/network_service.rs new file mode 100644 index 0000000000..e2a686dfba --- /dev/null +++ b/rust/agama-server/tests/network_service.rs @@ -0,0 +1,164 @@ +pub mod common; + +use crate::common::DBusServer; +use agama_lib::error::ServiceError; +use agama_lib::network::types::{DeviceType, SSID}; +use agama_server::network::web::network_service; +use agama_server::network::{ + self, + model::{self, AccessPoint, GeneralState, StateConfig}, + Adapter, NetworkAdapterError, NetworkState, +}; + +use async_trait::async_trait; +use axum::http::header; +use axum::{ + body::Body, + http::{Method, Request, StatusCode}, + Router, +}; +use common::body_to_string; +use serde_json::to_string; +use std::error::Error; +use tokio::{sync::broadcast, test}; +use tower::ServiceExt; + +async fn build_state() -> NetworkState { + let general_state = GeneralState::default(); + let device = model::Device { + name: String::from("eth0"), + type_: DeviceType::Ethernet, + ..Default::default() + }; + let eth0 = model::Connection::new("eth0".to_string(), DeviceType::Ethernet); + + NetworkState::new(general_state, vec![], vec![device], vec![eth0]) +} + +async fn build_service(state: NetworkState) -> Result { + let dbus = DBusServer::new().start().await?.connection(); + + let adapter = NetworkTestAdapter(state); + let (tx, _rx) = broadcast::channel(16); + Ok(network_service(dbus, adapter, tx).await?) +} + +#[derive(Default)] +pub struct NetworkTestAdapter(network::NetworkState); + +#[async_trait] +impl Adapter for NetworkTestAdapter { + async fn read(&self, _: StateConfig) -> Result { + Ok(self.0.clone()) + } + + async fn write(&self, _network: &network::NetworkState) -> Result<(), NetworkAdapterError> { + unimplemented!("Not used in tests"); + } +} + +#[test] +async fn test_network_state() -> Result<(), Box> { + let state = build_state().await; + let network_service = build_service(state).await?; + + let request = Request::builder() + .uri("/state") + .method(Method::GET) + .body(Body::empty()) + .unwrap(); + + let response = network_service.oneshot(request).await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_string(response.into_body()).await; + assert!(body.contains(r#""wireless_enabled":false"#)); + Ok(()) +} + +#[test] +async fn test_change_network_state() -> Result<(), Box> { + let mut state = build_state().await; + let network_service = build_service(state.clone()).await?; + state.general_state.wireless_enabled = true; + + let request = Request::builder() + .uri("/state") + .method(Method::PUT) + .header(header::CONTENT_TYPE, "application/json") + .body(to_string(&state.general_state)?) + .unwrap(); + + let response = network_service.oneshot(request).await?; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body(); + let body = body_to_string(body).await; + assert_eq!(body, to_string(&state.general_state)?); + Ok(()) +} + +#[test] +async fn test_network_connections() -> Result<(), Box> { + let state = build_state().await; + let network_service = build_service(state.clone()).await?; + + let request = Request::builder() + .uri("/connections") + .method(Method::GET) + .body(Body::empty()) + .unwrap(); + + let response = network_service.oneshot(request).await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_string(response.into_body()).await; + assert!(body.contains(r#""id":"eth0""#)); + Ok(()) +} + +#[test] +async fn test_network_devices() -> Result<(), Box> { + let state = build_state().await; + let network_service = build_service(state.clone()).await?; + + let request = Request::builder() + .uri("/devices") + .method(Method::GET) + .body(Body::empty()) + .unwrap(); + + let response = network_service.oneshot(request).await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_string(response.into_body()).await; + assert!(body.contains(r#""name":"eth0""#)); + Ok(()) +} + +#[test] +async fn test_network_wifis() -> Result<(), Box> { + let mut state = build_state().await; + state.access_points = vec![ + AccessPoint { + ssid: SSID("AgamaNetwork".as_bytes().into()), + hw_address: "00:11:22:33:44:00".into(), + ..Default::default() + }, + AccessPoint { + ssid: SSID("AgamaNetwork2".as_bytes().into()), + hw_address: "00:11:22:33:44:01".into(), + ..Default::default() + }, + ]; + let network_service = build_service(state.clone()).await?; + + let request = Request::builder() + .uri("/wifi") + .method(Method::GET) + .body(Body::empty()) + .unwrap(); + + let response = network_service.oneshot(request).await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_string(response.into_body()).await; + assert!(body.contains(r#""ssid":"AgamaNetwork""#)); + assert!(body.contains(r#""ssid":"AgamaNetwork2""#)); + Ok(()) +} diff --git a/rust/agama-server/tests/service.rs b/rust/agama-server/tests/service.rs index daa453af02..53e06393b2 100644 --- a/rust/agama-server/tests/service.rs +++ b/rust/agama-server/tests/service.rs @@ -1,33 +1,34 @@ pub mod common; -use agama_server::{ - service, - web::{generate_token, MainServiceBuilder, ServiceConfig}, -}; +use agama_server::web::{generate_token, MainServiceBuilder, ServiceConfig}; use axum::{ body::Body, http::{Method, Request, StatusCode}, response::Response, routing::get, - Router, }; -use common::{body_to_string, DBusServer}; -use std::error::Error; +use common::body_to_string; +use std::{error::Error, path::PathBuf}; use tokio::{sync::broadcast::channel, test}; use tower::ServiceExt; -async fn build_service() -> Router { - let (tx, _) = channel(16); - let server = DBusServer::new().start().await.unwrap(); - service(ServiceConfig::default(), tx, server.connection()) - .await - .unwrap() +fn public_dir() -> PathBuf { + std::env::current_dir().unwrap().join("public") } #[test] async fn test_ping() -> Result<(), Box> { - let web_service = build_service().await; - let request = Request::builder().uri("/ping").body(Body::empty()).unwrap(); + let config = ServiceConfig::default(); + let (tx, _) = channel(16); + let web_service = MainServiceBuilder::new(tx, public_dir()) + .add_service("/protected", get(protected)) + .with_config(config) + .build(); + + let request = Request::builder() + .uri("/api/ping") + .body(Body::empty()) + .unwrap(); let response = web_service.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); @@ -46,13 +47,13 @@ async fn access_protected_route(token: &str, jwt_secret: &str) -> Response { jwt_secret: jwt_secret.to_string(), }; let (tx, _) = channel(16); - let web_service = MainServiceBuilder::new(tx) + let web_service = MainServiceBuilder::new(tx, public_dir()) .add_service("/protected", get(protected)) .with_config(config) .build(); let request = Request::builder() - .uri("/protected") + .uri("/api/protected") .method(Method::GET) .header("Authorization", format!("Bearer {}", token)) .body(Body::empty()) diff --git a/rust/package/agama.changes b/rust/package/agama.changes index ef4e3e9431..7478d31fef 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,23 @@ +------------------------------------------------------------------- +Mon May 6 05:13:54 UTC 2024 - Imobach Gonzalez Sosa + +- Extend the HTTP/JSON API: + - Localization (gh#openSUSE/agama#1047, gh#openSUSE/agama#1120). + - Networking (gh#openSUSE/agama#1064). + - Software (gh#openSUSE/agama#1069). + - Manager service (gh#openSUSE/agama#1089). + - Questions (gh#openSUSE/agama#1091). + - Progress interface (gh#openSUSE/agama#1092). + - Issues interface (gh#openSUSE/agama#1100). + - Users (gh#openSUSE/agama#1117). + - Product registration (gh#openSUSE/agama#1146). +- Add an "agama-web-server" service (gh#openSUSE/agama/1119). +- Fix the generation of the self-signed certificate + (gh#openSUSE/agama#1131). +- Improve agama-server logging (gh#openSUSE/agama#1143). +- Provide frontend translations via the /po.js path + (gh#openSUSE/agama#1126). + ------------------------------------------------------------------- Wed Mar 13 12:42:58 UTC 2024 - Jorik Cronenberg diff --git a/rust/package/agama.spec b/rust/package/agama.spec index 2eeddf579f..828eb5095a 100644 --- a/rust/package/agama.spec +++ b/rust/package/agama.spec @@ -90,8 +90,8 @@ install -D -p -m 644 %{_builddir}/agama/share/agama.pam $RPM_BUILD_ROOT%{_pam_ve install -D -d -m 0755 %{buildroot}%{_datadir}/agama-cli install -m 0644 %{_builddir}/agama/agama-lib/share/profile.schema.json %{buildroot}%{_datadir}/agama-cli install --directory %{buildroot}%{_datadir}/dbus-1/agama-services -install -m 0644 --target-directory=%{buildroot}%{_datadir}/dbus-1/agama-services %{_builddir}/agama/share/*.service - +install -m 0644 --target-directory=%{buildroot}%{_datadir}/dbus-1/agama-services %{_builddir}/agama/share/org.opensuse.Agama1.service +install -D -m 0644 %{_builddir}/agama/share/agama-web-server.service %{buildroot}%{_unitdir}/agama-web-server.service %check PATH=$PWD/share/bin:$PATH @@ -102,11 +102,24 @@ echo $PATH %{cargo_test} %endif +%pre +%service_add_pre agama-web-server.service + +%post +%service_add_post agama-web-server.service + +%preun +%service_del_preun agama-web-server.service + +%postun +%service_del_preun agama-web-server.service + %files %{_bindir}/agama-dbus-server %{_bindir}/agama-web-server %{_datadir}/dbus-1/agama-services %{_pam_vendordir}/agama +%{_unitdir}/agama-web-server.service %files -n agama-cli %{_bindir}/agama diff --git a/rust/share/agama-web-server.service b/rust/share/agama-web-server.service new file mode 100644 index 0000000000..8c701d8675 --- /dev/null +++ b/rust/share/agama-web-server.service @@ -0,0 +1,13 @@ +[Unit] +Description=Agama Web Server +After=network-online.target agama.service + +[Service] +Type=simple +ExecStart=/usr/bin/agama-web-server serve --address :::80 --address2 :::443 --generate-token /run/agama/token +PIDFile=/run/agama/web.pid +User=root +TimeoutStopSec=5 + +[Install] +WantedBy=default.target diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index 6fec2df0f4..5a72a9b989 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -89,7 +89,9 @@ def issues dbus_method(:AddPattern, "in id:s, out result:b") { |p| backend.add_pattern(p) } dbus_method(:RemovePattern, "in id:s, out result:b") { |p| backend.remove_pattern(p) } - dbus_method(:SetUserPatterns, "in ids:as, out wrong:as") { |ids| [backend.assign_patterns(ids)] } + dbus_method(:SetUserPatterns, "in add:as, in remove:as, out wrong:as") do |add, remove| + [backend.assign_patterns(add, remove)] + end dbus_method :ProvisionsSelected, "in Provisions:as, out Result:ab" do |provisions| [provisions.map { |p| backend.provision_selected?(p) }] diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index a6ebf38ff2..139ca59e3a 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -249,16 +249,28 @@ def remove_pattern(id) true end - def assign_patterns(ids) - wrong_patterns = ids.reject { |p| pattern_exist?(p) } + def assign_patterns(add, remove) + wrong_patterns = [add, remove].flatten.reject { |p| pattern_exist?(p) } return wrong_patterns unless wrong_patterns.empty? user_patterns = Yast::PackagesProposal.GetResolvables(PROPOSAL_ID, :pattern) user_patterns.each { |p| Yast::Pkg.ResolvableNeutral(p, :pattern, force = false) } - Yast::PackagesProposal.SetResolvables(PROPOSAL_ID, :pattern, ids) - ids.each { |p| Yast::Pkg.ResolvableInstall(p, :pattern) } - logger.info "Setting patterns to #{ids.inspect}" + logger.info "Adding patterns: #{add.join(", ")}. Removing patterns: #{remove.join(",")}." + + Yast::PackagesProposal.SetResolvables(PROPOSAL_ID, :pattern, add) + add.each do |id| + res = Yast::Pkg.ResolvableInstall(id, :pattern) + logger.info "Adding pattern #{id}: #{res.inspect}" + end + + remove.each do |id| + res = Yast::Pkg.ResolvableNeutral(id, :pattern, force = false) + logger.info "Removing pattern #{id}: #{res.inspect}" + Yast::PackagesProposal.RemoveResolvables(PROPOSAL_ID, :pattern, [id]) + end + proposal.solve_dependencies + selected_patterns_changed [] diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 5ba52de817..adf9f9709d 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Mon May 6 05:13:11 UTC 2024 - Imobach Gonzalez Sosa + +- Remove the dependency on cockpit.socket (gh#openSUSE/agama#1119) + ------------------------------------------------------------------- Thu Apr 25 13:40:06 UTC 2024 - Ancor Gonzalez Sosa diff --git a/service/share/agama.service b/service/share/agama.service index bae87c7820..f9b9bcfeb3 100644 --- a/service/share/agama.service +++ b/service/share/agama.service @@ -1,6 +1,5 @@ [Unit] Description=Agama Installer Service -Requires=cockpit.socket After=network-online.target [Service] diff --git a/web/.eslintrc.json b/web/.eslintrc.json index d86feed6d9..1e82edd3c9 100644 --- a/web/.eslintrc.json +++ b/web/.eslintrc.json @@ -36,7 +36,7 @@ "no-var": "error", "no-multi-str": "off", "no-use-before-define": "off", - "@typescript-eslint/no-unused-vars": ["warn", { "ignoreRestSiblings": true }], + "@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-use-before-define": "warn", "@typescript-eslint/ban-ts-comment": "off", "lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }], @@ -78,7 +78,6 @@ ], "globals": { "require": false, - "module": false, - "COCKPIT_TARGET_URL": "readonly" + "module": false } } diff --git a/web/Makefile b/web/Makefile deleted file mode 100644 index 07b3cb7f84..0000000000 --- a/web/Makefile +++ /dev/null @@ -1,200 +0,0 @@ -# extract name from package.json -PACKAGE_NAME := $(shell awk '/"name":/ {gsub(/[",]/, "", $$2); print $$2}' package.json) -RPM_NAME := cockpit-$(PACKAGE_NAME) -VERSION := $(shell T=$$(git describe 2>/dev/null) || T=1; echo $$T | tr '-' '.') -ifeq ($(TEST_OS),) -TEST_OS = centos-8-stream -endif -export TEST_OS -TARFILE=$(RPM_NAME)-$(VERSION).tar.xz -NODE_CACHE=$(RPM_NAME)-node-$(VERSION).tar.xz -APPSTREAMFILE=org.opensuse.$(PACKAGE_NAME).metainfo.xml -VM_IMAGE=$(CURDIR)/test/images/$(TEST_OS) -# stamp file to check if/when npm install ran -NODE_MODULES_TEST=node_modules -# one example file in dist/ from webpack to check if that already ran -WEBPACK_TEST=dist/manifest.json -# one example file in src/lib to check if it was already checked out -LIB_TEST=src/lib/cockpit-po-plugin.js -# common arguments for tar, mostly to make the generated tarballs reproducible -TAR_ARGS = --sort=name --mtime "@$(shell git show --no-patch --format='%at')" --mode=go=rX,u+rw,a-s --numeric-owner --owner=0 --group=0 - -all: $(WEBPACK_TEST) - -# -# i18n -# - -LINGUAS=$(basename $(notdir $(wildcard po/*.po))) - -po/$(PACKAGE_NAME).js.pot: - xgettext --default-domain=$(PACKAGE_NAME) --output=$@ --language=C --keyword= \ - --keyword=_:1,1t --keyword=_:1c,2,2t --keyword=C_:1c,2 \ - --keyword=N_ --keyword=NC_:1c,2 \ - --keyword=gettext:1,1t --keyword=gettext:1c,2,2t \ - --keyword=ngettext:1,2,3t --keyword=ngettext:1c,2,3,4t \ - --keyword=gettextCatalog.getString:1,3c --keyword=gettextCatalog.getPlural:2,3,4c \ - --from-code=UTF-8 $$(find src/ \( -name '*.js' -o -name '*.jsx' \) \! -path 'src/lib/*') - -po/$(PACKAGE_NAME).html.pot: $(NODE_MODULES_TEST) - po/html2po -o $@ $$(find src -name '*.html' \! -path 'src/lib/*') - -po/$(PACKAGE_NAME).manifest.pot: $(NODE_MODULES_TEST) - po/manifest2po src/manifest.json -o $@ - -po/$(PACKAGE_NAME).metainfo.pot: $(APPSTREAMFILE) - xgettext --default-domain=$(PACKAGE_NAME) --output=$@ $< - -po/$(PACKAGE_NAME).pot: po/$(PACKAGE_NAME).html.pot po/$(PACKAGE_NAME).js.pot po/$(PACKAGE_NAME).manifest.pot po/$(PACKAGE_NAME).metainfo.pot - msgcat --sort-output --output-file=$@ $^ - -po/LINGUAS: - echo $(LINGUAS) | tr ' ' '\n' > $@ - -# Update translations against current PO template -update-po: po/$(PACKAGE_NAME).pot - for lang in $(LINGUAS); do \ - msgmerge --output-file=po/$$lang.po po/$$lang.po $<; \ - done - -# -# Build/Install/dist -# - -%.spec: packaging/%.spec.in - sed -e 's/%{VERSION}/$(VERSION)/g' $< > $@ - -$(WEBPACK_TEST): $(NODE_MODULES_TEST) $(LIB_TEST) $(shell find src/ -type f) package.json webpack.config.js - NODE_ENV=$(NODE_ENV) node_modules/.bin/webpack - -watch: - NODE_ENV=$(NODE_ENV) node_modules/.bin/webpack --watch - -clean: - rm -rf dist/ - rm -f po/LINGUAS - -clean_all: clean - rm -rf node_modules/ - -install: $(WEBPACK_TEST) po/LINGUAS - mkdir -p $(DESTDIR)/usr/share/cockpit/$(PACKAGE_NAME) - cp -r dist/* $(DESTDIR)/usr/share/cockpit/$(PACKAGE_NAME) - mkdir -p $(DESTDIR)/usr/share/metainfo/ - msgfmt --xml -d po \ - --template $(APPSTREAMFILE) \ - -o $(DESTDIR)/usr/share/metainfo/$(APPSTREAMFILE) - -# this requires a built source tree and avoids having to install anything system-wide -devel-install: $(WEBPACK_TEST) - mkdir -p ~/.local/share/cockpit - rm -f ~/.local/share/cockpit/$(PACKAGE_NAME) - ln -s `pwd`/dist ~/.local/share/cockpit/$(PACKAGE_NAME) - -# assumes that there was symlink set up using the above devel-install target, -# and removes it -devel-uninstall: - rm -f ~/.local/share/cockpit/$(PACKAGE_NAME) - -print-version: - @echo "$(VERSION)" - -dist: $(TARFILE) - @ls -1 $(TARFILE) - -# when building a distribution tarball, call webpack with a 'production' environment -# we don't ship node_modules for license and compactness reasons; we ship a -# pre-built dist/ (so it's not necessary) and ship packge-lock.json (so that -# node_modules/ can be reconstructed if necessary) -$(TARFILE): export NODE_ENV=production -$(TARFILE): $(WEBPACK_TEST) - if type appstream-util >/dev/null 2>&1; then appstream-util validate-relax --nonet *.metainfo.xml; fi - touch -r package.json $(NODE_MODULES_TEST) - touch dist/* - tar --xz $(TAR_ARGS) -cf $(TARFILE) --transform 's,^,$(RPM_NAME)/,' \ - --exclude node_modules \ - $$(git ls-files) src/lib package-lock.json dist/ - -$(NODE_CACHE): $(NODE_MODULES_TEST) - tar --xz $(TAR_ARGS) -cf $@ node_modules - -node-cache: $(NODE_CACHE) - -# convenience target for developers -srpm: $(TARFILE) $(NODE_CACHE) - rpmbuild -bs \ - --define "_sourcedir `pwd`" \ - --define "_srcrpmdir `pwd`" - -# convenience target for developers -rpm: $(TARFILE) $(NODE_CACHE) - mkdir -p "`pwd`/output" - mkdir -p "`pwd`/rpmbuild" - rpmbuild -bb \ - --define "_sourcedir `pwd`" \ - --define "_specdir `pwd`" \ - --define "_builddir `pwd`/rpmbuild" \ - --define "_srcrpmdir `pwd`" \ - --define "_rpmdir `pwd`/output" \ - --define "_buildrootdir `pwd`/build" - find `pwd`/output -name '*.rpm' -printf '%f\n' -exec mv {} . \; - rm -r "`pwd`/rpmbuild" - rm -r "`pwd`/output" "`pwd`/build" - -# build a VM with locally built distro pkgs installed -# disable networking, VM images have mock/pbuilder with the common build dependencies pre-installed -$(VM_IMAGE): $(TARFILE) $(NODE_CACHE) bots test/vm.install - bots/image-customize --no-network --fresh \ - --upload $(NODE_CACHE):/var/tmp/ --build $(TARFILE) \ - --script $(CURDIR)/test/vm.install $(TEST_OS) - -# convenience target for the above -vm: $(VM_IMAGE) - echo $(VM_IMAGE) - -# convenience target to print the filename of the test image -print-vm: - echo $(VM_IMAGE) - -# convenience target to setup all the bits needed for the integration tests -# without actually running them -prepare-check: $(NODE_MODULES_TEST) $(VM_IMAGE) test/common - -# run the browser integration tests; skip check for SELinux denials -# this will run all tests/check-* and format them as TAP -check: prepare-check - TEST_AUDIT_NO_SELINUX=1 test/common/run-tests - -# checkout Cockpit's bots for standard test VM images and API to launch them -# must be from main, as only that has current and existing images; but testvm.py API is stable -# support CI testing against a bots change -bots: - git clone --quiet --reference-if-able $${XDG_CACHE_HOME:-$$HOME/.cache}/cockpit-project/bots https://github.com/cockpit-project/bots.git - if [ -n "$$COCKPIT_BOTS_REF" ]; then git -C bots fetch --quiet --depth=1 origin "$$COCKPIT_BOTS_REF"; git -C bots checkout --quiet FETCH_HEAD; fi - @echo "checked out bots/ ref $$(git -C bots rev-parse HEAD)" - -# checkout Cockpit's test API; this has no API stability guarantee, so check out a stable tag -# when you start a new project, use the latest release, and update it from time to time -test/common: - flock Makefile sh -ec '\ - git fetch --depth=1 https://github.com/cockpit-project/cockpit.git 309; \ - git checkout --force FETCH_HEAD -- test/common; \ - git reset test/common' - -# checkout Cockpit's PF/React/build library; again this has no API stability guarantee, so check out a stable tag -# TODO: replace the commit with the tag 309 once it is released, which includes cockpit.js as a ES6 module in lib/. -$(LIB_TEST): - flock Makefile sh -ec '\ - git fetch --depth=1 https://github.com/cockpit-project/cockpit.git 309; \ - git checkout --force FETCH_HEAD -- ../pkg/lib; \ - git reset -- ../pkg/lib' - mv ../pkg/lib src/ && rmdir ../pkg - -# run 'npm install' if node_modules is missing -# or whenever package.json changes afterwards -$(NODE_MODULES_TEST): package.json - # unset NODE_ENV, skips devDependencies otherwise - env -u NODE_ENV npm install - env -u NODE_ENV npm prune - -.PHONY: all clean install devel-install print-version dist node-cache rpm check vm update-po print-vm devel-uninstall diff --git a/web/README.md b/web/README.md index d981a61d77..24f3db9d1c 100644 --- a/web/README.md +++ b/web/README.md @@ -1,45 +1,14 @@ -# Agama Web-Based UI +# Agama Web UI -This Cockpit modules offers a UI to the [Agama service](file:../service). The code is based on -[Cockpit's Starter Kit -(b2379f7)](https://github.com/cockpit-project/starter-kit/tree/b2379f78e203aab0028d8548b39f5f0bd2b27d2a). +The Agama web user interface is a React-based application that offers a user +interface to the [Agama service](file:../service). ## Development -There are basically two ways how to develop the Agama fronted. You can -override the original Cockpit plugins with your own code in your `$HOME` directory -or you can run a development server which works as a proxy and sends the Cockpit -requests to a real Cockpit server. - -The advantage of using the development server is that you can use the -[Hot Module Replacement](https://webpack.js.org/concepts/hot-module-replacement/) -feature for automatically updating the code and stylesheet in the browser -without reloading the page. - -### Overriding the Cockpit Plugin - -Cockpit searches for modules in the `$HOME/.local/share/cockpit` directory of the logged in user, -which is really handy when working on a module. To make the module available to Cockpit, you can -link your build folder (`dist`) or just rely on the `devel-install` task: - -``` - make devel-install -``` - -Then you can visit the Agama module through the following URL: - -http://localhost:9090/cockpit/@localhost/agama/index.html. - -Bear in mind that if something goes wrong while building the application (e.g., the linter fails), -the link will not be created. - -To automatically rebuild the sources after any change you can run - -``` - npm run watch -``` - -*But do not forget that you have to reload the code in your browser manually after each change!* +The easiest way to work on the Agama Web UI is to use the development server. +The advantage is that you can use the [Hot Module Replacement] (https:// +webpack.js.org/concepts/hot-module-replacement/) feature for automatically +updating the code and stylesheet in the browser without reloading the page. ### Using a development server @@ -52,28 +21,48 @@ use this command: The extra `--open` option automatically opens the server page in your default web browser. In this case the server will use the `https://localhost:8080` URL -and expects a running Cockpit instance at `https://localhost:9090`. - -At the first start the development server generates a self-signed SSL -certificate, you have to accept it in the browser. The certificate is saved to -disk and is used in the next runs so you do not have to accept it again. +and expects a running `agama-web-server` at `https://localhost:9090`. This can work also remotely, with a Agama instance running in a different machine (a virtual machine as well). In that case run ``` - COCKPIT_TARGET= npm run server -- --open + AGAMA_SERVER=https://: npm run server -- --open +``` + +Where `AGAMA_SERVER` is the IP address, the hostname or the full URL of the +running Agama server instance. This is especially useful if you use the Live ISO +which does not contain any development tools, you can develop the web frontend +easily from your workstation. + +Example of running from different machine: + +``` + # backend machine + # using ip of machine instead of localhost is important to be network accessible + # second address is needed for SSL which is mandatory for remote access + agama-web-server serve --address :::3000 --address2 :::443 + + # frontend machine + # ESLINT=0 is useful to ignore linter problems during development + ESLINT=0 AGAMA_SERVER=https://10.100.1.1:3000 npm run server ``` -Where `COCKPIT_TARGET` is the IP address or hostname of the running Agama -instance. This is especially useful if you use the Live ISO which does not contain -any development tools, you can develop the web frontend easily from your workstation. +### Debugging Hints + +There are several places to look when something does not work and requires debugging. +The first place is the browser's console which can give +some hints. The second location to check for errors or warnings is output of `npm run server` +where you can find issues when communicating with the backend. And last but on least is +journal on backend machine where is logged backend activity `journalctl -b`. +If the journal does not contain the required info, you can inspect the D-Bus communication +which can give hint about data flow. Command is `busctl monitor --address unix:path=/run/agama/bus` ### Special Environment Variables -`COCKPIT_TARGET` - When running the development server set up a proxy to the -specified Cockpit server. See the [using a development -server](#using-a-development-server) section above. +`AGAMA_SERVER` - When running the development server set up a proxy to +the specified Agama web server. See the [using a development server] +(#using-a-development-server) section above. `LOCAL_CONNECTION` - Force behaving as in a local connection, useful for development or testing some Agama features. For example the keyboard layout @@ -112,7 +101,6 @@ you want a JavaScript file to be type-checked, please add a `// @ts-check` comme ### Links -- [Cockpit developer documentation](https://cockpit-project.org/guide/latest/development) - [Webpack documentation](https://webpack.js.org/configuration/) - [PatternFly documentation](https://www.patternfly.org) - [Material Symbols (aka icons)](https://fonts.google.com/icons) diff --git a/web/jest.config.js b/web/jest.config.js index 87ae020f0d..18ded77fa3 100644 --- a/web/jest.config.js +++ b/web/jest.config.js @@ -67,7 +67,6 @@ module.exports = { // A set of global variables that need to be available in all test environments globals: { - COCKPIT_TARGET_URL: "https://localhost:9090", }, // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. diff --git a/web/package/_service b/web/package/_service index 6d9bafb227..b6815b5ab4 100644 --- a/web/package/_service +++ b/web/package/_service @@ -8,8 +8,8 @@ web enable package-lock.json - package/cockpit-agama.changes - package/cockpit-agama.spec + package/agama-web-ui.changes + package/agama-web-ui.spec node_modules.obscpio diff --git a/web/package/cockpit-agama.changes b/web/package/agama-web-ui.changes similarity index 97% rename from web/package/cockpit-agama.changes rename to web/package/agama-web-ui.changes index 77b9b3ee55..94900f9a2e 100644 --- a/web/package/cockpit-agama.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,21 @@ +------------------------------------------------------------------- +Mon May 6 05:41:15 UTC 2024 - Imobach Gonzalez Sosa + +- Adapt to the new HTTP/JSON API: + - Authentication (gh#openSUSE/agama#1080). + - Localization (gh#openSUSE/agama#1094). + - Networking (gh#openSUSE/agama#1116). + - Software (gh#openSUSE/agama#1094 and gh#openSUSE/agama#1112). + - Manager service (gh#openSUSE/agama#1132). + - Questions (gh#openSUSE/agama#1132). + - Progress interface (gh#openSUSE/agama#1103). + - Issues interface (gh#openSUSE/agama#1100). + - Product registration (gh#openSUSE/agama#1146). + - Users (gh#openSUSE/agama#1117). +- Adapt webpack to work with the new architecture + (gh#openSUSE/agama#1061, gh#openSUSE/agama#1074 and + gh#openSUSE/agama#1130). + ------------------------------------------------------------------- Fri May 3 09:45:36 UTC 2024 - José Iván López González diff --git a/web/package/cockpit-agama.spec b/web/package/agama-web-ui.spec similarity index 76% rename from web/package/cockpit-agama.spec rename to web/package/agama-web-ui.spec index 382cdeb1eb..39d03cc141 100644 --- a/web/package/cockpit-agama.spec +++ b/web/package/agama-web-ui.spec @@ -16,10 +16,10 @@ # -Name: cockpit-agama +Name: agama-web-ui Version: 0 Release: 0 -Summary: Cockpit module for Agama +Summary: Web UI for Agama installer License: GPL-2.0-only URL: https://github.com/openSUSE/agama # source_validator insists that if obscpio has no version then @@ -30,14 +30,11 @@ Source11: node_modules.spec.inc Source12: node_modules.sums %include %_sourcedir/node_modules.spec.inc BuildArch: noarch -Requires: cockpit -BuildRequires: cockpit -BuildRequires: cockpit-devel >= 243 BuildRequires: local-npm-registry BuildRequires: appstream-glib %description -Cockpit module for the experimental Agama installer. +Agama web UI for the experimental Agama installer. %prep %autosetup -p1 -n agama @@ -45,18 +42,16 @@ rm -f package-lock.json local-npm-registry %{_sourcedir} install --with=dev --legacy-peer-deps || ( find ~/.npm/_logs -name '*-debug.log' -print0 | xargs -0 cat; false) %build -# cp -r %{_datadir}/cockpit/devel/lib src/lib NODE_ENV="production" npm run build %install -%make_install - -%check -appstream-util validate-relax --nonet %{buildroot}/%{_datadir}/metainfo/* +install -D -m 0644 --target-directory=%{buildroot}%{_datadir}/agama/web_ui %{_builddir}/agama/dist/*.{gz,html,js,json,map,svg} +install -D -m 0644 --target-directory=%{buildroot}%{_datadir}/agama/web_ui/fonts %{_builddir}/agama/dist/fonts/*.woff? %files %doc README.md -%{_datadir}/cockpit -%{_datadir}/metainfo/* +%{_datadir}/agama +%{_datadir}/agama/web_ui +%{_datadir}/agama/web_ui/fonts %changelog diff --git a/web/src/App.jsx b/web/src/App.jsx index 8a333563a3..a3798d43f7 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -24,7 +24,7 @@ import { Outlet } from "react-router-dom"; import { useInstallerClient, useInstallerClientStatus } from "~/context/installer"; import { useProduct } from "./context/product"; -import { STARTUP, INSTALL } from "~/client/phase"; +import { INSTALL, STARTUP } from "~/client/phase"; import { BUSY } from "~/client/status"; import { DBusError, If, Installation } from "~/components/core"; diff --git a/web/src/DevServerWrapper.jsx b/web/src/DevServerWrapper.jsx deleted file mode 100644 index 456b9c24f1..0000000000 --- a/web/src/DevServerWrapper.jsx +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright (c) [2023] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React, { useEffect, useRef, useState } from "react"; -import { - Button, - Text, - EmptyState, EmptyStateBody, EmptyStateFooter, EmptyStateHeader, EmptyStateIcon -} from "@patternfly/react-core"; -import { Center, Icon, Loading } from "~/components/layout"; -import { _ } from "~/i18n"; - -// path to any internal Cockpit component to force displaying the login dialog -const loginPath = "/cockpit/@localhost/system/terminal.html"; -// id of the password field in the login dialog -const loginId = "login-password-input"; - -const ErrorIcon = () => ; - -/** - * This is a helper wrapper used in the development server only. It displays - * the Cockpit login page if the user is not authenticated. After successful - * authentication the Agama page is displayed. - * - * @param {React.ReactNode} [props.children] - content to display within the wrapper - * -*/ -export default function DevServerWrapper({ children }) { - const [isLoading, setIsLoading] = useState(true); - const [isAuthenticated, setIsAuthenticated] = useState(null); - const [isError, setIsError] = useState(false); - const iframeRef = useRef(null); - - useEffect(() => { - if (!isLoading) return; - - // get the current login state by querying the "/cockpit/login" path - const xhr = new XMLHttpRequest(); - xhr.ontimeout = () => { - setIsError(true); - setIsLoading(false); - }; - xhr.onloadend = () => { - // 200 = OK - if (xhr.status === 200) - setIsAuthenticated(true); - // 401 = Authentication failed - else if (xhr.status === 401) - setIsAuthenticated(false); - else - setIsError(true); - - setIsLoading(false); - }; - xhr.timeout = 5000; - xhr.open("GET", "/cockpit/login"); - xhr.send(); - }, [isLoading]); - - if (isLoading) return ; - - if (isError) { - // TRANSLATORS: error message, %s is replaced by the server URL - const [msg1, msg2] = _("The server at %s is not reachable.").split("%s"); - return ( -

- - } - /> - - - {msg1} {" "} - - {" "} {msg2} - - - - - - -
- ); - } - - if (isAuthenticated) { - // just display the wrapped content - return children; - } else { - // handle updating the iframe with the login form - const onFrameLoad = () => { - // have a full screen login form - document.getElementById("root").style.maxInlineSize = "none"; - - const passwordInput = iframeRef.current.contentWindow.document.getElementById(loginId); - // reload the window so the manifests.js file referenced from the - // index.html file is also loaded again - if (!passwordInput) window.location.reload(); - }; - - return