# Styrene Bond RPC Requirements

**Document Version:** 1.0
**Date:** 2026-01-22
**Purpose:** Define device management RPC operations, authorization model, and NixOS integration strategy

---

## 1. Overview

The Styrene Bond RPC system provides remote device management capabilities over LXMF messaging. It extends the existing RPC client (which currently supports `status` and `exec` commands) with additional device management operations and a comprehensive authorization framework.

**Design Goals:**
- Identity-based authorization for all commands
- NixOS-native deployment (systemd service)
- Minimal resource footprint for edge devices
- Audit logging for all operations
- Graceful degradation when offline

---

## 2. Existing RPC Architecture

### Current Implementation

**Client-side** (`src/styrene/services/rpc_client.py`):
- Request/response correlation via UUID
- Timeout management (10s for status, 60s for exec)
- Future-based async API
- Message type registry for deserialization

**Current Message Types** (`src/styrene/models/rpc_messages.py`):
1. `StatusRequest` → `StatusResponse` (uptime, IP, services, disk usage)
2. `ExecCommand` → `ExecResult` (exit code, stdout, stderr)

**Transport**: LXMF messages with JSON-serialized payloads (should migrate to MessagePack per lxmf-message-structure.md recommendation)

### Limitations to Address

1. **No authorization** - Any identity can execute any command
2. **No server-side implementation** - Devices must manually implement message handlers
3. **Limited command set** - Only status and exec
4. **No audit logging** - No record of who executed what
5. **No configuration management** - Cannot update device config remotely
6. **No reboot capability** - Cannot restart devices for updates

---

## 3. Required RPC Operations

### 3.1 Status (Existing - Enhance)

**Current Signature:**
```python
StatusRequest() → StatusResponse(uptime, ip, services, disk_used, disk_total)
```

**Enhancements:**
- Add `hostname`
- Add `reticulum_status` (interface count, peer count)
- Add `memory_used` / `memory_total`
- Add `cpu_percent` (1-minute average)
- Add `nixos_generation` (current system generation)
- Add `last_update` (timestamp of last successful update)

**Enhanced Signature:**
```python
@dataclass
class StatusResponse:
    uptime: int                    # Seconds since boot
    hostname: str                  # Device hostname
    ip: str                        # Primary IP address
    services: list[str]            # Running systemd services
    disk_used: int                 # Used disk space (bytes)
    disk_total: int                # Total disk space (bytes)
    memory_used: int               # Used RAM (bytes)
    memory_total: int              # Total RAM (bytes)
    cpu_percent: float             # CPU utilization (0-100)
    reticulum_interfaces: int      # Active Reticulum interfaces
    reticulum_peers: int           # Known Reticulum peers
    nixos_generation: int          # Current NixOS generation number
    last_update: float             # Unix timestamp of last update
    type: str = "status_response"
```

**Dependencies:** `psutil`, `subprocess` (for systemctl queries)

---

### 3.2 Exec (Existing - Authorization Required)

**Current Signature:**
```python
ExecCommand(command: str, args: list[str]) → ExecResult(exit_code, stdout, stderr)
```

**Authorization Requirements:**
- **HIGH RISK** - Arbitrary command execution must be strictly controlled
- Require `exec` permission in authorization config
- Log all exec commands with identity and timestamp
- Consider allowlist of permitted commands (e.g., only `systemctl`, `journalctl`, `nixos-rebuild`)

**Recommended Allowlist:**
```python
ALLOWED_COMMANDS = {
    "systemctl": ["status", "restart", "start", "stop"],  # Service management
    "journalctl": ["-u", "-n", "--since"],                 # Log viewing
    "nixos-rebuild": ["dry-build", "test"],                # Safe NixOS operations
    "reticulum": ["--status"],                             # Reticulum status
}
```

**Deny Dangerous Commands:**
- `rm`, `dd`, `mkfs` (data destruction)
- `chmod`, `chown` (permission changes)
- `shutdown`, `reboot` (use dedicated RPC command instead)
- Any command with shell metacharacters (`|`, `;`, `&`, `$()`, etc.)

---

### 3.3 Reboot (New)

**Purpose:** Safely reboot device for updates or recovery.

