فهرست منبع

fix(install): auto-provision SpoolBuddy kiosk API key in full-mode install

  Full-mode install wrote CHANGE_ME_AFTER_SETUP as SPOOLBUDDY_API_KEY because
  no admin exists yet to create a real one. On reboot the kiosk launched with
  that placeholder, AuthContext rejected it, and the user hit the Bambuddy
  login page instead of the kiosk. Standalone mode was unaffected — users
  paste a real key from their existing Bambuddy before install.

  Adds backend/app/cli.py with a kiosk-bootstrap subcommand that creates a
  scoped APIKey row directly in the DB (can_read_status=True, everything else
  false) and prints the full key to stdout. install.sh full-mode runs it as
  the bambuddy service user after create_bambuddy_service, captures the key,
  and sed-replaces the placeholder in spoolbuddy/.env. Idempotent with
  --force for re-installs.

  Drops the outdated "create an API key and edit .env" next-step block since
  the kiosk is now provisioned automatically.
maziggy 1 ماه پیش
والد
کامیت
3502ab33c5
4فایلهای تغییر یافته به همراه288 افزوده شده و 4 حذف شده
  1. 1 0
      CHANGELOG.md
  2. 117 0
      backend/app/cli.py
  3. 131 0
      backend/tests/unit/test_cli.py
  4. 39 4
      spoolbuddy/install/install.sh

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 0
CHANGELOG.md


+ 117 - 0
backend/app/cli.py

