synqro 0.1.1

Synqro Zero-Trust OTA updater library
Documentation
#!/usr/bin/env python3
"""Synqro Post-Install Configuration Generator.

Cross-platform, idempotent post-install script that:
  - Generates a cryptographically-random installation_id
  - Writes synqro_ota.yaml with 0o600 permissions (TOCTOU-safe)
  - Updates .gitignore
  - Creates required cache/keystore directories
  - Prints platform-specific keychain setup instructions

Security properties:
  - Uses os.open(O_CREAT|O_WRONLY|O_EXCL) for atomic config creation
  - No secrets are ever written to disk — only REPLACE_ME sentinels
  - No shell=True, no eval(), no exec()
  - No hardcoded tokens or passwords
  - All file handles closed via context managers or explicit os.close()
"""

from __future__ import annotations

import hashlib
import logging
import os
import platform
import stat
import sys
from pathlib import Path

import yaml  # PyYAML — listed in requirements.txt


# ---------------------------------------------------------------------------
# Structured logger (library-safe: no print/eprintln)
# ---------------------------------------------------------------------------

logging.basicConfig(
    format='{"time": "%(asctime)s", "level": "%(levelname)s", "logger": "%(name)s", "msg": %(message)s}',
    level=logging.INFO,
    stream=sys.stdout,
)
log = logging.getLogger("synqro.post_install")

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

CONFIG_FILENAME = "synqro_ota.yaml"
GITIGNORE_FILENAME = ".gitignore"
CACHE_DIR = ".synqro_cache"
KEYSTORE_DIR = ".synqro_keystore"
STAGING_DIR = ".synqro_cache/staging"
BACKUP_DIR = ".synqro_cache/backup"

GITIGNORE_BLOCK = """\
# Synqro — auto-added by post-install
synqro_ota.yaml
.synqro_cache/
.synqro_keystore/
"""

CONFIG_HEADER = (
    "# Synqro Configuration \u2014 DO NOT COMMIT THIS FILE\n"
    "# Auto-generated by Synqro post-install. See docs for field descriptions.\n"
)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _generate_installation_id() -> str:
    """Return a hex SHA-256 of 32 CSPRNG bytes."""
    raw = os.urandom(32)
    return hashlib.sha256(raw).hexdigest()


def _load_existing_config(config_path: Path) -> dict | None:
    """Return parsed YAML dict if the config file exists, else None."""
    if not config_path.exists():
        return None
    try:
        text = config_path.read_text(encoding="utf-8")
        return yaml.safe_load(text)
    except (OSError, yaml.YAMLError) as exc:
        log.warning('"Failed to read existing config: %s"', exc)
        return None


def _is_valid_installation_id(cfg: dict | None) -> bool:
    """Return True when cfg contains a non-REPLACE_ME installation_id."""
    if not isinstance(cfg, dict):
        return False
    try:
        inst_id = cfg["synqro"]["installation_id"]
    except (KeyError, TypeError):
        return False
    return bool(inst_id) and inst_id != "REPLACE_ME"


def _build_config(installation_id: str) -> dict:
    """Build the full synqro_ota.yaml data structure."""
    return {
        "synqro": {
            "version": "1.0",
            "installation_id": installation_id,
            "source": {
                "provider": "github",
                "owner": "REPLACE_ME",
                "repo": "REPLACE_ME",
                "branch": "main",
                "manifest_path": "synqro_manifest.json",
            },
            "auth": {
                "token_source": "keychain",
                "env_var": "",
            },
            "crypto": {
                "release_signing_pubkey": "REPLACE_ME",
                "github_api_cert_pin": "REPLACE_ME",
            },
            "update": {
                "check_interval_seconds": 3600,
                "max_retries": 3,
                "retry_backoff_base_seconds": 5,
                "download_timeout_seconds": 60,
                "max_payload_size_bytes": 52428800,
                "staging_dir": ".synqro_cache/staging",
                "backup_dir": ".synqro_cache/backup",
            },
            "rollback": {
                "enabled": True,
                "health_check_timeout_seconds": 30,
                "max_backup_versions": 2,
            },
            "reporting": {
                "enabled": True,
                "telegram_token_source": "keychain",
                "telegram_token_env_var": "",
                "telegram_chat_id": "REPLACE_ME",
                "scrub_patterns": [
                    r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b",
                    r"\b(?:\d{1,3}\.){3}\d{1,3}\b",
                    r"ghp_[A-Za-z0-9]{36}",
                    r"(?i)(password|secret|token|key)\s*[=:>]\s*\S+",
                ],
            },
            "logging": {
                "level": "warn",
                "structured": True,
                "audit_log_path": ".synqro_cache/audit.log.jsonl",
                "audit_hmac_enabled": True,
            },
        }
    }