**Signature:**
```python
@dataclass
class RebootCommand:
    delay: int = 0                 # Delay in seconds before reboot (default immediate)
    message: str = ""              # Message to log before reboot
    type: str = "reboot"

@dataclass
class RebootResponse:
    scheduled_time: float          # Unix timestamp when reboot will occur
    message: str                   # Confirmation message
    type: str = "reboot_response"
```

**Authorization:**
- Requires `reboot` permission
- Logs identity, timestamp, message

**Implementation:**
```python
async def handle_reboot(command: RebootCommand, identity: str) -> RebootResponse:
    if not auth.can_reboot(identity):
        raise PermissionDeniedError(f"{identity} not authorized for reboot")

    scheduled_time = time.time() + command.delay
    log_audit(identity, "reboot", {"delay": command.delay, "message": command.message})

    # Schedule reboot via systemd
    subprocess.run(["sudo", "systemctl", "reboot", f"--delay={command.delay}"])

    return RebootResponse(
        scheduled_time=scheduled_time,
        message=f"Reboot scheduled for {scheduled_time}"
    )
```

---

### 3.4 UpdateConfig (New)

**Purpose:** Update device configuration remotely (NixOS configuration.nix, Reticulum config, etc.)

**Signature:**
```python
@dataclass
class UpdateConfigCommand:
    config_type: str               # "nixos" | "reticulum" | "styrene"
    config_data: dict[str, Any]    # Configuration updates (partial merge)
    apply_immediately: bool = False  # Apply config immediately (vs next reboot)
    type: str = "update_config"

@dataclass
class UpdateConfigResponse:
    success: bool
    generation: int | None         # New NixOS generation (if nixos config)
    message: str                   # Status message
    dry_run_output: str | None     # Output from dry-run (if applicable)
    type: str = "update_config_response"
```

**Authorization:**
- Requires `update_config` permission
- Logs identity, config_type, changes

**NixOS Integration:**
```python
async def handle_update_config(cmd: UpdateConfigCommand, identity: str) -> UpdateConfigResponse:
    if not auth.can_update_config(identity):
        raise PermissionDeniedError(f"{identity} not authorized for config updates")

    if cmd.config_type == "nixos":
        # Merge config updates into /etc/nixos/configuration.nix
        merge_nixos_config(cmd.config_data)

        # Dry-run to validate
        result = subprocess.run(
            ["sudo", "nixos-rebuild", "dry-build"],
            capture_output=True, text=True
        )

        if result.returncode != 0:
            return UpdateConfigResponse(
                success=False,
                generation=None,
                message="Dry-run failed",
                dry_run_output=result.stderr
            )

        # Apply if requested
        if cmd.apply_immediately:
            result = subprocess.run(
                ["sudo", "nixos-rebuild", "test"],
                capture_output=True, text=True
            )
            generation = extract_generation(result.stdout)
        else:
            generation = None

        log_audit(identity, "update_config", {"type": "nixos", "apply": cmd.apply_immediately})

        return UpdateConfigResponse(
            success=True,
            generation=generation,
            message="Config updated successfully",
            dry_run_output=result.stdout
        )

    elif cmd.config_type == "reticulum":
        # Update ~/.reticulum/config
        update_reticulum_config(cmd.config_data)
        return UpdateConfigResponse(success=True, generation=None, message="Reticulum config updated")

    else:
        raise ValueError(f"Unknown config_type: {cmd.config_type}")
```

---

### 3.5 GetLogs (New)

**Purpose:** Retrieve systemd journal logs for diagnostics.

**Signature:**
```python
@dataclass
class GetLogsCommand:
    unit: str | None = None        # Service name (e.g., "styrene-bond-rpc")
    since: str | None = None       # Time filter (e.g., "1 hour ago")
    lines: int = 100               # Number of lines to retrieve
    type: str = "get_logs"

@dataclass
class GetLogsResponse:
    logs: str                      # Log output
    truncated: bool                # True if output was truncated
    type: str = "get_logs_response"
```

**Authorization:**
- Requires `view_logs` permission