@@ -0,0 +1,117 @@
+"""Bambuddy administrative CLI.
+
+Invoked via ``python -m backend.app.cli <subcommand>``.
+
+Currently provides ``kiosk-bootstrap`` for creating the SpoolBuddy kiosk
+API key during install (see ``spoolbuddy/install/install.sh``).
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import sys
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import async_sessionmaker
+
+from backend.app.core.auth import generate_api_key
+from backend.app.core.database import async_session as default_session_maker, init_db
+from backend.app.models.api_key import APIKey
+
+DEFAULT_KIOSK_KEY_NAME = "spoolbuddy-kiosk"
+
+
+class KioskBootstrapError(RuntimeError):
+    """Raised when an existing kiosk key would be silently overwritten."""
+
+
+async def kiosk_bootstrap(
+    name: str,
+    *,
+    force: bool,
+    session_maker: async_sessionmaker | None = None,
+    ensure_schema: bool = True,
+) -> str:
+    """Create (or rotate) an API key for the SpoolBuddy kiosk and return it.
+
+    The returned value is the one-time full key string; callers are responsible
+    for writing it somewhere secure — it cannot be retrieved again.
+    """
+    if ensure_schema and session_maker is None:
+        await init_db()
+
+    maker = session_maker or default_session_maker
+
+    async with maker() as db:
+        existing = (await db.execute(select(APIKey).where(APIKey.name == name))).scalar_one_or_none()
+
+        if existing and not force:
+            raise KioskBootstrapError(
+                f"API key {name!r} already exists (prefix={existing.key_prefix}). Re-run with --force to rotate."
+            )
+
+        if existing:
+            await db.delete(existing)
+            await db.flush()
+
+        full_key, key_hash, key_prefix = generate_api_key()
+        row = APIKey(
+            name=name,
+            key_hash=key_hash,
+            key_prefix=key_prefix,
+            can_queue=False,
+            can_control_printer=False,
+            can_read_status=True,
+            printer_ids=None,
+            enabled=True,
+            expires_at=None,
+        )
+        db.add(row)
+        await db.commit()
+        return full_key
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(
+        prog="python -m backend.app.cli",
+        description="Bambuddy administrative commands",
+    )
+    sub = parser.add_subparsers(dest="command", required=True)
+
+    kiosk = sub.add_parser(
+        "kiosk-bootstrap",
+        help="Create an API key for the SpoolBuddy kiosk",
+        description=(
+            "Create (or rotate with --force) an API key scoped for the SpoolBuddy "
+            "kiosk. The full key is printed to stdout — capture it into "
+            "spoolbuddy/.env as SPOOLBUDDY_API_KEY."
+        ),
+    )
+    kiosk.add_argument(
+        "--name",
+        default=DEFAULT_KIOSK_KEY_NAME,
+        help=f"Key name in the DB (default: {DEFAULT_KIOSK_KEY_NAME})",
+    )
+    kiosk.add_argument(
+        "--force",
+        action="store_true",
+        help="Rotate an existing key with the same name (deletes the old one)",
+    )
+
+    args = parser.parse_args(argv)
+
+    if args.command == "kiosk-bootstrap":
+        try:
+            key = asyncio.run(kiosk_bootstrap(args.name, force=args.force))
+        except KioskBootstrapError as exc:
+            print(str(exc), file=sys.stderr)
+            return 1
+        print(key)
+        return 0
+
+    return 2
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 131 - 0
backend/tests/unit/test_cli.py

@@ -0,0 +1,131 @@
+"""Unit tests for the ``backend.app.cli`` kiosk-bootstrap subcommand."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator
+
+import pytest
+import pytest_asyncio
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+from backend.app.cli import DEFAULT_KIOSK_KEY_NAME, KioskBootstrapError, kiosk_bootstrap
+from backend.app.core.auth import _validate_api_key
+from backend.app.core.database import Base
+from backend.app.models.api_key import APIKey
+
+
+@pytest_asyncio.fixture
+async def session_maker() -> AsyncGenerator[async_sessionmaker, None]:
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+    try:
+        yield maker
+    finally:
+        await engine.dispose()
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_bootstrap_creates_key_when_none_exists(session_maker):
+    key = await kiosk_bootstrap(
+        DEFAULT_KIOSK_KEY_NAME,
+        force=False,
+        session_maker=session_maker,
+        ensure_schema=False,
+    )
+
+    assert key.startswith("bb_")
+    assert len(key) > 20
+
+    async with session_maker() as db:
+        rows = (await db.execute(select(APIKey))).scalars().all()
+        assert len(rows) == 1
+        row = rows[0]
+        assert row.name == DEFAULT_KIOSK_KEY_NAME
+        assert row.enabled is True
+        assert row.can_queue is False
+        assert row.can_control_printer is False
+        assert row.can_read_status is True
+        assert row.printer_ids is None
+        assert row.expires_at is None
+        assert row.key_prefix.startswith("bb_")
+        assert row.key_hash != key  # stored value is a hash, not the plaintext
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_bootstrap_refuses_to_overwrite_without_force(session_maker):
+    first = await kiosk_bootstrap(
+        DEFAULT_KIOSK_KEY_NAME,
+        force=False,
+        session_maker=session_maker,
+        ensure_schema=False,
+    )
+
+    with pytest.raises(KioskBootstrapError) as exc_info:
+        await kiosk_bootstrap(
+            DEFAULT_KIOSK_KEY_NAME,
+            force=False,
+            session_maker=session_maker,
+            ensure_schema=False,
+        )
+
+    assert "already exists" in str(exc_info.value)
+    assert "--force" in str(exc_info.value)
+
+    # First key survives unchanged and still validates
+    async with session_maker() as db:
+        row = (await db.execute(select(APIKey))).scalar_one()
+        validated = await _validate_api_key(db, first)
+        assert validated is not None
+        assert validated.id == row.id
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_bootstrap_force_rotates_existing_key(session_maker):
+    first = await kiosk_bootstrap(
+        DEFAULT_KIOSK_KEY_NAME,
+        force=False,
+        session_maker=session_maker,
+        ensure_schema=False,
+    )
+    second = await kiosk_bootstrap(
+        DEFAULT_KIOSK_KEY_NAME,
+        force=True,
+        session_maker=session_maker,
+        ensure_schema=False,
+    )
+
+    assert first != second
+
+    async with session_maker() as db:
+        rows = (await db.execute(select(APIKey))).scalars().all()
+        assert len(rows) == 1  # old row was deleted, not duplicated
+
+        # Old key no longer validates, new key does
+        assert await _validate_api_key(db, first) is None
+        validated = await _validate_api_key(db, second)
+        assert validated is not None
+        assert validated.name == DEFAULT_KIOSK_KEY_NAME
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_bootstrap_custom_name(session_maker):
+    key = await kiosk_bootstrap(
+        "custom-kiosk-name",
+        force=False,
+        session_maker=session_maker,
+        ensure_schema=False,
+    )
+
+    async with session_maker() as db:
+        row = (await db.execute(select(APIKey))).scalar_one()
+        assert row.name == "custom-kiosk-name"
+        validated = await _validate_api_key(db, key)
+        assert validated is not None
+        assert validated.name == "custom-kiosk-name"