def _write_config_atomic(config_path: Path, content: str) -> None:
    """Write config atomically with TOCTOU-safe O_EXCL, mode 0o600."""
    flags = os.O_CREAT | os.O_WRONLY | os.O_EXCL
    try:
        fd = os.open(str(config_path), flags, 0o600)
    except FileExistsError:
        # Race: another process created the file between our check and write.
        # Treat as "already initialised" — safe to abort.
        log.warning('"Config file appeared unexpectedly during atomic write — aborting write."')
        raise
    try:
        os.write(fd, content.encode("utf-8"))
    finally:
        os.close(fd)

    # Enforce 0o600 explicitly after write (defence-in-depth; umask may vary)
    os.chmod(str(config_path), stat.S_IRUSR | stat.S_IWUSR)


def _update_gitignore(base_dir: Path) -> None:
    """Append Synqro lines to .gitignore if not already present."""
    gitignore_path = base_dir / GITIGNORE_FILENAME

    # Warn if no .git directory — we still create/update .gitignore
    git_dir = base_dir / ".git"
    if not git_dir.is_dir():
        sys.stdout.write(
            "WARNING: No .git/ directory found in the project root. "
            ".gitignore was updated, but this does not appear to be a Git repository.\n"
        )

    existing = ""
    if gitignore_path.exists():
        try:
            existing = gitignore_path.read_text(encoding="utf-8")
        except OSError as exc:
            log.warning('"Could not read .gitignore: %s"', exc)

    if "synqro_ota.yaml" in existing and ".synqro_cache/" in existing:
        log.info('"Synqro entries already present in .gitignore — skipping."')
        return

    try:
        with gitignore_path.open("a", encoding="utf-8") as fh:
            if existing and not existing.endswith("\n"):
                fh.write("\n")
            fh.write(GITIGNORE_BLOCK)
        log.info('"Updated .gitignore with Synqro entries."')
    except OSError as exc:
        log.error('"Failed to update .gitignore: %s"', exc)
        sys.exit(1)


def _create_dir_0700(path: Path) -> None:
    """Create directory with 0o700 permissions, idempotent."""
    try:
        path.mkdir(mode=0o700, parents=True, exist_ok=True)
        # chmod after mkdir to override any restrictive umask
        os.chmod(str(path), stat.S_IRWXU)
        log.info('"Created directory: %s"', path)
    except OSError as exc:
        log.error('"Failed to create directory %s: %s"', path, exc)
        sys.exit(1)


# ---------------------------------------------------------------------------
# Platform keychain instructions
# ---------------------------------------------------------------------------


def _print_keychain_instructions() -> None:
    """Print platform-specific commands the user must run to store secrets."""
    system = platform.system()

    sys.stdout.write("\n" + "=" * 70 + "\n")
    sys.stdout.write("KEYCHAIN SETUP — run these commands to store your secrets securely\n")
    sys.stdout.write("=" * 70 + "\n\n")

    if system == "Linux":
        sys.stdout.write("Platform: Linux (libsecret / secret-tool)\n\n")
        sys.stdout.write(
            "  # Store GitHub Personal Access Token:\n"
            "  secret-tool store --label='Synqro GitHub Token' \\\n"
            "      service synqro account github_token\n\n"
            "  # Store Telegram Bot Token:\n"
            "  secret-tool store --label='Synqro Telegram Token' \\\n"
            "      service synqro account telegram_token\n\n"
            "  # Verify storage:\n"
            "  secret-tool lookup service synqro account github_token\n"
            "  secret-tool lookup service synqro account telegram_token\n\n"
            "  NOTE: Install libsecret if missing: sudo apt install libsecret-tools\n"
        )

    elif system == "Darwin":
        sys.stdout.write("Platform: macOS (Keychain Access / security)\n\n")
        sys.stdout.write(
            "  # Store GitHub Personal Access Token:\n"
            "  security add-generic-password -s synqro -a github_token -w\n\n"
            "  # Store Telegram Bot Token:\n"
            "  security add-generic-password -s synqro -a telegram_token -w\n\n"
            "  # Verify storage:\n"
            "  security find-generic-password -s synqro -a github_token -w\n"
            "  security find-generic-password -s synqro -a telegram_token -w\n"
        )

    elif system == "Windows":
        sys.stdout.write("Platform: Windows (Credential Manager)\n\n")
        sys.stdout.write(
            "  # Install the 'keyring' Python package, then run:\n"
            "  python -c \"import keyring; keyring.set_password('synqro', 'github_token', input('Token: '))\"\n"
            "  python -c \"import keyring; keyring.set_password('synqro', 'telegram_token', input('Token: '))\"\n\n"
            "  # Alternatively via PowerShell (no shell=True used in Synqro):\n"
            "  # cmdkey /generic:synqro_github_token /user:synqro /pass\n"
        )

    else:
        sys.stdout.write(
            "Platform: Unknown — please store secrets in your OS keychain manually.\n"
            "  Environment variables are also supported:\n"
            "    SYNQRO_GITHUB_TOKEN=<token> python -m synqro ...\n"
            "    SYNQRO_TELEGRAM_TOKEN=<token> python -m synqro ...\n"
        )

    sys.stdout.write("\n")


