Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion docs/how-to/client_service.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,26 @@ For speed and ease of use, you can install the client service using the `uv` too
```bash
sudo -s # uv (installed by root) requires the root profile so use sudo -s
uvx usb-remote install-service --service-type client
systemctl enable --now usb-remote-client.service
exit
```

3. Configure the client service to let it know which usb-remote servers to connect to. Create the configuration directory and file:

```bash
sudo mkdir -p /etc/usb-remote-client
sudo vim /etc/usb-remote-client/usb-remote.config
```

Add your configuration settings as needed (see [Client Configuration File](../reference/config.md) for details).

4. Enable and start the service:

```bash
sudo systemctl enable --now usb-remote-client.service
```

```bash

Check service status:

```bash
Expand Down
19 changes: 5 additions & 14 deletions src/usb_remote/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from .client import attach_device, detach_device, find_device, list_devices
from .client_service import ClientService
from .config import (
DEFAULT_CONFIG_PATH,
Defaults,
discover_config_path,
get_config,
save_servers,
Expand Down Expand Up @@ -327,22 +327,13 @@ def config_show() -> None:

if config_path is None:
typer.echo("No configuration file found.")
typer.echo(f"Default location: {DEFAULT_CONFIG_PATH}")
typer.echo(f"Default location: {Defaults.CONFIG_PATH}")
typer.echo("\nDefault configuration:")
else:
typer.echo(f"Configuration file: {config_path}")
typer.echo()

config = get_config()

typer.echo(f"Servers ({len(config.servers)}):")
if config.servers:
for server in config.servers:
typer.echo(f" - {server}")
else:
typer.echo(" (none)")

typer.echo(f"\nTimeout: {config.timeout}s")
typer.echo(get_config())


@config_app.command(name="add-server")
Expand All @@ -359,7 +350,7 @@ def config_add_server(
config.servers.append(server)
save_servers(config.servers)

config_path = discover_config_path() or DEFAULT_CONFIG_PATH
config_path = discover_config_path() or Defaults.CONFIG_PATH
typer.echo(f"Added server '{server}' to {config_path}")


Expand Down Expand Up @@ -398,7 +389,7 @@ def config_set_timeout(
config.timeout = timeout
config.to_file()

config_path = discover_config_path() or DEFAULT_CONFIG_PATH
config_path = discover_config_path() or Defaults.CONFIG_PATH
typer.echo(f"Set timeout to {timeout}s in {config_path}")


Expand Down
29 changes: 9 additions & 20 deletions src/usb_remote/client_api.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,26 @@
"""Pydantic models for client service socket communication."""

import os
from pathlib import Path
from typing import Literal

from pydantic import BaseModel, ConfigDict

from .usbdevice import UsbDevice
from usb_remote.config import Defaults, Environment

CLIENT_SOCKET_PATH = "/tmp/usb-remote-client.sock"
CLIENT_SOCKET_PATH_SYSTEMD = "/run/usb-remote-client/usb-remote-client.sock"
from .usbdevice import UsbDevice


def get_client_socket_path() -> str:
"""Get the appropriate socket path based on execution context.
"""Get the appropriate socket path based on environment variable or default.

Returns:
/run/usb-remote-client/usb-remote-client.sock when running as systemd service,
/tmp/usb-remote-client.sock otherwise.
Path to the client socket file.
"""
# Detect systemd service by checking for INVOCATION_ID environment variable
# and verifying the runtime directory exists (created by systemd's RuntimeDirectory)
if os.environ.get("INVOCATION_ID"):
socket_dir = Path(CLIENT_SOCKET_PATH_SYSTEMD).parent
# Check if the directory exists and is writable (systemd creates it)
if socket_dir.exists() and os.access(socket_dir, os.W_OK):
return CLIENT_SOCKET_PATH_SYSTEMD
else:
raise RuntimeError(
f"Expected systemd runtime directory {socket_dir}"
f" does not exist or is not writable."
)
return CLIENT_SOCKET_PATH
# prefer the environment variable if set but fall back to default socket path
socket_path = os.environ.get(
Environment.USB_REMOTE_CLIENT_SOCKET, Defaults.CLIENT_SOCKET
)
return socket_path


class StrictBaseModel(BaseModel):
Expand Down
61 changes: 42 additions & 19 deletions src/usb_remote/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,55 @@

import logging
import os
from enum import StrEnum
from pathlib import Path

import yaml
from pydantic import BaseModel, ConfigDict, Field

logger = logging.getLogger(__name__)

DEFAULT_CONFIG_PATH = Path.home() / ".config" / "usb-remote" / "usb-remote.config"
SYSTEMD_CONFIG_PATH = Path("/etc/usb-remote-client/usb-remote.config")
DEFAULT_TIMEOUT = 5.0

SERVER_PORT = 5055
class Defaults:
"""Default configuration values."""

CLIENT_SOCKET = "/tmp/usb-remote-client.sock"
CONFIG_PATH = Path.home() / ".config" / "usb-remote" / "usb-remote.config"
SERVER_PORT = 5055
TIMEOUT = 2.0


class Environment(StrEnum):
"""Environment Variables that may override Defaults above."""

USB_REMOTE_CLIENT_SOCKET = "USB_REMOTE_CLIENT_SOCKET"
USB_REMOTE_CONFIG_PATH = "USB_REMOTE_CONFIG_PATH"
USB_REMOTE_SERVER_PORT = "USB_REMOTE_SERVER_PORT"


class UsbRemoteConfig(BaseModel):
"""Pydantic model for usb_remote configuration."""

servers: list[str] = Field(default_factory=list)
server_ranges: list[str] = Field(default_factory=list)
timeout: float = Field(default=DEFAULT_TIMEOUT, gt=0)
server_port: int = Field(default=SERVER_PORT)
timeout: float = Field(default=Defaults.TIMEOUT, gt=0)
server_port: int = Field(default=Defaults.SERVER_PORT)
model_config = ConfigDict(extra="forbid")

def __str__(self) -> str:
def do_list_format(s: list[str]) -> str:
return " none" if not s else "\n".join(f" - {s}" for s in s)

return (
f"UsbRemoteConfig:\n"
f" servers:\n"
f"{do_list_format(self.servers)}\n"
f" server_ranges:\n"
f"{do_list_format(self.server_ranges)}\n"
f" timeout={self.timeout}\n"
f" server_port={self.server_port}"
)

@classmethod
def from_file(cls, config_path: Path) -> "UsbRemoteConfig":
"""
Expand Down Expand Up @@ -61,7 +87,7 @@ def to_file(self) -> None:
Args:
config_path: Path to the config file.
"""
config_path = discover_config_path() or DEFAULT_CONFIG_PATH
config_path = discover_config_path() or Defaults.CONFIG_PATH
# Create directory if it doesn't exist
config_path.parent.mkdir(parents=True, exist_ok=True)

Expand Down Expand Up @@ -90,30 +116,27 @@ def discover_config_path() -> Path | None:
Path to config file if found, None otherwise.
"""
# 1. Check environment variable
env_config = os.environ.get("USB_REMOTE_CONFIG")
env_config = os.environ.get(Environment.USB_REMOTE_CONFIG_PATH)
if env_config:
env_path = Path(env_config).expanduser()
if env_path.exists():
logger.debug(f"Using config from USB_REMOTE_CONFIG: {env_path}")
logger.debug(f"Using config from USB_REMOTE_CONFIG_PATH: {env_path}")
return env_path
else:
logger.warning(f"USB_REMOTE_CONFIG points to non-existent file: {env_path}")

# 2. Check systemd config (when running as systemd service)
if os.environ.get("INVOCATION_ID") and SYSTEMD_CONFIG_PATH.exists():
logger.debug(f"Using systemd config: {SYSTEMD_CONFIG_PATH}")
return SYSTEMD_CONFIG_PATH
logger.warning(
f"USB_REMOTE_CONFIG_PATH points to non-existent file: {env_path}"
)

# 3. Check local directory
# 2. Check local directory
local_config = Path.cwd() / ".usb-remote.config"
if local_config.exists():
logger.debug(f"Using local config: {local_config}")
return local_config

# 3. Check default location
if DEFAULT_CONFIG_PATH.exists():
logger.debug(f"Using default config: {DEFAULT_CONFIG_PATH}")
return DEFAULT_CONFIG_PATH
if Defaults.CONFIG_PATH.exists():
logger.debug(f"Using default config: {Defaults.CONFIG_PATH}")
return Defaults.CONFIG_PATH

logger.debug("No config file found")
return None
Expand Down
6 changes: 4 additions & 2 deletions src/usb_remote/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
multiple_matches_response,
not_found_response,
)
from .config import SERVER_PORT
from .config import Defaults, Environment
from .usbdevice import (
DeviceNotFoundError,
MultipleDevicesError,
Expand All @@ -34,7 +34,9 @@ def __init__(self, host: str = "0.0.0.0", port: int | None = None):
self.host = host
# Allow server port to be overridden via environment variable
if port is None:
port = int(os.environ.get("USB_REMOTE_SERVER_PORT", SERVER_PORT))
port = int(
os.environ.get(Environment.USB_REMOTE_SERVER_PORT, Defaults.SERVER_PORT)
)
self.port = port
self.server_socket = None
self.running = False
Expand Down
7 changes: 5 additions & 2 deletions src/usb_remote/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,19 @@
[Service]
Type=simple
User={user}
# TODO : Change to an appropriate group if we need access from non-root users
# Group=root
WorkingDirectory={working_dir}
ExecStart={executable} -m usb_remote client-service
Restart=on-failure
RestartSec=5s
RuntimeDirectory=usb-remote-client
RuntimeDirectoryMode=0755
# TODO : Change to an appropriate group if we need access from non-root users
RuntimeDirectoryGroup=root
ConfigurationDirectory=usb-remote-client
ConfigurationDirectoryMode=0755
Environment="USB_REMOTE_CONFIG_PATH=/etc/usb-remote-client/usb-remote.config"
Environment="USB_REMOTE_CLIENT_SOCKET=/run/usb-remote-client/usb-remote-client.sock"


# Security hardening
NoNewPrivileges=true
Expand Down
9 changes: 5 additions & 4 deletions src/usb_remote/utility.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import socket
import subprocess

from usb_remote.config import SERVER_PORT, get_server_ranges, get_servers
from usb_remote.config import get_server_port, get_server_ranges, get_servers

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -72,11 +72,12 @@ def _scan_ip_range(range_spec: str) -> list[str]:
for current_int in range(int(start_ip), int(end_ip) + 1):
current_ip = ipaddress.ip_address(current_int)
ip_str = str(current_ip)
if _is_port_open(ip_str, SERVER_PORT):
logger.info(f"Found server at {ip_str}:{SERVER_PORT}")
port = get_server_port()
if _is_port_open(ip_str, port):
logger.info(f"Found server at {ip_str}:{port}")
responsive_servers.append(ip_str)
else:
logger.debug(f"No response from {ip_str}:{SERVER_PORT}")
logger.debug(f"No response from {ip_str}:{port}")
current_int += 1

except ValueError as e:
Expand Down
3 changes: 0 additions & 3 deletions tests/conftest_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@

import pytest

# Unset INVOCATION_ID at module level to prevent systemd socket detection
_original_invocation_id = os.environ.pop("INVOCATION_ID", None)


@pytest.fixture(scope="session")
def mock_subprocess_run():
Expand Down
Loading