diff --git a/CHANGELOG.md b/CHANGELOG.md index aa83a58..c487432 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +2025/02/06 Version 4.0.0-1 +-------------------------- +- `plusdeckctl` connects to the system bus by default +- `plusdeckctl --user` will connect to the user bus +- `plusdeckd` loads local config by default unless run as root +- systemd unit fixes + - Requires `dbus.socket`, starts after `dbus.socket` + - Wanted by `multiuser.target` +- dbus access policy + - Ownership and allowed destination for root + - Allowed destination for `plusdeck` user + 2025/02/04 Version 3.0.0-3 -------------------------- - Fix install path of systemd unit diff --git a/dbus/org.jfhbrook.plusdeck.conf b/dbus/org.jfhbrook.plusdeck.conf new file mode 100644 index 0000000..4eb51d5 --- /dev/null +++ b/dbus/org.jfhbrook.plusdeck.conf @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/docs/dbus.md b/docs/dbus.md index c18277b..ade95ca 100644 --- a/docs/dbus.md +++ b/docs/dbus.md @@ -4,26 +4,18 @@ The `plusdeck` library includes a DBus service and client. This service allows f For information on the API, visit [the API docs for `plusdeck.dbus`](./api/plusdeck.dbus.md). -## plusdeckd +## Starting the Dbus Service -The DBus service can be launched using `plusdeckd`: +`plusdeck` ships with a systemd unit that configures the service as a Dbus service. To set up the service, run: ```sh -$ plusdeckd --help -Usage: plusdeckd [OPTIONS] - - Expose the Plus Deck 2C PC Cassette Deck as a DBus service. - -Options: - -C, --config-file PATH A path to a config file - --log-level [DEBUG|INFO|WARNING|ERROR|CRITICAL] - Set the log level - --help Show this message and exit. +sudo systemctl enable plusdeck +sudo systemctl start plusdeck # optional ``` -In most cases, this can be called without arguments. By default, `plusdeckd` will use the global config file at `/etc/plusdeck.yml`. +This unit will start on the `system` bus, under the root user. -## plusdeckctl +## Running `plusdeckctl` Assuming `plusdeckd` is running, you may interact with the service using `plusdeckctl`: @@ -37,6 +29,7 @@ Options: --log-level [DEBUG|INFO|WARNING|ERROR|CRITICAL] Set the log level --output [text|json] Output either human-friendly text or JSON + --user / --no-user Connect to the user bus --help Show this message and exit. Commands: @@ -47,8 +40,102 @@ Commands: pause Pause the tape play Play a tape rewind Rewind a tape + state Get the current state stop Stop the tape subscribe Subscribe to state changes ``` -The interface is *very* similar to the vanilla `plusdeck` CLI. Note, however, that the config commands are slightly different. `plusdeckd` doesn't watch or reload the configuration in-place, so `plusdeckctl` will instead show the drift between the relevant config file and the loaded configuration. To synchronize the configuration, restart `plusdeckd` - if running under systemd, this will be `systemctl restart plusdeck` or similar. +The interface is similar to the vanilla `plusdeck` CLI. However, there are a few differences: + +1. By default, `plusdeckctl` will connect to the `system` bus. To connect to the local bus, set the `--user` flag. +2. Configuration commands do not reload `plusdeckctl`'s configuration. Instead, they will update the relevant config file, and show the differences between the file config and the service's loaded config. +3. If the config file isn't owned by the user, `plusdeckctl` will attempt to run editing commands with `sudo`. + +## Dbus Access Policies + +**NOTE: Full access for `plusdeck` group access is an area of active development. This feature does not work - at least, on Fedora.** To follow along, view [this StackExchange post](https://unix.stackexchange.com/questions/790750/dbus-policy-that-allows-group-to-access-system-service). and this [Fedora discussion post](https://discussion.fedoraproject.org/t/dbus-policy-that-allows-group-to-access-system-service/144265). + +When running services under the `system` bus, care must be taken to manage access policies. Dbus does this primarily with [an XML-based policy language](https://dbus.freedesktop.org/doc/dbus-daemon.1.html). Systemd additionally manages access to privileged methods, seemingly with the intent of delegating to polkit. + +By default, Dbus is configured with the following policies: + +* The root user may own the bus, and send and receive messages from `org.jfhbrook.plusdeck` +* Users in the `plusdeck` Unix group may additionally send and receive messages from `org.jfhbrook.plusdeck` + +This means that, if the service is running, `sudo plusdeckctl` commands should always work; and that if your user is in the `plusdeck` Unix group, Dbus will allow for unprivileged `plusdeckctl` commands as well. You can create this group and add yourself to it by running: + +```bash +sudo groupadd plusdeck +sudo usermod -a -G plusdeck "${USER}" +``` + +### Polkit + +**NOTE: The Polkit policies have not been shown to work at this time.** + +Prototype Polkit policies/rules may be found in the `./polkit` folder. + +## Running `plusdeckd` Directly + +The DBus service can be launched directly using `plusdeckd`: + +```sh +$ plusdeckd --help +Usage: plusdeckd [OPTIONS] + + Expose the Plus Deck 2C PC Cassette Deck as a DBus service. + +Options: + -C, --config-file PATH A path to a config file + --log-level [DEBUG|INFO|WARNING|ERROR|CRITICAL] + Set the log level + --help Show this message and exit. +``` + +In most cases, this can be called without arguments. By default, `plusdeckd` will listen on the `system` bus and load the global config file (`/etc/plusdeck.yml`) if launched as root; and otherwise listen on the `user` bus and load the user's config file (`~/.config/plusdeck.yml`). + +## Debugging Dbus + +### Default Dbus Configuration + +The default Dbus configuration is at `/usr/share/dbus-1/system.conf`. It may be useful to refer to this file when trying to understand what default access policies are being applied. + +### Monitoring Dbus + +The best tool for debugging Dbus seems to be [dbus-monitor](https://dbus.freedesktop.org/doc/dbus-monitor.1.html). To follow system bus messages, run: + +```sh +sudo dbus-monitor --system +``` + +### Viewing Dbus Logs + +You can review recent logs by checking the status of the `dbus` unit: + +```sh +sudo systemctl status dbus +``` + +### Viewing the Dbus Interface + +I have a just task for that: + +```sh +just get-dbus-iface +``` + +### Debugging SELinux + +While I haven't seen this to be the case, it seems theoretically possible for SELinux to block access to Dbus. + +You should be able to see access denials due to SELinux by running either: + +```sh +sudo ausearch -ts recent +``` + +or: + +```sh +sudo tail -f /var/log/audit/audit.log +``` diff --git a/justfile b/justfile index d560cec..6657675 100644 --- a/justfile +++ b/justfile @@ -104,6 +104,27 @@ tox: clean-tox: rm -rf .tox +# Install systemd service files and dbus config for development purposes +install-service: + sudo install -p -D -m 0644 systemd/plusdeck.service /usr/lib/systemd/system/plusdeck.service + sudo install -p -D -m 0644 dbus/org.jfhbrook.plusdeck.conf /usr/share/dbus-1/system.d/org.jfhbrook.plusdeck.conf + +install-polkit-config: + sudo install -p -D -m 0644 polkit/org.jfhbrook.plusdeck.policy /usr/share/polkit-1/actions/org.jfhbrook.plusdeck.policy + sudo install -p -D -m 0644 polkit/org.jfhbrook.plusdeck.rules /usr/share/polkit-1/rules.d/org.jfhbrook.plusdeck.rules + +remove-polkit-config: + sudo rm -f /usr/share/polkit-1/actions/org.jfhbrook.plusdeck.policy + sudo rm -f /usr/share/polkit-1/rules.d/org.jfhbrook.plusdeck.rules + +# Pull the plusdeck service's logs with journalctl +service-logs: + journalctl -xeu plusdeck.service + +# Fetch the dbus interface for the live service from dbus +get-dbus-iface: + ./scripts/get-dbus-iface.sh + # # Shell and console # @@ -130,10 +151,6 @@ build-docs: # Package publishing # -# -# Package publishing -# - # Build the package build: uv build diff --git a/plusdeck.spec b/plusdeck.spec index 1154411..d7c1c88 100644 --- a/plusdeck.spec +++ b/plusdeck.spec @@ -1,6 +1,6 @@ Name: plusdeck -Version: 3.0.0 -Release: 3 +Version: 4.0.0 +Release: 1 License: MPL-2.0 Summary: Serial client and Linux service for Plus Deck 2C PC Cassette Deck @@ -9,6 +9,7 @@ Source0: %{name}-%{version}.tar.gz BuildArch: noarch Requires: python-plusdeck +Requires: python-sdbus %description @@ -24,13 +25,14 @@ tar -xzf %{SOURCE0} %install mkdir -p %{buildroot}%{_prefix}/lib/systemd/system install -p -D -m 0644 systemd/plusdeck.service %{buildroot}%{_prefix}/lib/systemd/system/plusdeck.service - +install -p -D -m 0644 dbus/org.jfhbrook.plusdeck.conf %{buildroot}%{_prefix}/share/dbus-1/system.d/org.jfhbrook.plusdeck.conf %check %files %{_prefix}/lib/systemd/system/plusdeck.service +%{_prefix}/share/dbus-1/system.d/org.jfhbrook.plusdeck.conf %changelog * Thu Feb 06 2025 Josh Holbrook 3.0.0-3 diff --git a/plusdeck.spec.tmpl b/plusdeck.spec.tmpl index 75c9bf2..0ccbdfb 100644 --- a/plusdeck.spec.tmpl +++ b/plusdeck.spec.tmpl @@ -9,6 +9,7 @@ Source0: %{name}-%{version}.tar.gz BuildArch: noarch Requires: python-plusdeck +Requires: python-sdbus %description @@ -24,13 +25,14 @@ tar -xzf %{SOURCE0} %install mkdir -p %{buildroot}%{_prefix}/lib/systemd/system install -p -D -m 0644 systemd/plusdeck.service %{buildroot}%{_prefix}/lib/systemd/system/plusdeck.service - +install -p -D -m 0644 dbus/org.jfhbrook.plusdeck.conf %{buildroot}%{_prefix}/share/dbus-1/system.d/org.jfhbrook.plusdeck.conf %check %files %{_prefix}/lib/systemd/system/plusdeck.service +%{_prefix}/share/dbus-1/system.d/org.jfhbrook.plusdeck.conf %changelog {{ .Env.CHANGELOG }} diff --git a/plusdeck/dbus/client.py b/plusdeck/dbus/client.py index 4ffe02a..a199e4b 100644 --- a/plusdeck/dbus/client.py +++ b/plusdeck/dbus/client.py @@ -12,6 +12,7 @@ from unittest.mock import Mock import click +from sdbus import sd_bus_open_system, sd_bus_open_user, SdBus from plusdeck.cli import async_command, AsyncCommand, echo, LogLevel, OutputMode, STATE from plusdeck.client import State @@ -27,12 +28,12 @@ class DbusClient(DbusInterface): A DBus client for the Plus Deck 2C PC Cassette Deck. """ - def __init__(self: Self) -> None: + def __init__(self: Self, bus: Optional[SdBus] = None) -> None: client = Mock(name="client", side_effect=NotImplementedError("client")) self.subscribe = Mock(name="client.subscribe") - super().__init__("", client) + super().__init__(client) - cast(Any, self)._proxify(DBUS_NAME, "/") + cast(Any, self)._proxify(DBUS_NAME, "/", bus=bus) async def staged_config(self: Self) -> StagedConfig: """ @@ -54,6 +55,7 @@ class Obj: client: DbusClient log_level: LogLevel output: OutputMode + user: bool def pass_config(fn: AsyncCommand) -> AsyncCommand: @@ -146,11 +148,12 @@ def warn_dirty() -> None: default="text", help="Output either human-friendly text or JSON", ) +@click.option( + "--user/--no-user", type=bool, default=False, help="Connect to the user bus" +) @click.pass_context def main( - ctx: click.Context, - log_level: LogLevel, - output: OutputMode, + ctx: click.Context, log_level: LogLevel, output: OutputMode, user: bool ) -> None: """ Control your Plus Deck 2C Cassette Drive through dbus. @@ -162,8 +165,9 @@ def main( echo.mode = output async def load() -> None: - client = DbusClient() - ctx.obj = Obj(client=client, log_level=log_level, output=output) + bus: SdBus = sd_bus_open_user() if user else sd_bus_open_system() + client = DbusClient(bus) + ctx.obj = Obj(client=client, log_level=log_level, output=output, user=user) asyncio.run(load()) diff --git a/plusdeck/dbus/interface.py b/plusdeck/dbus/interface.py index f541faf..bacc607 100644 --- a/plusdeck/dbus/interface.py +++ b/plusdeck/dbus/interface.py @@ -18,7 +18,7 @@ DBUS_NAME = "org.jfhbrook.plusdeck" -async def load_client(config_file: str) -> Client: +async def load_client(config_file: Optional[str]) -> Client: config: Config = Config.from_file(config_file) client = await create_connection(config.port) @@ -33,7 +33,7 @@ class DbusInterface( # type: ignore A DBus interface for controlling the Plus Deck 2C PC Cassette Deck. """ - def __init__(self: Self, config_file: str, client: Client) -> None: + def __init__(self: Self, client: Client, config_file: Optional[str] = None) -> None: super().__init__() self._config: Config = Config.from_file(config_file) self.client: Client = client diff --git a/plusdeck/dbus/service.py b/plusdeck/dbus/service.py index 8dc917b..8a5dcc2 100644 --- a/plusdeck/dbus/service.py +++ b/plusdeck/dbus/service.py @@ -1,5 +1,7 @@ import asyncio import logging +import os +from typing import Optional import click from sdbus import ( # pyright: ignore [reportMissingModuleSource] @@ -13,13 +15,13 @@ logger = logging.getLogger(__name__) -async def service(config_file: str) -> DbusInterface: +async def service(config_file: Optional[str] = None) -> DbusInterface: """ Create a configure DBus service with a supplied config file. """ client = await load_client(config_file) - iface = DbusInterface(config_file, client) + iface = DbusInterface(client, config_file=config_file) logger.debug(f"Requesting bus name {DBUS_NAME}...") await request_default_bus_name_async(DBUS_NAME) @@ -33,7 +35,7 @@ async def service(config_file: str) -> DbusInterface: return iface -async def serve(config_file: str) -> None: +async def serve(config_file: Optional[str] = None) -> None: """ Create and serve configure DBus service with a supplied config file. """ @@ -44,6 +46,13 @@ async def serve(config_file: str) -> None: @click.command +@click.option( + "--global/--no-global", + "global_", + default=os.geteuid() == 0, + help=f"Load the global config file at {GLOBAL_FILE} " + "(default true when called with sudo)", +) @click.option( "--config-file", "-C", @@ -58,14 +67,24 @@ async def serve(config_file: str) -> None: default="INFO", help="Set the log level", ) -def main(config_file: str, log_level: LogLevel) -> None: +def main(global_: bool, config_file: str, log_level: LogLevel) -> None: """ Expose the Plus Deck 2C PC Cassette Deck as a DBus service. """ logging.basicConfig(level=getattr(logging, log_level)) - asyncio.run(serve(config_file)) + file = None + if config_file: + if global_: + logger.debug( + "--config-file is specified, so --global flag will be ignored." + ) + file = config_file + elif global_: + file = GLOBAL_FILE + + asyncio.run(serve(file)) if __name__ == "__main__": diff --git a/polkit/org.jfhbrook.plusdeck.policy b/polkit/org.jfhbrook.plusdeck.policy new file mode 100644 index 0000000..75bf3df --- /dev/null +++ b/polkit/org.jfhbrook.plusdeck.policy @@ -0,0 +1,14 @@ + + + + plusdeck + https://github.com/jfhbrook/plusdeck + + Polkit no allow eject tho + + yes + yes + yes + + + diff --git a/polkit/org.jfhbrook.plusdeck.rules b/polkit/org.jfhbrook.plusdeck.rules new file mode 100644 index 0000000..13865f4 --- /dev/null +++ b/polkit/org.jfhbrook.plusdeck.rules @@ -0,0 +1,14 @@ +polkit.addRule(function(action, subject) { + if ((action.id == "org.jfhbrook.plusdeck.Eject" || + action.id == "org.jfhbrook.plusdeck.FastForwardA" || + action.id == "org.jfhbrook.plusdeck.Pause" || + action.id == "org.jfhbrook.plusdeck.PlayA" || + action.id == "org.jfhbrook.plusdeck.PlayB" || + action.id == "org.jfhbrook.plusdeck.Stop" || + action.id == "org.jfhbrook.plusdeck.WaitFor") && + subject.isInGroup("plusdeck")) { + return polkit.Result.YES; + } + + return polkit.Result.NOT_HANDLED; +}); diff --git a/pyproject.toml b/pyproject.toml index 9a1c358..b7fc945 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,11 +5,11 @@ build-backend = "setuptools.build_meta" [tool.rpm] # Generally this is "1", but can be incremented to roll up bugfixes in the # top-level `plusdeck` Fedora package. -release = "3" +release = "1" [project] name = "plusdeck" -version = "3.0.0" +version = "4.0.0" authors = [ {name = "Josh Holbrook", email = "josh.holbrook@gmail.com"} ] diff --git a/scripts/get-dbus-iface.sh b/scripts/get-dbus-iface.sh new file mode 100755 index 0000000..0af109f --- /dev/null +++ b/scripts/get-dbus-iface.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +function extract-response { + tail -n +2 | sed 's/^\s*string "//' | sed 's/"$//' +} + +dbus-send --system \ + --dest=org.jfhbrook.plusdeck "/" \ + --print-reply \ + org.freedesktop.DBus.Introspectable.Introspect \ + | extract-response diff --git a/scripts/release-version.py b/scripts/release-version.py index 82568b4..91e1127 100755 --- a/scripts/release-version.py +++ b/scripts/release-version.py @@ -1,8 +1,8 @@ #!/usr/bin/env python -import toml +import tomllib -with open("pyproject.toml", "r") as f: - project = toml.load(f) +with open("pyproject.toml", "rb") as f: + project = tomllib.load(f) print(project["tool"]["rpm"]["release"]) diff --git a/scripts/version.py b/scripts/version.py index 218040c..dba81e8 100755 --- a/scripts/version.py +++ b/scripts/version.py @@ -1,8 +1,8 @@ #!/usr/bin/env python -import toml +import tomllib -with open("pyproject.toml", "r") as f: - project = toml.load(f) +with open("pyproject.toml", "rb") as f: + project = tomllib.load(f) print(project["project"]["version"]) diff --git a/systemd/plusdeck.service b/systemd/plusdeck.service index 7d53be5..b67268c 100644 --- a/systemd/plusdeck.service +++ b/systemd/plusdeck.service @@ -1,5 +1,7 @@ [Unit] Description=Plus Deck 2C PC Cassette Deck +Requires=dbus.socket +After=dbus.socket [Service] Type=dbus @@ -7,3 +9,5 @@ BusName=org.jfhbrook.plusdeck ExecStart=/usr/bin/plusdeckd Restart=on-failure +[Install] +WantedBy=multi-user.target diff --git a/uv.lock b/uv.lock index 32233ed..c4601c7 100644 --- a/uv.lock +++ b/uv.lock @@ -1415,7 +1415,7 @@ wheels = [ [[package]] name = "plusdeck" -version = "3.0.0" +version = "4.0.0" source = { editable = "." } dependencies = [ { name = "click" },