+ 39 - 4
spoolbuddy/install/install.sh

@@ -783,6 +783,43 @@ EOF
     success "Bambuddy service created and enabled"
     success "Bambuddy service created and enabled"
 }
 }
 
 
+bootstrap_spoolbuddy_kiosk_key() {
+    # Provision an API key for the local SpoolBuddy kiosk and write it into
+    # spoolbuddy/.env. Runs against the Bambuddy DB directly (via the CLI),
+    # so the bambuddy service does not need to be running yet.
+    info "Provisioning SpoolBuddy kiosk API key..."
+
+    local env_file="$INSTALL_PATH/spoolbuddy/.env"
+    if [[ ! -f "$env_file" ]]; then
+        warn "SpoolBuddy env file not found at $env_file — skipping kiosk key bootstrap"
+        return
+    fi
+
+    # CWD must be $INSTALL_PATH so `python -m backend.app.cli` finds the backend
+    # package on sys.path (matches the systemd unit's WorkingDirectory).
+    local kiosk_key
+    if ! kiosk_key="$(cd "$INSTALL_PATH" && sudo -u "$BAMBUDDY_SERVICE_USER" \
+            env DATA_DIR="$INSTALL_PATH/data" LOG_DIR="$INSTALL_PATH/logs" \
+            "$INSTALL_PATH/venv/bin/python" -m backend.app.cli kiosk-bootstrap --force)"; then
+        error "Failed to bootstrap SpoolBuddy kiosk API key"
+    fi
+
+    if [[ -z "$kiosk_key" || "$kiosk_key" != bb_* ]]; then
+        error "CLI returned an invalid API key (got: ${kiosk_key:0:8}...)"
+    fi
+
+    if ! grep -q '^SPOOLBUDDY_API_KEY=' "$env_file"; then
+        error "Sentinel 'SPOOLBUDDY_API_KEY=' line missing in $env_file"
+    fi
+
+    # Escape for sed replacement (the key is base64url-safe, no slashes, but be defensive)
+    local escaped_key
+    escaped_key=$(printf '%s\n' "$kiosk_key" | sed -e 's/[\/&]/\\&/g')
+    sed -i "s/^SPOOLBUDDY_API_KEY=.*/SPOOLBUDDY_API_KEY=${escaped_key}/" "$env_file"
+
+    success "SpoolBuddy kiosk API key provisioned"
+}
+
 # ─────────────────────────────────────────────────────────────────────────────
 # ─────────────────────────────────────────────────────────────────────────────
 # System Strip-Down (dedicated appliance — remove unnecessary services/packages)
 # System Strip-Down (dedicated appliance — remove unnecessary services/packages)
 # ─────────────────────────────────────────────────────────────────────────────
 # ─────────────────────────────────────────────────────────────────────────────
@@ -1504,6 +1541,7 @@ main() {
         create_bambuddy_directories
         create_bambuddy_directories
         create_bambuddy_env
         create_bambuddy_env
         create_bambuddy_service
         create_bambuddy_service
+        bootstrap_spoolbuddy_kiosk_key
         echo ""
         echo ""
     fi
     fi
 
 
@@ -1532,10 +1570,7 @@ main() {
         echo -e "  ${BOLD}Next steps:${NC}"
         echo -e "  ${BOLD}Next steps:${NC}"
         echo -e "    1. Reboot (required for kiosk, Plymouth splash, and hardware changes)"
         echo -e "    1. Reboot (required for kiosk, Plymouth splash, and hardware changes)"
         echo -e "    2. The touchscreen kiosk will start automatically after reboot"
         echo -e "    2. The touchscreen kiosk will start automatically after reboot"
-        echo -e "    3. On another device, open ${CYAN}http://$ip_addr:$BAMBUDDY_PORT${NC}"
-        echo -e "    4. Go to Settings -> API Keys and create an API key"
-        echo -e "    5. Update the API key in: ${CYAN}$INSTALL_PATH/spoolbuddy/.env${NC}"
-        echo -e "    6. Restart SpoolBuddy: ${CYAN}sudo systemctl restart spoolbuddy${NC}"
+        echo -e "    3. On another device, open ${CYAN}http://$ip_addr:$BAMBUDDY_PORT${NC} to complete first-run admin setup"
     fi
     fi
 
 
     echo ""
     echo ""

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است