**Implementation:**
```python
async def handle_get_logs(cmd: GetLogsCommand, identity: str) -> GetLogsResponse:
    if not auth.can_view_logs(identity):
        raise PermissionDeniedError(f"{identity} not authorized for log viewing")

    args = ["journalctl", "-n", str(cmd.lines)]
    if cmd.unit:
        args.extend(["-u", cmd.unit])
    if cmd.since:
        args.extend(["--since", cmd.since])

    result = subprocess.run(args, capture_output=True, text=True)

    log_audit(identity, "get_logs", {"unit": cmd.unit, "lines": cmd.lines})

    return GetLogsResponse(
        logs=result.stdout,
        truncated=len(result.stdout) >= 100000  # Arbitrary truncation threshold
    )
```

---

## 4. Authorization Model

### 4.1 Design Principles

1. **Identity-based:** Authorization decisions based on Reticulum identity hash
2. **Principle of least privilege:** Default deny, explicit allow
3. **Auditable:** All authorization decisions logged
4. **Configurable:** YAML-based authorization policy
5. **Offline-safe:** Authorization config cached locally, no external dependencies

---

### 4.2 Authorization Configuration

**File:** `/etc/styrene/bond-rpc-auth.yaml`

```yaml
# Styrene Bond RPC Authorization Policy
version: 1

# Identity groups for easier management
groups:
  operators:
    - <identity_hash_1>  # Alice
    - <identity_hash_2>  # Bob

  readonly:
    - <identity_hash_3>  # Monitoring system

# Permission assignments
permissions:
  - identities: operators
    allow:
      - status
      - exec
      - reboot
      - update_config
      - view_logs

  - identities: readonly
    allow:
      - status
      - view_logs

  # Individual identity overrides
  - identities:
      - <identity_hash_4>  # Emergency admin account
    allow:
      - "*"  # All permissions

# Exec command allowlist (applies to all identities with exec permission)
exec_allowlist:
  systemctl:
    - status
    - restart
    - start
    - stop
  journalctl:
    - "-u"
    - "-n"
    - "--since"
  nixos-rebuild:
    - dry-build
    - test

# Audit log settings
audit:
  enabled: true
  log_file: /var/log/styrene/bond-rpc-audit.log
  rotate_size: 10485760  # 10 MB
  rotate_count: 5
```

---

### 4.3 Authorization Service Implementation

**File:** `packages/styrene-bond-rpc/src/styrene_bond_rpc/auth.py`

