add yml config file, client now does not depends on .env
This commit is contained in:
+3
-31
@@ -1,5 +1,5 @@
|
|||||||
# nexus-sync environment example
|
# nexus-sync server environment example
|
||||||
# Copy this file to .env and replace example secrets before running.
|
# Copy this file to .env and replace example secrets before running the server.
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Server configuration
|
# Server configuration
|
||||||
@@ -15,33 +15,5 @@
|
|||||||
# better to set this explicitly.
|
# better to set this explicitly.
|
||||||
NEXUS_SYNC_CLIENT_TOKENS="dev-client:dev-token"
|
NEXUS_SYNC_CLIENT_TOKENS="dev-client:dev-token"
|
||||||
|
|
||||||
# Logging level for both server and client: DEBUG, INFO, WARNING, ERROR, CRITICAL.
|
# Logging level for the server: DEBUG, INFO, WARNING, ERROR, CRITICAL.
|
||||||
NEXUS_SYNC_LOG_LEVEL="INFO"
|
NEXUS_SYNC_LOG_LEVEL="INFO"
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Client configuration
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Base URL of the nexus-sync server.
|
|
||||||
NEXUS_SYNC_SERVER_URL="http://127.0.0.1:8000"
|
|
||||||
|
|
||||||
# Stable ID of this machine/client. Must match one client_id from
|
|
||||||
# NEXUS_SYNC_CLIENT_TOKENS on the server.
|
|
||||||
NEXUS_SYNC_CLIENT_ID="dev-client"
|
|
||||||
|
|
||||||
# Bearer token for this client. Must match the token paired with
|
|
||||||
# NEXUS_SYNC_CLIENT_ID on the server.
|
|
||||||
NEXUS_SYNC_CLIENT_TOKEN="dev-token"
|
|
||||||
|
|
||||||
# Commands this client is allowed to execute when the server requests them.
|
|
||||||
# Supported presets currently implemented:
|
|
||||||
# - hostname
|
|
||||||
# - network_interfaces
|
|
||||||
#
|
|
||||||
# Safer explicit allowlist:
|
|
||||||
NEXUS_SYNC_ALLOWED_COMMANDS="hostname,network_interfaces"
|
|
||||||
|
|
||||||
# Alternative: allow every locally registered preset. This still does NOT allow
|
|
||||||
# arbitrary shell strings from the server. Uncomment instead of the line above if
|
|
||||||
# desired.
|
|
||||||
# NEXUS_SYNC_ALLOWED_COMMANDS="full_access"
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
a utility that allows you to manage your computers.
|
a utility that allows you to manage your computers.
|
||||||
|
|
||||||
*Main Idea*
|
_Main Idea_
|
||||||
server can send to every client "what to do". Centralized control for all clients.
|
server can send to every client "what to do". Centralized control for all clients.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
@@ -11,6 +11,42 @@ server can send to every client "what to do". Centralized control for all client
|
|||||||
pip install -e .[dev]
|
pip install -e .[dev]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Client config
|
||||||
|
|
||||||
|
The client reads YAML config files. The server still use
|
||||||
|
`.env` for server-side settings such as `NEXUS_SYNC_CLIENT_TOKENS`.
|
||||||
|
|
||||||
|
Client config is searched in this order:
|
||||||
|
|
||||||
|
1. `$(pwd)/nexus.yml`
|
||||||
|
2. `$(pwd)/nexus.yaml`
|
||||||
|
3. `$XDG_CONFIG_HOME/nexus.yml`
|
||||||
|
4. `$XDG_CONFIG_HOME/nexus.yaml`
|
||||||
|
5. `~/.config/nexus.yml`
|
||||||
|
6. `~/.config/nexus.yaml`
|
||||||
|
7. `~/.config/nexus/config.yml`
|
||||||
|
8. `~/.config/nexus/config.yaml`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server_url: "http://127.0.0.1:5852"
|
||||||
|
client_id: "linux-client"
|
||||||
|
client_token: "change-me-client-token"
|
||||||
|
allowed_commands:
|
||||||
|
- name: hostname
|
||||||
|
description: "Get the hostname of the client machine"
|
||||||
|
cmd: "hostname"
|
||||||
|
- name: network_interfaces
|
||||||
|
description: "Get network interface information"
|
||||||
|
cmd: "ip addr show"
|
||||||
|
logging_level: "INFO"
|
||||||
|
```
|
||||||
|
|
||||||
|
Template examples are available in `template/` for Linux, Windows, and Darwin.
|
||||||
|
The client reports only command `name` and `description` to the server; `cmd`
|
||||||
|
stays local to the client config.
|
||||||
|
|
||||||
## Current design
|
## Current design
|
||||||
|
|
||||||
- API contract: [docs/api.md](docs/api.md)
|
- API contract: [docs/api.md](docs/api.md)
|
||||||
@@ -23,7 +59,6 @@ pip install -e .[dev]
|
|||||||
make [client|server]
|
make [client|server]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### To test
|
### To test
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
+19
-8
@@ -32,15 +32,26 @@ client -> server: hello!, i'm $(hostname), uuid= , ts=, stdout= , stderr= , ...
|
|||||||
|
|
||||||
## Разрешённые команды
|
## Разрешённые команды
|
||||||
|
|
||||||
Клиент исполняет только локально разрешённые command presets. Базовая
|
Клиент исполняет только команды, описанные в локальном YAML config-файле.
|
||||||
настройка задаётся переменной окружения `NEXUS_SYNC_ALLOWED_COMMANDS`.
|
Серверу отправляются только `name` и `description`; поле `cmd` остаётся только
|
||||||
|
на клиенте и не управляется сервером.
|
||||||
|
|
||||||
Примеры:
|
Пример:
|
||||||
|
|
||||||
```bash
|
```yaml
|
||||||
NEXUS_SYNC_ALLOWED_COMMANDS=hostname,network_interfaces
|
server_url: "http://127.0.0.1:5852"
|
||||||
NEXUS_SYNC_ALLOWED_COMMANDS=full_access
|
client_id: "linux-client"
|
||||||
|
client_token: "change-me-client-token"
|
||||||
|
allowed_commands:
|
||||||
|
- name: hostname
|
||||||
|
description: "Get the hostname of the client machine"
|
||||||
|
cmd: "hostname"
|
||||||
|
- name: network_interfaces
|
||||||
|
description: "Get network interface information"
|
||||||
|
cmd: "ip addr show"
|
||||||
|
logging_level: "INFO"
|
||||||
```
|
```
|
||||||
|
|
||||||
`full_access` означает доступ ко всем локально зарегистрированным presets. Это
|
Файл ищется как `nexus.yml`/`nexus.yaml` в текущей директории, затем в
|
||||||
не разрешает выполнение произвольных shell-строк от сервера.
|
`$XDG_CONFIG_HOME`, затем в `~/.config`, затем как
|
||||||
|
`~/.config/nexus/config.yml`/`.yaml`.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ requires-python = ">=3.11"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.115.0",
|
"fastapi>=0.115.0",
|
||||||
"pydantic>=2.10.0",
|
"pydantic>=2.10.0",
|
||||||
|
"PyYAML>=6.0.0",
|
||||||
"sqlalchemy>=2.0.0",
|
"sqlalchemy>=2.0.0",
|
||||||
"uvicorn[standard]>=0.34.0",
|
"uvicorn[standard]>=0.34.0",
|
||||||
]
|
]
|
||||||
@@ -23,6 +24,7 @@ dev = [
|
|||||||
"black>=24.0",
|
"black>=24.0",
|
||||||
"pyinstaller>=6.0",
|
"pyinstaller>=6.0",
|
||||||
"mypy>=1.0",
|
"mypy>=1.0",
|
||||||
|
"types-PyYAML>=6.0.12",
|
||||||
"pre-commit>=4.0"
|
"pre-commit>=4.0"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
from nexus_sync.client.config import (
|
|
||||||
COMMAND_ACCESS_ENV,
|
|
||||||
FULL_ACCESS_VALUE,
|
|
||||||
load_command_access_policy,
|
|
||||||
)
|
|
||||||
from nexus_sync.client.execute import (
|
from nexus_sync.client.execute import (
|
||||||
CommandAccessPolicy,
|
CommandAccessPolicy,
|
||||||
execute_command,
|
execute_command,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"COMMAND_ACCESS_ENV",
|
|
||||||
"CommandAccessPolicy",
|
"CommandAccessPolicy",
|
||||||
"FULL_ACCESS_VALUE",
|
|
||||||
"execute_command",
|
"execute_command",
|
||||||
"load_command_access_policy",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import os
|
|
||||||
from collections.abc import Mapping
|
|
||||||
|
|
||||||
from nexus_sync.client.execute import CommandAccessPolicy
|
|
||||||
|
|
||||||
COMMAND_ACCESS_ENV = "NEXUS_SYNC_ALLOWED_COMMANDS"
|
|
||||||
FULL_ACCESS_VALUE = "full_access"
|
|
||||||
|
|
||||||
|
|
||||||
def load_command_access_policy(
|
|
||||||
env: Mapping[str, str] = os.environ,
|
|
||||||
*,
|
|
||||||
default: CommandAccessPolicy | None = None,
|
|
||||||
) -> CommandAccessPolicy:
|
|
||||||
raw_value = env.get(COMMAND_ACCESS_ENV)
|
|
||||||
if raw_value is None or not raw_value.strip():
|
|
||||||
return default or CommandAccessPolicy.deny_all()
|
|
||||||
|
|
||||||
command_names = [item.strip() for item in raw_value.split(",") if item.strip()]
|
|
||||||
if len(command_names) == 1 and command_names[0].lower() == FULL_ACCESS_VALUE:
|
|
||||||
return CommandAccessPolicy.allow_all()
|
|
||||||
if any(command_name.lower() == FULL_ACCESS_VALUE for command_name in command_names):
|
|
||||||
raise ValueError(f"{FULL_ACCESS_VALUE} cannot be mixed with explicit command names")
|
|
||||||
|
|
||||||
return CommandAccessPolicy.allow(command_names)
|
|
||||||
@@ -2,21 +2,25 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import shlex
|
||||||
import socket
|
import socket
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from dataclasses import dataclass
|
from collections.abc import Mapping as MappingABC
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
from typing import Callable, Mapping, Protocol, Self
|
from typing import Callable, Mapping, Protocol, Self
|
||||||
|
|
||||||
|
import yaml # type: ignore[import-untyped]
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from nexus_sync.client.config import load_command_access_policy
|
|
||||||
from nexus_sync.client.execute import (
|
from nexus_sync.client.execute import (
|
||||||
DEFAULT_PRESET_DESCRIPTIONS,
|
DEFAULT_PRESET_DESCRIPTIONS,
|
||||||
DEFAULT_PRESETS,
|
DEFAULT_PRESETS,
|
||||||
CommandAccessPolicy,
|
CommandAccessPolicy,
|
||||||
|
PresetBuilder,
|
||||||
execute_command,
|
execute_command,
|
||||||
)
|
)
|
||||||
from nexus_sync.common import (
|
from nexus_sync.common import (
|
||||||
@@ -29,9 +33,6 @@ from nexus_sync.common import (
|
|||||||
HeartbeatResponse,
|
HeartbeatResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
SERVER_URL_ENV = "NEXUS_SYNC_SERVER_URL"
|
|
||||||
CLIENT_ID_ENV = "NEXUS_SYNC_CLIENT_ID"
|
|
||||||
CLIENT_TOKEN_ENV = "NEXUS_SYNC_CLIENT_TOKEN"
|
|
||||||
CLIENT_VERSION = "0.1.0"
|
CLIENT_VERSION = "0.1.0"
|
||||||
HEARTBEAT_PATH = "/api/v1/client/heartbeat"
|
HEARTBEAT_PATH = "/api/v1/client/heartbeat"
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -67,17 +68,75 @@ class ClientConfig:
|
|||||||
client_id: str
|
client_id: str
|
||||||
token: str
|
token: str
|
||||||
command_access_policy: CommandAccessPolicy
|
command_access_policy: CommandAccessPolicy
|
||||||
|
command_presets: Mapping[str, PresetBuilder] = field(default_factory=lambda: DEFAULT_PRESETS)
|
||||||
|
command_descriptions: Mapping[str, str] = field(
|
||||||
|
default_factory=lambda: DEFAULT_PRESET_DESCRIPTIONS
|
||||||
|
)
|
||||||
|
logging_level: str = "INFO"
|
||||||
|
|
||||||
|
|
||||||
def load_client_config(env: Mapping[str, str] = os.environ) -> ClientConfig:
|
def find_client_config_path(
|
||||||
server_url = _required_env(env, SERVER_URL_ENV).rstrip("/")
|
*,
|
||||||
client_id = _required_env(env, CLIENT_ID_ENV)
|
env: Mapping[str, str] = os.environ,
|
||||||
token = _required_env(env, CLIENT_TOKEN_ENV)
|
cwd: Path | None = None,
|
||||||
|
home: Path | None = None,
|
||||||
|
) -> Path | None:
|
||||||
|
cwd = cwd or Path.cwd()
|
||||||
|
home = home or Path.home()
|
||||||
|
candidates = [
|
||||||
|
cwd / "nexus.yml",
|
||||||
|
cwd / "nexus.yaml",
|
||||||
|
]
|
||||||
|
|
||||||
|
xdg_config_home = env.get("XDG_CONFIG_HOME")
|
||||||
|
if xdg_config_home and xdg_config_home.strip():
|
||||||
|
xdg_dir = Path(xdg_config_home).expanduser()
|
||||||
|
candidates.extend([xdg_dir / "nexus.yml", xdg_dir / "nexus.yaml"])
|
||||||
|
|
||||||
|
candidates.extend(
|
||||||
|
[
|
||||||
|
home / ".config" / "nexus.yml",
|
||||||
|
home / ".config" / "nexus.yaml",
|
||||||
|
home / ".config" / "nexus" / "config.yml",
|
||||||
|
home / ".config" / "nexus" / "config.yaml",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return next((path for path in candidates if path.is_file()), None)
|
||||||
|
|
||||||
|
|
||||||
|
def load_client_config(
|
||||||
|
env: Mapping[str, str] = os.environ,
|
||||||
|
*,
|
||||||
|
config_path: Path | str | None = None,
|
||||||
|
) -> ClientConfig:
|
||||||
|
path = Path(config_path) if config_path is not None else find_client_config_path(env=env)
|
||||||
|
if path is None:
|
||||||
|
raise ClientConfigError("client config file not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_config = yaml.safe_load(path.read_text())
|
||||||
|
except OSError as error:
|
||||||
|
raise ClientConfigError(f"failed to read client config file {path}: {error}") from error
|
||||||
|
except yaml.YAMLError as error:
|
||||||
|
raise ClientConfigError(f"failed to parse client config file {path}: {error}") from error
|
||||||
|
|
||||||
|
if not isinstance(raw_config, MappingABC):
|
||||||
|
raise ClientConfigError("client config file must contain a YAML mapping")
|
||||||
|
|
||||||
|
server_url = _required_config_string(raw_config, "server_url").rstrip("/")
|
||||||
|
client_id = _required_config_string(raw_config, "client_id")
|
||||||
|
token = _required_config_string(raw_config, "client_token")
|
||||||
|
commands = _load_configured_commands(raw_config.get("allowed_commands", []))
|
||||||
|
|
||||||
return ClientConfig(
|
return ClientConfig(
|
||||||
server_url=server_url,
|
server_url=server_url,
|
||||||
client_id=client_id,
|
client_id=client_id,
|
||||||
token=token,
|
token=token,
|
||||||
command_access_policy=load_command_access_policy(env),
|
command_access_policy=CommandAccessPolicy.allow(commands.presets),
|
||||||
|
command_presets=commands.presets,
|
||||||
|
command_descriptions=commands.descriptions,
|
||||||
|
logging_level=str(raw_config.get("logging_level", "INFO")).strip() or "INFO",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -99,20 +158,27 @@ def build_heartbeat_request(
|
|||||||
local_time=datetime.now().astimezone(),
|
local_time=datetime.now().astimezone(),
|
||||||
uptime_seconds=None,
|
uptime_seconds=None,
|
||||||
),
|
),
|
||||||
available_commands=list_available_commands(config.command_access_policy),
|
available_commands=list_available_commands(
|
||||||
|
config.command_access_policy,
|
||||||
|
presets=config.command_presets,
|
||||||
|
descriptions=config.command_descriptions,
|
||||||
|
),
|
||||||
last_command_result=last_command_result,
|
last_command_result=last_command_result,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def list_available_commands(
|
def list_available_commands(
|
||||||
access_policy: CommandAccessPolicy,
|
access_policy: CommandAccessPolicy,
|
||||||
|
*,
|
||||||
|
presets: Mapping[str, PresetBuilder] = DEFAULT_PRESETS,
|
||||||
|
descriptions: Mapping[str, str] = DEFAULT_PRESET_DESCRIPTIONS,
|
||||||
) -> list[ClientCommandCapability]:
|
) -> list[ClientCommandCapability]:
|
||||||
return [
|
return [
|
||||||
ClientCommandCapability(
|
ClientCommandCapability(
|
||||||
name=name,
|
name=name,
|
||||||
description=DEFAULT_PRESET_DESCRIPTIONS.get(name, ""),
|
description=descriptions.get(name, ""),
|
||||||
)
|
)
|
||||||
for name in sorted(DEFAULT_PRESETS)
|
for name in sorted(presets)
|
||||||
if access_policy.allows(name)
|
if access_policy.allows(name)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -171,6 +237,7 @@ def run_once(
|
|||||||
return executor(
|
return executor(
|
||||||
response.command,
|
response.command,
|
||||||
access_policy=config.command_access_policy,
|
access_policy=config.command_access_policy,
|
||||||
|
presets=config.command_presets,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -198,10 +265,62 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _required_env(env: Mapping[str, str], name: str) -> str:
|
@dataclass(frozen=True)
|
||||||
value = env.get(name)
|
class _ConfiguredCommands:
|
||||||
if value is None or not value.strip():
|
presets: dict[str, PresetBuilder]
|
||||||
raise ClientConfigError(f"{name} is required")
|
descriptions: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_configured_commands(raw_commands: object) -> _ConfiguredCommands:
|
||||||
|
if raw_commands is None:
|
||||||
|
raw_commands = []
|
||||||
|
if not isinstance(raw_commands, list):
|
||||||
|
raise ClientConfigError("allowed_commands must be a list")
|
||||||
|
|
||||||
|
presets: dict[str, PresetBuilder] = {}
|
||||||
|
descriptions: dict[str, str] = {}
|
||||||
|
for index, raw_command in enumerate(raw_commands):
|
||||||
|
if not isinstance(raw_command, MappingABC):
|
||||||
|
raise ClientConfigError(f"allowed_commands[{index}] must be a mapping")
|
||||||
|
name = _required_config_string(raw_command, "name", prefix=f"allowed_commands[{index}]")
|
||||||
|
description = _required_config_string(
|
||||||
|
raw_command,
|
||||||
|
"description",
|
||||||
|
prefix=f"allowed_commands[{index}]",
|
||||||
|
)
|
||||||
|
command_line = _required_config_string(
|
||||||
|
raw_command, "cmd", prefix=f"allowed_commands[{index}]"
|
||||||
|
)
|
||||||
|
argv = shlex.split(command_line, posix=os.name != "nt")
|
||||||
|
if not argv:
|
||||||
|
raise ClientConfigError(f"allowed_commands[{index}].cmd must not be empty")
|
||||||
|
if name in presets:
|
||||||
|
raise ClientConfigError(f"duplicate allowed command name: {name}")
|
||||||
|
presets[name] = _static_preset(argv)
|
||||||
|
descriptions[name] = description
|
||||||
|
|
||||||
|
return _ConfiguredCommands(presets=presets, descriptions=descriptions)
|
||||||
|
|
||||||
|
|
||||||
|
def _static_preset(argv: list[str]) -> PresetBuilder:
|
||||||
|
def build(args: Mapping[str, object]) -> list[str]:
|
||||||
|
if args:
|
||||||
|
raise ValueError("configured commands do not accept arguments")
|
||||||
|
return list(argv)
|
||||||
|
|
||||||
|
return build
|
||||||
|
|
||||||
|
|
||||||
|
def _required_config_string(
|
||||||
|
config: MappingABC[object, object],
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
prefix: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
value = config.get(name)
|
||||||
|
display_name = f"{prefix}.{name}" if prefix else name
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise ClientConfigError(f"{display_name} is required")
|
||||||
return value.strip()
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from nexus_sync.utils import configure_logging
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
configure_logging()
|
configure_logging()
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
uvicorn.run(app, host="0.0.0.0", port=5852)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# nexus-sync client config template for macOS/Darwin
|
||||||
|
server_url: "http://127.0.0.1:5852"
|
||||||
|
client_id: "darwin-client"
|
||||||
|
client_token: "change-me-client-token"
|
||||||
|
allowed_commands:
|
||||||
|
- name: hostname
|
||||||
|
description: "Get the hostname of the client machine"
|
||||||
|
cmd: "hostname"
|
||||||
|
- name: network_interfaces
|
||||||
|
description: "Get network interface information"
|
||||||
|
cmd: "ifconfig"
|
||||||
|
logging_level: "INFO"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# nexus-sync client config template for Linux
|
||||||
|
server_url: "http://127.0.0.1:5852"
|
||||||
|
client_id: "linux-client"
|
||||||
|
client_token: "change-me-client-token"
|
||||||
|
allowed_commands:
|
||||||
|
- name: hostname
|
||||||
|
description: "Get the hostname of the client machine"
|
||||||
|
cmd: "hostname"
|
||||||
|
- name: network_interfaces
|
||||||
|
description: "Get network interface information"
|
||||||
|
cmd: "ip addr show"
|
||||||
|
logging_level: "INFO"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# nexus-sync client config template for Windows
|
||||||
|
server_url: "http://127.0.0.1:5852"
|
||||||
|
client_id: "windows-client"
|
||||||
|
client_token: "change-me-client-token"
|
||||||
|
allowed_commands:
|
||||||
|
- name: hostname
|
||||||
|
description: "Get the hostname of the client machine"
|
||||||
|
cmd: "hostname"
|
||||||
|
- name: network_interfaces
|
||||||
|
description: "Get network interface information"
|
||||||
|
cmd: "ipconfig /all"
|
||||||
|
logging_level: "INFO"
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from nexus_sync.client.config import COMMAND_ACCESS_ENV, load_command_access_policy
|
|
||||||
from nexus_sync.client.execute import CommandAccessPolicy
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_command_access_policy_defaults_to_deny_all() -> None:
|
|
||||||
policy = load_command_access_policy({})
|
|
||||||
|
|
||||||
assert not policy.full_access
|
|
||||||
assert not policy.allows("hostname")
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_command_access_policy_uses_default_when_env_is_missing() -> None:
|
|
||||||
default = CommandAccessPolicy.allow(["hostname"])
|
|
||||||
|
|
||||||
policy = load_command_access_policy({}, default=default)
|
|
||||||
|
|
||||||
assert policy == default
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_command_access_policy_supports_command_allowlist() -> None:
|
|
||||||
policy = load_command_access_policy(
|
|
||||||
{COMMAND_ACCESS_ENV: "hostname, network_interfaces"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert policy.allows("hostname")
|
|
||||||
assert policy.allows("network_interfaces")
|
|
||||||
assert not policy.allows("unknown")
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_command_access_policy_supports_full_access() -> None:
|
|
||||||
policy = load_command_access_policy({COMMAND_ACCESS_ENV: "full_access"})
|
|
||||||
|
|
||||||
assert policy.full_access
|
|
||||||
assert policy.allows("hostname")
|
|
||||||
assert policy.allows("future_registered_preset")
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_command_access_policy_rejects_mixed_full_access() -> None:
|
|
||||||
with pytest.raises(ValueError, match="cannot be mixed"):
|
|
||||||
load_command_access_policy({COMMAND_ACCESS_ENV: "hostname,full_access"})
|
|
||||||
@@ -7,13 +7,11 @@ import pytest
|
|||||||
|
|
||||||
from nexus_sync.client.execute import CommandAccessPolicy
|
from nexus_sync.client.execute import CommandAccessPolicy
|
||||||
from nexus_sync.client.runtime import (
|
from nexus_sync.client.runtime import (
|
||||||
CLIENT_ID_ENV,
|
|
||||||
CLIENT_TOKEN_ENV,
|
|
||||||
SERVER_URL_ENV,
|
|
||||||
ClientConfig,
|
ClientConfig,
|
||||||
ClientConfigError,
|
ClientConfigError,
|
||||||
HeartbeatError,
|
HeartbeatError,
|
||||||
build_heartbeat_request,
|
build_heartbeat_request,
|
||||||
|
find_client_config_path,
|
||||||
list_available_commands,
|
list_available_commands,
|
||||||
load_client_config,
|
load_client_config,
|
||||||
main,
|
main,
|
||||||
@@ -39,34 +37,77 @@ def _config(policy: CommandAccessPolicy | None = None) -> ClientConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_load_client_config_reads_required_env_and_normalizes_server_url() -> None:
|
def test_find_client_config_path_prefers_current_directory(tmp_path) -> None:
|
||||||
config = load_client_config(
|
cwd_config = tmp_path / "nexus.yaml"
|
||||||
{
|
cwd_config.write_text("client_id: current\n")
|
||||||
SERVER_URL_ENV: "https://nexus.example.test/",
|
xdg_config = tmp_path / "xdg" / "nexus.yml"
|
||||||
CLIENT_ID_ENV: "macbook-pro-01",
|
xdg_config.parent.mkdir()
|
||||||
CLIENT_TOKEN_ENV: "client-token",
|
xdg_config.write_text("client_id: xdg\n")
|
||||||
"NEXUS_SYNC_ALLOWED_COMMANDS": "hostname",
|
|
||||||
}
|
path = find_client_config_path(
|
||||||
|
env={"XDG_CONFIG_HOME": str(xdg_config.parent)},
|
||||||
|
cwd=tmp_path,
|
||||||
|
home=tmp_path / "home",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert path == cwd_config
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_client_config_path_checks_xdg_and_home_locations(tmp_path) -> None:
|
||||||
|
home = tmp_path / "home"
|
||||||
|
nested_config = home / ".config" / "nexus" / "config.yml"
|
||||||
|
nested_config.parent.mkdir(parents=True)
|
||||||
|
nested_config.write_text("client_id: nested\n")
|
||||||
|
|
||||||
|
path = find_client_config_path(env={}, cwd=tmp_path, home=home)
|
||||||
|
|
||||||
|
assert path == nested_config
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_client_config_reads_yaml_file_and_normalizes_server_url(tmp_path) -> None:
|
||||||
|
config_path = tmp_path / "nexus.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
'server_url: "https://nexus.example.test/"',
|
||||||
|
'client_id: "macbook-pro-01"',
|
||||||
|
'client_token: "client-token"',
|
||||||
|
"allowed_commands:",
|
||||||
|
" - name: hostname",
|
||||||
|
' description: "Configured hostname"',
|
||||||
|
' cmd: "hostname"',
|
||||||
|
" - name: network_interfaces",
|
||||||
|
' description: "Configured interfaces"',
|
||||||
|
' cmd: "ip addr show"',
|
||||||
|
'logging_level: "INFO"',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_client_config(config_path=config_path)
|
||||||
|
|
||||||
assert config.server_url == "https://nexus.example.test"
|
assert config.server_url == "https://nexus.example.test"
|
||||||
assert config.client_id == "macbook-pro-01"
|
assert config.client_id == "macbook-pro-01"
|
||||||
assert config.token == "client-token"
|
assert config.token == "client-token"
|
||||||
assert config.command_access_policy.allows("hostname")
|
assert config.command_access_policy.allows("hostname")
|
||||||
assert not config.command_access_policy.allows("network_interfaces")
|
assert config.command_access_policy.allows("network_interfaces")
|
||||||
|
assert config.command_descriptions["hostname"] == "Configured hostname"
|
||||||
|
assert config.command_presets["network_interfaces"]({}) == ["ip", "addr", "show"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("missing_name", [SERVER_URL_ENV, CLIENT_ID_ENV, CLIENT_TOKEN_ENV])
|
@pytest.mark.parametrize("missing_name", ["server_url", "client_id", "client_token"])
|
||||||
def test_load_client_config_requires_env_values(missing_name: str) -> None:
|
def test_load_client_config_requires_yaml_values(tmp_path, missing_name: str) -> None:
|
||||||
env = {
|
values = {
|
||||||
SERVER_URL_ENV: "https://nexus.example.test",
|
"server_url": '"https://nexus.example.test"',
|
||||||
CLIENT_ID_ENV: "macbook-pro-01",
|
"client_id": '"macbook-pro-01"',
|
||||||
CLIENT_TOKEN_ENV: "client-token",
|
"client_token": '"client-token"',
|
||||||
}
|
}
|
||||||
del env[missing_name]
|
del values[missing_name]
|
||||||
|
config_path = tmp_path / "nexus.yml"
|
||||||
|
config_path.write_text("\n".join(f"{key}: {value}" for key, value in values.items()))
|
||||||
|
|
||||||
with pytest.raises(ClientConfigError, match=missing_name):
|
with pytest.raises(ClientConfigError, match=missing_name):
|
||||||
load_client_config(env)
|
load_client_config(config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
def test_build_heartbeat_request_contains_client_state(monkeypatch) -> None:
|
def test_build_heartbeat_request_contains_client_state(monkeypatch) -> None:
|
||||||
@@ -270,6 +311,7 @@ def test_run_once_executes_command_with_configured_access_policy() -> None:
|
|||||||
def fake_executor(command: Command, **kwargs) -> CommandResult:
|
def fake_executor(command: Command, **kwargs) -> CommandResult:
|
||||||
seen["command"] = command
|
seen["command"] = command
|
||||||
seen["access_policy"] = kwargs["access_policy"]
|
seen["access_policy"] = kwargs["access_policy"]
|
||||||
|
seen["presets"] = kwargs["presets"]
|
||||||
return CommandResult(
|
return CommandResult(
|
||||||
command_id=command.id,
|
command_id=command.id,
|
||||||
status=CommandResultStatus.SUCCEEDED,
|
status=CommandResultStatus.SUCCEEDED,
|
||||||
@@ -283,6 +325,7 @@ def test_run_once_executes_command_with_configured_access_policy() -> None:
|
|||||||
assert result.status == CommandResultStatus.SUCCEEDED
|
assert result.status == CommandResultStatus.SUCCEEDED
|
||||||
assert seen["command"].name == "hostname"
|
assert seen["command"].name == "hostname"
|
||||||
assert seen["access_policy"] == policy
|
assert seen["access_policy"] == policy
|
||||||
|
assert "hostname" in seen["presets"]
|
||||||
|
|
||||||
|
|
||||||
def test_run_once_sends_previous_command_result() -> None:
|
def test_run_once_sends_previous_command_result() -> None:
|
||||||
@@ -313,22 +356,29 @@ def test_run_once_sends_previous_command_result() -> None:
|
|||||||
assert seen["last_command_result"] == previous_result
|
assert seen["last_command_result"] == previous_result
|
||||||
|
|
||||||
|
|
||||||
def test_main_returns_non_zero_for_missing_config(monkeypatch, caplog) -> None:
|
def test_main_returns_non_zero_for_missing_config(monkeypatch, caplog, tmp_path) -> None:
|
||||||
monkeypatch.delenv(SERVER_URL_ENV, raising=False)
|
monkeypatch.chdir(tmp_path)
|
||||||
monkeypatch.delenv(CLIENT_ID_ENV, raising=False)
|
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
||||||
monkeypatch.delenv(CLIENT_TOKEN_ENV, raising=False)
|
|
||||||
|
|
||||||
exit_code = main([])
|
exit_code = main([])
|
||||||
|
|
||||||
assert exit_code == 1
|
assert exit_code == 1
|
||||||
assert SERVER_URL_ENV in caplog.text
|
assert "client config file not found" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_main_logs_success_without_command(monkeypatch, caplog) -> None:
|
def test_main_logs_success_without_command(monkeypatch, caplog, tmp_path) -> None:
|
||||||
caplog.set_level("INFO")
|
caplog.set_level("INFO")
|
||||||
monkeypatch.setenv(SERVER_URL_ENV, "https://nexus.example.test")
|
monkeypatch.chdir(tmp_path)
|
||||||
monkeypatch.setenv(CLIENT_ID_ENV, "macbook-pro-01")
|
(tmp_path / "nexus.yml").write_text(
|
||||||
monkeypatch.setenv(CLIENT_TOKEN_ENV, "client-token")
|
"\n".join(
|
||||||
|
[
|
||||||
|
'server_url: "https://nexus.example.test"',
|
||||||
|
'client_id: "macbook-pro-01"',
|
||||||
|
'client_token: "client-token"',
|
||||||
|
"allowed_commands: []",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
monkeypatch.setattr("nexus_sync.client.runtime.run_once", lambda _config: None)
|
monkeypatch.setattr("nexus_sync.client.runtime.run_once", lambda _config: None)
|
||||||
|
|
||||||
exit_code = main([])
|
exit_code = main([])
|
||||||
@@ -337,11 +387,19 @@ def test_main_logs_success_without_command(monkeypatch, caplog) -> None:
|
|||||||
assert "heartbeat accepted; no command" in caplog.text
|
assert "heartbeat accepted; no command" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_main_logs_command_result(monkeypatch, caplog) -> None:
|
def test_main_logs_command_result(monkeypatch, caplog, tmp_path) -> None:
|
||||||
caplog.set_level("INFO")
|
caplog.set_level("INFO")
|
||||||
monkeypatch.setenv(SERVER_URL_ENV, "https://nexus.example.test")
|
monkeypatch.chdir(tmp_path)
|
||||||
monkeypatch.setenv(CLIENT_ID_ENV, "macbook-pro-01")
|
(tmp_path / "nexus.yml").write_text(
|
||||||
monkeypatch.setenv(CLIENT_TOKEN_ENV, "client-token")
|
"\n".join(
|
||||||
|
[
|
||||||
|
'server_url: "https://nexus.example.test"',
|
||||||
|
'client_id: "macbook-pro-01"',
|
||||||
|
'client_token: "client-token"',
|
||||||
|
"allowed_commands: []",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nexus_sync.client.runtime.run_once",
|
"nexus_sync.client.runtime.run_once",
|
||||||
lambda _config: CommandResult(
|
lambda _config: CommandResult(
|
||||||
|
|||||||
Reference in New Issue
Block a user