# ---------------------------------------------------------------------------
# Setup checklist
# ---------------------------------------------------------------------------


def _print_setup_checklist() -> None:
    """Print one-time setup checklist to stdout."""
    sys.stdout.write("\n" + "=" * 70 + "\n")
    sys.stdout.write("SYNQRO SETUP CHECKLIST\n")
    sys.stdout.write("=" * 70 + "\n\n")
    sys.stdout.write(
        "  Step 1: Generate an Ed25519 signing keypair\n"
        "          openssl genpkey -algorithm ed25519 -out synqro_signing.key\n"
        "          openssl pkey -in synqro_signing.key -pubout -out synqro_signing.pub\n"
        "          # Paste the public key content into synqro_ota.yaml -> crypto.release_signing_pubkey\n\n"
        "  Step 2: Store your GitHub PAT in the OS keychain\n"
        "          (See keychain instructions above)\n\n"
        "  Step 3: Store your Telegram bot token in the OS keychain\n"
        "          (See keychain instructions above)\n\n"
        "  Step 4: Fill in all remaining REPLACE_ME fields in synqro_ota.yaml:\n"
        "          - source.owner        (your GitHub username/org)\n"
        "          - source.repo         (your GitHub repo name)\n"
        "          - crypto.github_api_cert_pin  (SHA-256 of GitHub API TLS cert)\n"
        "          - reporting.telegram_chat_id  (your Telegram chat ID)\n\n"
        "  Step 5: Verify your configuration:\n"
        "          synqro verify\n\n"
        "  IMPORTANT: Never commit synqro_ota.yaml — it contains your installation_id.\n"
        "             The .gitignore has been updated to exclude it automatically.\n"
    )
    sys.stdout.write("=" * 70 + "\n\n")


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------


def main() -> int:
    """Entry point. Returns exit code."""
    # Resolve project root as the parent of the scripts/ directory
    scripts_dir = Path(__file__).resolve().parent
    base_dir = scripts_dir.parent
    config_path = base_dir / CONFIG_FILENAME

    log.info('"Synqro post-install starting. Base dir: %s"', base_dir)

    # ------------------------------------------------------------------
    # Idempotency check
    # ------------------------------------------------------------------
    existing_cfg = _load_existing_config(config_path)
    if _is_valid_installation_id(existing_cfg):
        sys.stdout.write("Config already exists, skipping.\n")
        log.info('"Valid synqro_ota.yaml already present — nothing to do."')
        return 0

    # ------------------------------------------------------------------
    # Generate installation_id
    # ------------------------------------------------------------------
    installation_id = _generate_installation_id()
    log.info('"Generated new installation_id (first 8 chars): %s..."', installation_id[:8])

    # ------------------------------------------------------------------
    # Build YAML content
    # ------------------------------------------------------------------
    config_data = _build_config(installation_id)
    yaml_body = yaml.dump(
        config_data,
        default_flow_style=False,
        allow_unicode=True,
        sort_keys=False,
        width=120,
    )
    full_content = CONFIG_HEADER + yaml_body

    # ------------------------------------------------------------------
    # Write config atomically (TOCTOU-safe, 0o600)
    # ------------------------------------------------------------------
    try:
        _write_config_atomic(config_path, full_content)
        log.info('"Wrote synqro_ota.yaml with mode 0o600."')
    except FileExistsError:
        # Created between our check and write — treat as already initialised
        sys.stdout.write("Config already exists, skipping.\n")
        return 0
    except OSError as exc:
        log.error('"Failed to write synqro_ota.yaml: %s"', exc)
        return 1

    # ------------------------------------------------------------------
    # Create required directories (0o700)
    # ------------------------------------------------------------------
    for rel_dir in (CACHE_DIR, KEYSTORE_DIR, STAGING_DIR, BACKUP_DIR):
        _create_dir_0700(base_dir / rel_dir)

    # ------------------------------------------------------------------
    # Update .gitignore
    # ------------------------------------------------------------------
    _update_gitignore(base_dir)

    # ------------------------------------------------------------------
    # Keychain instructions + setup checklist
    # ------------------------------------------------------------------
    _print_keychain_instructions()
    _print_setup_checklist()

    sys.stdout.write(
        "Post-install complete.\n"
        "synqro_ota.yaml written to: {}\n".format(config_path)
    )
    return 0


if __name__ == "__main__":
    sys.exit(main())