```python
import hashlib
import logging
import yaml
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)

class AuthorizationService:
    """Identity-based authorization for RPC commands."""

    def __init__(self, config_path: str = "/etc/styrene/bond-rpc-auth.yaml"):
        """Initialize authorization service.

        Args:
            config_path: Path to authorization config file.
        """
        self.config_path = Path(config_path)
        self.config: dict[str, Any] = {}
        self.identity_permissions: dict[str, set[str]] = {}
        self.load_config()

    def load_config(self) -> None:
        """Load authorization configuration from YAML file."""
        if not self.config_path.exists():
            logger.warning(f"Auth config not found: {self.config_path}, using default deny-all")
            self.config = {"permissions": []}
            return

        with open(self.config_path) as f:
            self.config = yaml.safe_load(f)

        # Build identity permission cache
        self._build_permission_cache()

        logger.info(f"Loaded authorization config from {self.config_path}")

    def _build_permission_cache(self) -> None:
        """Build permission cache for fast lookup."""
        self.identity_permissions = {}
        groups = self.config.get("groups", {})

        for perm_entry in self.config.get("permissions", []):
            identities = perm_entry.get("identities", [])
            permissions = perm_entry.get("allow", [])

            # Resolve group references
            if isinstance(identities, str) and identities in groups:
                identities = groups[identities]
            elif isinstance(identities, list):
                expanded = []
                for ident in identities:
                    if isinstance(ident, str) and ident in groups:
                        expanded.extend(groups[ident])
                    else:
                        expanded.append(ident)
                identities = expanded

            # Assign permissions
            for identity in identities:
                if identity not in self.identity_permissions:
                    self.identity_permissions[identity] = set()
                self.identity_permissions[identity].update(permissions)

        logger.debug(f"Built permission cache for {len(self.identity_permissions)} identities")

    def can_execute(self, identity_hash: str, command: str) -> bool:
        """Check if identity can execute command.

        Args:
            identity_hash: Reticulum identity hash (hex string).
            command: Command name (e.g., "status", "exec", "reboot").

        Returns:
            True if authorized, False otherwise.
        """
        if identity_hash not in self.identity_permissions:
            logger.warning(f"Identity {identity_hash} not in authorization config")
            return False

        permissions = self.identity_permissions[identity_hash]

        # Check for wildcard or specific permission
        authorized = "*" in permissions or command in permissions

        if not authorized:
            logger.warning(
                f"Identity {identity_hash} denied for {command} "
                f"(has: {permissions})"
            )

        return authorized

    def can_exec_command(self, identity_hash: str, command: str, args: list[str]) -> bool:
        """Check if identity can execute specific exec command.

        Args:
            identity_hash: Reticulum identity hash.
            command: Command binary name.
            args: Command arguments.

        Returns:
            True if authorized, False otherwise.
        """
        # First check if identity has exec permission at all
        if not self.can_execute(identity_hash, "exec"):
            return False

        # Check exec allowlist
        allowlist = self.config.get("exec_allowlist", {})

        if command not in allowlist:
            logger.warning(f"Command {command} not in exec allowlist")
            return False

        # Check if all args are allowed
        allowed_args = set(allowlist[command])
        provided_args = set(args)

        # Allow if all provided args are in allowlist
        # (This is simplified - production should handle partial matches, flags, etc.)
        if not provided_args.issubset(allowed_args):
            logger.warning(
                f"Args {provided_args} not subset of allowed {allowed_args} "
                f"for {command}"
            )
            return False

        return True

    def reload(self) -> None:
        """Reload configuration from disk."""
        self.load_config()
        logger.info("Authorization config reloaded")
```

**Usage:**
```python
auth = AuthorizationService()

if not auth.can_execute(identity_hash, "reboot"):
    raise PermissionDeniedError(f"{identity_hash} not authorized for reboot")

if not auth.can_exec_command(identity_hash, "systemctl", ["restart", "reticulum"]):
    raise PermissionDeniedError("Command not allowed")
```

---

### 4.4 Audit Logging

**File:** `packages/styrene-bond-rpc/src/styrene_bond_rpc/audit.py`

```python
import json
import logging
import time
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Any

class AuditLogger:
    """Audit logger for RPC operations."""

    def __init__(
        self,
        log_file: str = "/var/log/styrene/bond-rpc-audit.log",
        max_bytes: int = 10485760,  # 10 MB
        backup_count: int = 5,
    ):
        """Initialize audit logger.

        Args:
            log_file: Path to audit log file.
            max_bytes: Max size before rotation.
            backup_count: Number of backup files to keep.
        """
        self.logger = logging.getLogger("styrene.bond_rpc.audit")
        self.logger.setLevel(logging.INFO)

        # Create log directory if needed
        log_path = Path(log_file)
        log_path.parent.mkdir(parents=True, exist_ok=True)

        # Rotating file handler
        handler = RotatingFileHandler(
            log_file,
            maxBytes=max_bytes,
            backupCount=backup_count,
        )

        # JSON formatter
        handler.setFormatter(logging.Formatter("%(message)s"))
        self.logger.addHandler(handler)

    def log(
        self,
        identity: str,
        command: str,
        result: str,  # "success" | "denied" | "error"
        details: dict[str, Any] | None = None,
    ) -> None:
        """Log RPC operation.

        Args:
            identity: Reticulum identity hash.
            command: Command executed.
            result: Outcome of command.
            details: Additional details (optional).
        """
        entry = {
            "timestamp": time.time(),
            "identity": identity,
            "command": command,
            "result": result,
            "details": details or {},
        }

        self.logger.info(json.dumps(entry))

# Global audit logger
audit_logger = AuditLogger()

def log_audit(identity: str, command: str, details: dict[str, Any] | None = None) -> None:
    """Convenience function for audit logging."""
    audit_logger.log(identity, command, "success", details)
```

**Audit Log Example:**
```json
{"timestamp": 1737584400, "identity": "<hash1>", "command": "status", "result": "success", "details": {}}
{"timestamp": 1737584401, "identity": "<hash1>", "command": "exec", "result": "success", "details": {"command": "systemctl", "args": ["status", "reticulum"]}}
{"timestamp": 1737584402, "identity": "<hash2>", "command": "reboot", "result": "denied", "details": {"reason": "insufficient permissions"}}
{"timestamp": 1737584403, "identity": "<hash1>", "command": "update_config", "result": "success", "details": {"type": "nixos", "apply": true, "generation": 42}}
```

---

## 5. Device-Side RPC Server Architecture

### 5.1 Systemd Service

**Purpose:** Run RPC server as a systemd service on NixOS devices

**Service File:** `packages/styrene-bond-rpc/systemd/styrene-bond-rpc.service`

```ini
[Unit]
Description=Styrene Bond RPC Server
Documentation=https://github.com/vanderlyn/styrene-tui
After=network.target reticulum.service
Requires=reticulum.service

[Service]
Type=simple
User=styrene-rpc
Group=styrene-rpc
WorkingDirectory=/var/lib/styrene-bond-rpc

# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/styrene-bond-rpc /var/log/styrene

# Service execution
ExecStart=/usr/bin/env python3 -m styrene_bond_rpc.server
Restart=on-failure
RestartSec=5

# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=styrene-bond-rpc

[Install]
WantedBy=multi-user.target
```

**Sudoers Configuration** (for privileged operations):
```
# Allow styrene-rpc user to execute specific commands without password
styrene-rpc ALL=(ALL) NOPASSWD: /run/current-system/sw/bin/systemctl reboot
styrene-rpc ALL=(ALL) NOPASSWD: /run/current-system/sw/bin/nixos-rebuild dry-build
styrene-rpc ALL=(ALL) NOPASSWD: /run/current-system/sw/bin/nixos-rebuild test
styrene-rpc ALL=(ALL) NOPASSWD: /run/current-system/sw/bin/journalctl *
styrene-rpc ALL=(ALL) NOPASSWD: /run/current-system/sw/bin/systemctl status *
styrene-rpc ALL=(ALL) NOPASSWD: /run/current-system/sw/bin/systemctl restart *
```

---

### 5.2 NixOS Module

**File:** `packages/styrene-bond-rpc/nix/module.nix`

```nix
{ config, lib, pkgs, ... }:

with lib;

let
  cfg = config.services.styrene-bond-rpc;

in {
  options.services.styrene-bond-rpc = {
    enable = mkEnableOption "Styrene Bond RPC server";

    package = mkOption {
      type = types.package;
      default = pkgs.styrene-bond-rpc;
      description = "The styrene-bond-rpc package to use";
    };

    authConfig = mkOption {
      type = types.path;
      default = /etc/styrene/bond-rpc-auth.yaml;
      description = "Path to authorization configuration";
    };

    user = mkOption {
      type = types.str;
      default = "styrene-rpc";
      description = "User account under which styrene-bond-rpc runs";
    };

    group = mkOption {
      type = types.str;
      default = "styrene-rpc";
      description = "Group under which styrene-bond-rpc runs";
    };

    stateDirectory = mkOption {
      type = types.path;
      default = "/var/lib/styrene-bond-rpc";
      description = "Directory for styrene-bond-rpc state";
    };

    logDirectory = mkOption {
      type = types.path;
      default = "/var/log/styrene";
      description = "Directory for styrene-bond-rpc logs";
    };
  };

  config = mkIf cfg.enable {
    # Create user and group
    users.users.${cfg.user} = {
      isSystemUser = true;
      group = cfg.group;
      description = "Styrene Bond RPC service user";
      home = cfg.stateDirectory;
    };

    users.groups.${cfg.group} = {};

    # Install package
    environment.systemPackages = [ cfg.package ];

    # Systemd service
    systemd.services.styrene-bond-rpc = {
      description = "Styrene Bond RPC Server";
      wantedBy = [ "multi-user.target" ];
      after = [ "network.target" "reticulum.service" ];
      requires = [ "reticulum.service" ];

      serviceConfig = {
        Type = "simple";
        User = cfg.user;
        Group = cfg.group;
        WorkingDirectory = cfg.stateDirectory;
        ExecStart = "${cfg.package}/bin/styrene-bond-rpc-server";
        Restart = "on-failure";
        RestartSec = 5;

        # Hardening
        NoNewPrivileges = true;
        PrivateTmp = true;
        ProtectSystem = "strict";
        ProtectHome = true;
        ReadWritePaths = [ cfg.stateDirectory cfg.logDirectory ];

        # Logging
        StandardOutput = "journal";
        StandardError = "journal";
        SyslogIdentifier = "styrene-bond-rpc";
      };
    };

    # Sudoers configuration
    security.sudo.extraRules = [
      {
        users = [ cfg.user ];
        commands = [
          { command = "/run/current-system/sw/bin/systemctl reboot"; options = [ "NOPASSWD" ]; }
          { command = "/run/current-system/sw/bin/nixos-rebuild dry-build"; options = [ "NOPASSWD" ]; }
          { command = "/run/current-system/sw/bin/nixos-rebuild test"; options = [ "NOPASSWD" ]; }
          { command = "/run/current-system/sw/bin/journalctl"; options = [ "NOPASSWD" ]; }
          { command = "/run/current-system/sw/bin/systemctl status"; options = [ "NOPASSWD" ]; }
          { command = "/run/current-system/sw/bin/systemctl restart"; options = [ "NOPASSWD" ]; }
        ];
      }
    ];

    # Ensure directories exist
    systemd.tmpfiles.rules = [
      "d ${cfg.stateDirectory} 0755 ${cfg.user} ${cfg.group} -"
      "d ${cfg.logDirectory} 0755 ${cfg.user} ${cfg.group} -"
    ];
  };
}
```

**Usage in configuration.nix:**
```nix
{
  services.styrene-bond-rpc = {
    enable = true;
    authConfig = /etc/styrene/bond-rpc-auth.yaml;
  };
}
```

---

## 6. Summary and Recommendations

### Required RPC Operations

| Operation | Purpose | Authorization | Risk Level |
|-----------|---------|---------------|------------|
| **status** (enhanced) | Device health metrics | `status` permission | Low |
| **exec** (existing) | Command execution | `exec` + allowlist | **HIGH** |
| **reboot** (new) | Safe device reboot | `reboot` permission | Medium |
| **update_config** (new) | Remote configuration | `update_config` permission | Medium |
| **get_logs** (new) | Log retrieval | `view_logs` permission | Low |

### Authorization Model

- **YAML-based configuration** at `/etc/styrene/bond-rpc-auth.yaml`
- **Identity groups** for easier management
- **Principle of least privilege** (default deny)
- **Audit logging** for all operations (JSON format, rotating logs)
- **Exec allowlist** to restrict dangerous commands

### NixOS Integration

- **Systemd service** with hardening (NoNewPrivileges, ProtectSystem, PrivateTmp)
- **Dedicated user/group** (`styrene-rpc`)
- **Sudoers configuration** for privileged operations
- **NixOS module** for declarative configuration
- **State directory** at `/var/lib/styrene-bond-rpc`
- **Log directory** at `/var/log/styrene`

### Key Decisions

1. **Use MessagePack** instead of JSON (per lxmf-message-structure.md recommendation)
2. **Exec allowlist** required to prevent arbitrary code execution
3. **Systemd service** is the correct deployment model for NixOS
4. **Authorization config** should be versioned and deployed via NixOS configuration
5. **Audit logs** must be retained for compliance (30 days minimum)

### Implementation Checklist

- [ ] Enhance StatusResponse with additional fields
- [ ] Implement authorization service (auth.py)
- [ ] Implement audit logger (audit.py)
- [ ] Create RPC message types for new commands (reboot, update_config, get_logs)
- [ ] Implement command handlers (handlers.py)
- [ ] Create RPC server (server.py)
- [ ] Write systemd service file
- [ ] Write NixOS module
- [ ] Write comprehensive tests (90%+ coverage target)
- [ ] Migrate existing RPC client to use Protocol interface (Phase 2 dependency)
- [ ] Document deployment process
