diff --git a/.env b/.env deleted file mode 100644 index e29e5de67..000000000 --- a/.env +++ /dev/null @@ -1,7 +0,0 @@ -# .env -# Enter all origins allowed to access the Trustpoint server (IPs, mDNS, or DNS domains) -# Example: TP_URLS=10.10.0.2, mytrustpoint.local:8443, trustpoint.example.com -# Note: Do not include the protocol (http:// or https://) in the URLs, and separate multiple URLs with commas. -# You only need to include the port if it is not the default (80 for HTTP, 443 for HTTPS). -# No need to include localhost, unless you are using a custom port. -TP_URLS=trustpoint.local, \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..840ea61cf --- /dev/null +++ b/.env.example @@ -0,0 +1,60 @@ +# Copy this file to .env and adjust it before running Docker Compose. +# +# Usage: +# cp .env.example .env +# $EDITOR .env +# docker compose up -d + +# PostgreSQL connection used by the Trustpoint containers. +# Development default: admin / testing321 +# Use a long, randomly generated password outside local testing: +# openssl rand -base64 32 +POSTGRES_DB=trustpoint_db +DATABASE_USER=admin +DATABASE_PASSWORD=testing321 +DATABASE_HOST=postgres +DATABASE_PORT=5432 + +# TLS Server Certificate Configuration +# Comma-separated lists of addresses/names where Trustpoint is reachable. +# These are used for: +# - Subject Alternative Names (SANs) in the TLS certificate +# - Django ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS +# At least one value must be specified for production deployments. +TP_TLS_IPV4_ADDRESSES=127.0.0.1 +TP_TLS_IPV6_ADDRESSES=::1 +TP_TLS_DNS_NAMES=localhost + +# HTTP and HTTPS ports for external access +# Only include non-standard ports in CSRF_TRUSTED_ORIGINS (default: 80 for HTTP, 443 for HTTPS) +# TP_HTTP_PORT=80 +# TP_HTTPS_PORT=443 + +# Optional mail settings. Leave EMAIL_HOST empty to use Django's console backend. +# DEFAULT_FROM_EMAIL=no-reply@trustpoint.de +# EMAIL_HOST= +# EMAIL_PORT=587 +# EMAIL_USE_TLS= +# EMAIL_USE_SSL= +# EMAIL_HOST_USER= +# EMAIL_HOST_PASSWORD= +# EMAIL_TIMEOUT=10 + +# ======================================================================== +# Auto-Setup Configuration (Skip Setup Wizard) +# ======================================================================== +# If TP_AUTO_SETUP=true, Trustpoint will automatically configure itself +# from environment variables, bypassing the interactive setup wizard. + +# Enable automatic setup from environment variables (true/false) +# TP_AUTO_SETUP=false + +# Superuser credentials (REQUIRED if TP_AUTO_SETUP=true) +# TP_ADMIN_USERNAME=admin +# TP_ADMIN_PASSWORD=testing321 + +# Inject demo data for testing (true/false) +# TP_INJECT_DEMO_DATA=false + +# Note: When auto-setup is enabled, the TLS certificate will be automatically +# generated using the TP_TLS_* variables specified above. diff --git a/.github/workflows/backup-restore.yml b/.github/workflows/backup-restore.yml index d39476c96..dae1b2ad8 100644 --- a/.github/workflows/backup-restore.yml +++ b/.github/workflows/backup-restore.yml @@ -17,6 +17,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 + - name: Setup .env file + run: cp .env.example .env + - name: Build and start PostgreSQL run: | docker build -t trustpointproject/postgres:latest -f docker/db/Dockerfile . diff --git a/.github/workflows/docker-test-compose.yml b/.github/workflows/docker-test-compose.yml index 511e4a688..ef1079bc0 100644 --- a/.github/workflows/docker-test-compose.yml +++ b/.github/workflows/docker-test-compose.yml @@ -15,6 +15,9 @@ jobs: - name: Checkout code uses: actions/checkout@v7 + - name: Setup .env file + run: cp .env.example .env + # Step: Run Docker Compose to start services. # Uses the hoverkraft-tech/compose-action to manage Docker Compose commands. # "compose-file": path to the docker-compose file. diff --git a/.github/workflows/r_200_feature_test.yml b/.github/workflows/r_200_feature_test.yml index e0ea166fc..510ffaef5 100644 --- a/.github/workflows/r_200_feature_test.yml +++ b/.github/workflows/r_200_feature_test.yml @@ -19,6 +19,9 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v7 + + - name: Setup .env file + run: cp .env.example .env # Build and start Docker containers with docker-compose - name: Build and start Docker containers diff --git a/.github/workflows/zap.yml b/.github/workflows/zap.yml index d21a0c909..e3859cd01 100644 --- a/.github/workflows/zap.yml +++ b/.github/workflows/zap.yml @@ -20,6 +20,9 @@ jobs: - name: Checkout Code uses: actions/checkout@v7 + - name: Setup .env file + run: cp .env.example .env + - name: Start Trustpoint via docker-compose run: | docker compose -f docker-compose.yml up -d diff --git a/.gitignore b/.gitignore index 7000fc282..3dcb0a258 100644 --- a/.gitignore +++ b/.gitignore @@ -195,3 +195,5 @@ tests/client/* node_modules/ workflow2Folder/ + +.env diff --git a/docker-compose.softhsm.yml b/docker-compose.softhsm.yml index 0173e5f8e..4da4ef409 100644 --- a/docker-compose.softhsm.yml +++ b/docker-compose.softhsm.yml @@ -5,22 +5,21 @@ services: dockerfile: docker/trustpoint/Dockerfile image: trustpointproject/trustpoint:latest container_name: trustpoint + restart: unless-stopped ports: - - "80:80" - - "443:443" + - "${TP_HTTP_PORT:-80}:80" + - "${TP_HTTPS_PORT:-443}:443" depends_on: - postgres: - condition: service_started softhsm: condition: service_healthy volumes: - ./trustpoint/settings:/var/www/html/trustpoint/trustpoint/settings environment: - POSTGRES_DB: "trustpoint_db" - POSTGRES_USER_FILE: /run/secrets/db_user - POSTGRES_PASSWORD_FILE: /run/secrets/db_password - DATABASE_HOST: "postgres" - DATABASE_PORT: "5432" + POSTGRES_DB: "${POSTGRES_DB:-trustpoint_db}" + DATABASE_USER_FILE: /run/secrets/db_user + DATABASE_PASSWORD_FILE: /run/secrets/db_password + DATABASE_HOST: "${DATABASE_HOST:-postgres}" + DATABASE_PORT: "${DATABASE_PORT:-5432}" HSM_PIN_FILE: /run/secrets/hsm_pin HSM_SO_PIN_FILE: /run/secrets/hsm_so_pin secrets: @@ -28,6 +27,12 @@ services: - db_password - hsm_pin - hsm_so_pin + healthcheck: + test: ["CMD-SHELL", "curl -fsk --max-time 5 https://localhost/ > /dev/null"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 90s postgres: build: @@ -35,23 +40,32 @@ services: dockerfile: docker/db/Dockerfile image: trustpointproject/postgres:latest container_name: postgres + restart: unless-stopped ports: - - "5432:5432" + # Bind to loopback only – PostgreSQL must not be reachable from the network. + - "127.0.0.1:5432:5432" volumes: - postgres_data:/var/lib/postgresql environment: POSTGRES_USER_FILE: /run/secrets/db_user POSTGRES_PASSWORD_FILE: /run/secrets/db_password - POSTGRES_DB: "trustpoint_db" + POSTGRES_DB: "${POSTGRES_DB:-trustpoint_db}" secrets: - db_user - db_password + healthcheck: + test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s softhsm: build: context: . dockerfile: docker/softhsm/Dockerfile container_name: softhsm + restart: unless-stopped ports: - "5657:5657" healthcheck: diff --git a/docker-compose.yml b/docker-compose.yml index 1663e3bd6..43b462619 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,35 +5,43 @@ services: dockerfile: docker/trustpoint/Dockerfile image: trustpointproject/trustpoint:latest container_name: trustpoint + restart: unless-stopped ports: - - "80:80" - - "443:443" - depends_on: - - postgres + - "${TP_HTTP_PORT:-80}:80" + - "${TP_HTTPS_PORT:-443}:443" + env_file: + - .env environment: - POSTGRES_DB: "trustpoint_db" - DATABASE_USER: "admin" - DATABASE_PASSWORD: "testing321" - DATABASE_HOST: "postgres" - DATABASE_PORT: "5432" - TP_URLS: ${TP_URLS} + POSTGRES_DB: "${POSTGRES_DB:-trustpoint_db}" + DATABASE_USER: "${DATABASE_USER:-admin}" + DATABASE_PASSWORD: "${DATABASE_PASSWORD:-testing321}" + DATABASE_HOST: "${DATABASE_HOST:-postgres}" + DATABASE_PORT: "${DATABASE_PORT:-5432}" + healthcheck: + test: ["CMD-SHELL", "curl -fsk --max-time 5 https://localhost/ > /dev/null"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 90s trustpoint-worker: build: context: . dockerfile: docker/trustpoint/Dockerfile image: trustpointproject/trustpoint:latest + restart: on-failure depends_on: - - postgres - - trustpoint + trustpoint: + condition: service_healthy + env_file: + - .env environment: - POSTGRES_DB: "trustpoint_db" - DATABASE_USER: "admin" - DATABASE_PASSWORD: "testing321" - DATABASE_HOST: "postgres" - DATABASE_PORT: "5432" + POSTGRES_DB: "${POSTGRES_DB:-trustpoint_db}" + DATABASE_USER: "${DATABASE_USER:-admin}" + DATABASE_PASSWORD: "${DATABASE_PASSWORD:-testing321}" + DATABASE_HOST: "${DATABASE_HOST:-postgres}" + DATABASE_PORT: "${DATABASE_PORT:-5432}" TRUSTPOINT_SERVICE_ROLE: "worker" - restart: on-failure postgres: build: @@ -41,14 +49,22 @@ services: dockerfile: docker/db/Dockerfile image: trustpointproject/postgres:latest container_name: postgres + restart: unless-stopped ports: - - "5432:5432" + # Bind to loopback only – PostgreSQL must not be reachable from the network. + - "127.0.0.1:5432:5432" volumes: - postgres_data:/var/lib/postgresql environment: - POSTGRES_USER: "admin" - POSTGRES_PASSWORD: "testing321" - POSTGRES_DB: "trustpoint_db" + POSTGRES_USER: "${DATABASE_USER:-admin}" + POSTGRES_PASSWORD: "${DATABASE_PASSWORD:-testing321}" + POSTGRES_DB: "${POSTGRES_DB:-trustpoint_db}" + healthcheck: + test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s volumes: - postgres_data: \ No newline at end of file + postgres_data: diff --git a/docker/trustpoint/wizard/update_tls.sh b/docker/trustpoint/wizard/update_tls.sh index 241fdc31c..62ae72459 100644 --- a/docker/trustpoint/wizard/update_tls.sh +++ b/docker/trustpoint/wizard/update_tls.sh @@ -26,7 +26,13 @@ mkdir -p "$NGINX_TLS_DIR" log INFO "Move TLS Server credentials into $NGINX_TLS_DIR" # Copies the TLS-Server credentials into the nginx TLS directory. -if ! mv /var/www/html/trustpoint/docker/trustpoint/nginx/tls/* "$NGINX_TLS_DIR" +shopt -s nullglob +TLS_FILES=(/var/www/html/trustpoint/docker/trustpoint/nginx/tls/*) +shopt -u nullglob + +if [ ${#TLS_FILES[@]} -eq 0 ]; then + log INFO "No staged TLS files found; keeping existing nginx TLS files" +elif ! mv "${TLS_FILES[@]}" "$NGINX_TLS_DIR" then log ERROR "Failed to copy Trustpoint TLS files to $NGINX_TLS_DIR." exit 5 diff --git a/docker/trustpoint/wizard/update_tls_nginx.sh b/docker/trustpoint/wizard/update_tls_nginx.sh index ff08805a8..c420bd533 100644 --- a/docker/trustpoint/wizard/update_tls_nginx.sh +++ b/docker/trustpoint/wizard/update_tls_nginx.sh @@ -39,7 +39,13 @@ mkdir -p "$NGINX_TLS_DIR" log INFO "Move TLS Server credentials into $NGINX_TLS_DIR" # Copies the TLS-Server credentials into the nginx TLS directory. -if ! mv /var/www/html/trustpoint/docker/trustpoint/nginx/tls/* "$NGINX_TLS_DIR" +shopt -s nullglob +TLS_FILES=(/var/www/html/trustpoint/docker/trustpoint/nginx/tls/*) +shopt -u nullglob + +if [ ${#TLS_FILES[@]} -eq 0 ]; then + log INFO "No staged TLS files found; keeping existing nginx TLS files" +elif ! mv "${TLS_FILES[@]}" "$NGINX_TLS_DIR" then log ERROR "Failed to copy Trustpoint TLS files to $NGINX_TLS_DIR." exit 3 @@ -75,4 +81,4 @@ else fi log INFO "TLS certificate update for nginx completed successfully" -exit 0 \ No newline at end of file +exit 0 diff --git a/docs/source/getting_started/quickstart_setup.rst b/docs/source/getting_started/quickstart_setup.rst index a651ec58c..f08ebf6f1 100644 --- a/docs/source/getting_started/quickstart_setup.rst +++ b/docs/source/getting_started/quickstart_setup.rst @@ -3,266 +3,314 @@ Quickstart Setup Guide ====================== -This guide provides an introduction to Trustpoint and instructions for setting up the Trustpoint using Docker and Docker Compose. +This guide covers setting up Trustpoint using Docker and Docker Compose. -Prerequisites ---------------- -Make sure you have the following installed: +Prerequisites +------------- -1. **Docker**: Version 20.10 or higher. -2. **Docker Compose**: Version v2.32.4 or higher. -3. **Git**: To clone the Trustpoint repository. +- **Docker** 20.10 or higher +- **Docker Compose** v2.32.4 or higher +- **Git** Getting started with the Trustpoint Wizard script ---------------------------------------------------- +-------------------------------------------------- -The ``tp_wizard.sh`` offers a convenient guided CLI for setting up a Docker container environment. +The ``tp_wizard.sh`` script provides a guided CLI for setting up a Docker environment. +This requires a Linux host. -1. **Clone** the Trustpoint repository +.. code-block:: bash - First, clone the Trustpoint source code from the official repository: + git clone https://github.com/Trustpoint-Project/trustpoint.git + cd trustpoint + ./tp_wizard.sh - .. code-block:: bash - - git clone https://github.com/Trustpoint-Project/trustpoint.git - cd trustpoint +Convenience commands: -2. **Interactively configure** the Trustpoint environment using the script - - This requires a Linux host. - - .. code-block:: bash - - ./tp_wizard.sh - -| For testing, you can use ``./tp_wizard.sh up`` to directly start the Trustpoint and integrated postgres DB container with default testing credentials. -| Strictly for testing use only, use ``./tp_wizard.sh up demo`` to additionally start SFTP and mailpit demo servers. -| Use ``./tp_wizard.sh down`` to stop and remove all containers. -| To completely remove the Trustpoint volume and delete all stored data, you can use ``./tp_wizard.sh nuke``. +- ``./tp_wizard.sh up`` — start Trustpoint and Postgres with default testing credentials (testing only). +- ``./tp_wizard.sh up demo`` — additionally start SFTP and mailpit demo servers (testing only). +- ``./tp_wizard.sh down`` — stop and remove all containers. +- ``./tp_wizard.sh nuke`` — remove all containers and delete all stored data. Getting started with Docker Compose --------------------------------------- +------------------------------------ + +The .env file +^^^^^^^^^^^^^ + +.. warning:: + + Trustpoint **requires** a ``.env`` file to start. If no ``.env`` file is present, startup will fail with an error. + +Docker Compose and ``tp_wizard.sh`` read the ``.env`` file in the project root. +The repository contains development defaults. For production-like deployments, +copy the example and replace at least the database password: + +.. code-block:: bash + + cp .env.example .env + +The following variables are supported: + +.. list-table:: + :widths: 30 10 60 + :header-rows: 1 + + * - Variable + - Required + - Description + * - ``DATABASE_USER`` + - No + - PostgreSQL username. Defaults to ``admin`` for local testing. + * - ``DATABASE_PASSWORD`` + - No + - PostgreSQL password. Defaults to ``testing321`` for local testing. Generate a strong value for production-like deployments: ``openssl rand -base64 32`` + * - ``POSTGRES_DB`` + - No + - Database name. Defaults to ``trustpoint_db``. + * - ``DATABASE_HOST`` + - No + - Database host used by the Trustpoint containers. Defaults to ``postgres``. + * - ``DATABASE_PORT`` + - No + - Database port used by the Trustpoint containers. Defaults to ``5432``. + * - ``TP_TLS_IPV4_ADDRESSES`` + - No + - Comma-separated IPv4 addresses where Trustpoint is reachable. Used for TLS certificate SANs and Django ``ALLOWED_HOSTS``. Defaults to ``127.0.0.1``. + * - ``TP_TLS_IPV6_ADDRESSES`` + - No + - Comma-separated IPv6 addresses where Trustpoint is reachable. Used for TLS certificate SANs and Django ``ALLOWED_HOSTS``. Defaults to ``::1``. + * - ``TP_TLS_DNS_NAMES`` + - No + - Comma-separated DNS names where Trustpoint is reachable. Used for TLS certificate SANs and Django ``ALLOWED_HOSTS``. Defaults to ``localhost``. + * - ``TP_HTTP_PORT`` + - No + - HTTP port for external access. Used in ``CSRF_TRUSTED_ORIGINS`` and Docker port mapping. Defaults to ``80``. + * - ``TP_HTTPS_PORT`` + - No + - HTTPS port for external access. Used in ``CSRF_TRUSTED_ORIGINS`` and Docker port mapping. Defaults to ``443``. + * - ``DEFAULT_FROM_EMAIL`` + - No + - Sender address for Trustpoint emails. Defaults to ``no-reply@trustpoint.de``. + * - ``EMAIL_HOST`` + - No + - SMTP host. Leave empty to use Django's console backend. + +Auto-Setup from Environment Variables +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Trustpoint can be configured to skip the interactive setup wizard and auto-configure from environment variables. +This is useful for automated deployments, CI/CD pipelines, or headless installations. + +.. important:: + + When ``TP_AUTO_SETUP=true``, the ``TP_ADMIN_USERNAME`` and ``TP_ADMIN_PASSWORD`` variables become **required**. + +To enable auto-setup, add the following variables to your ``.env`` file: + +.. list-table:: + :widths: 30 10 60 + :header-rows: 1 + + * - Variable + - Required + - Description + * - ``TP_AUTO_SETUP`` + - Yes + - Set to ``true`` to enable automatic setup. Default: ``false`` + * - ``TP_ADMIN_USERNAME`` + - Yes* + - Initial superuser username. *Required when auto-setup is enabled. + * - ``TP_ADMIN_PASSWORD`` + - Yes* + - Initial superuser password. *Required when auto-setup is enabled. + * - ``TP_INJECT_DEMO_DATA`` + - No + - Set to ``true`` to inject demo domains and devices for testing. Default: ``false`` -Step-by-Step Setup (Load from Dockerhub) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -1. **Download** `docker-compose.yml `_ +.. note:: -2. **Pull and run the Trustpoint and Postgres Containers** + The TLS certificate SANs (``TP_TLS_IPV4_ADDRESSES``, ``TP_TLS_IPV6_ADDRESSES``, ``TP_TLS_DNS_NAMES``) + are also used to configure Django's ``ALLOWED_HOSTS`` and ``CSRF_TRUSTED_ORIGINS``, ensuring consistency + between your TLS certificate and Django security settings. - You can pull the images and start Trustpoint and Postgres containers with following command: +Auto-setup ``.env`` example: - .. code-block:: bash +.. code-block:: bash - docker compose up -d + # Database configuration + POSTGRES_DB=trustpoint_db + DATABASE_USER=admin + DATABASE_PASSWORD=correct-horse-battery-staple + DATABASE_HOST=postgres + DATABASE_PORT=5432 - - **-d**: Runs the container in detached mode. + # TLS/Network configuration + TP_TLS_IPV4_ADDRESSES=10.0.0.5 + TP_TLS_DNS_NAMES=trustpoint.local - .. note:: + # Auto-setup configuration + TP_AUTO_SETUP=true + TP_ADMIN_USERNAME=admin + TP_ADMIN_PASSWORD=secure_admin_password_here + TP_INJECT_DEMO_DATA=false - If the specified ports are already in use on your system, modify the port mapping in the `docker-compose.yml` file accordingly. +Setup (Load from Docker Hub) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Step-by-Step Setup (Build container) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +1. Download `docker-compose.yml `_ + and `.env.example `_. -1. **Clone the Trustpoint Repository** +2. Create the ``.env`` file as described above. - First, clone the Trustpoint source code from the official repository: +3. Start all containers: .. code-block:: bash - git clone https://github.com/Trustpoint-Project/trustpoint.git - cd trustpoint - - This command downloads the Trustpoint source code to your local machine and navigates into the project directory. + docker compose up -d .. note:: - The database connection between the containers uses default credentials for testing. THIS IS INSECURE. - It is highly encouraged to change the default credentials in the `docker-compose.yml` file before building the containers. -2. **Edit the .env file to specify allowed URLs** + If the specified ports are already in use, adjust the port mappings in ``docker-compose.yml``. - Open the `.env` file in the project root and set the `TP_URLS` variable to include the URLs you will use to access Trustpoint. For example: +Setup (Build from source) +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +1. Clone the repository: .. code-block:: bash - TP_URLS=trustpoint.myfactory.local,localhost:8443 + git clone https://github.com/Trustpoint-Project/trustpoint.git + cd trustpoint -3. **Build the Trustpoint and Postgres Docker Images** +2. Create the ``.env`` file as described above. - Use docker compose to build the Trustpoint and Postgres images from the source: +3. Build the images: .. code-block:: bash docker compose build -4. **Run the Trustpoint and Postgres Containers** - - Start the Trustpoint and Postgres containers using the images you just built: +4. Start all containers: .. code-block:: bash docker compose up -d - - **-d**: Runs the container in detached mode. - .. note:: - If the specified ports are already in use on your system, modify the port mapping in the `docker-compose.yml` file accordingly. - + If the specified ports are already in use, adjust the port mappings in ``docker-compose.yml``. Getting Started with Docker ---------------------------- -Step-by-Step Setup (Load from Dockerhub) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -1. **Pull the Trustpoint Docker Image** +Setup (Load from Docker Hub) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - First, pull the Trustpoint and Postgres docker images from Docker Hub. This command will download the pre-built container images directly: +1. Pull the images: .. code-block:: bash - docker pull trustpointproject/trustpoint:latest - docker pull trustpointproject/postgres:latest - - These commands pull the latest versions of the Trustpoint and Postgres images. + docker pull trustpointproject/trustpoint:latest + docker pull trustpointproject/postgres:latest -2. **Run the Trustpoint and Postgres Containers with a Custom Name and Port Mappings** - - Once the images are downloaded, you can start containers with custom names and ports mappings: +2. Run the containers: .. code-block:: bash - docker run -d --name postgres -v "postgres_data":/var/lib/postgresql/data -p 5432:5432 trustpointproject/postgres:latest - docker run -d --name trustpoint -p 80:80 -p 443:443 trustpointproject/trustpoint:latest - - ``E.g.: docker run -d --name postgres-v2.0.0 -v "postgres-v2.0.0":/var/lib/postgresql/data -p 5432:5432 trustpointproject/postgres:latest`` - - - **-d**: Runs the container in detached mode. - - **--name trustpoint**: Names the Trustpoint container `trustpoint`. - - **--name postgres**: Names the Postgres container `postgres`. - - **-p 80:80**: Maps the Trustpoint container's HTTP port to your local machine's port 80. - - **-p 443:443**: Maps the Trustpoint container's HTTPs port to your local machine's port 443. - - **-p 5432:5432**: Maps the Postgres container's TCP port to your local machine's port 5432. - - **-v postgres_data:/var/lib/postgresql/data**: Creates a volume for Postgres to persist data. + docker run -d --name postgres- \ + -v postgres_data-:/var/lib/postgresql \ + -p 127.0.0.1:5432:5432 \ + -e POSTGRES_USER= \ + -e POSTGRES_PASSWORD= \ + -e POSTGRES_DB=trustpoint_db \ + trustpointproject/postgres:latest + + docker run -d --name trustpoint- \ + --link postgres- \ + -p 80:80 -p 443:443 \ + -e POSTGRES_DB=trustpoint_db \ + -e DATABASE_USER= \ + -e DATABASE_PASSWORD= \ + -e DATABASE_HOST=postgres- \ + -e DATABASE_PORT=5432 \ + trustpointproject/trustpoint:latest .. note:: - If the specified ports are already in use on your system, modify the port mapping in the command accordingly. + PostgreSQL 18+ stores data under ``/var/lib/postgresql/18/main``. + Mount the volume at ``/var/lib/postgresql``, not ``/var/lib/postgresql/data``. -Step-by-Step Setup (Build container) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Setup (Build from source) +^^^^^^^^^^^^^^^^^^^^^^^^^^ -1. **Clone the Trustpoint Repository** - - First, clone the Trustpoint source code from the official repository: +1. Clone the repository and build the images: .. code-block:: bash git clone https://github.com/Trustpoint-Project/trustpoint.git cd trustpoint - - This command downloads the Trustpoint source code to your local machine and navigates into the project directory. - -2. **Build the Postgres and Trustpoint Docker Images** - - Use Docker to build the Postgres and Trustpoint images: - - .. code-block:: bash - docker build -t trustpointproject/postgres:latest -f docker/db/Dockerfile . docker build -t trustpointproject/trustpoint:latest -f docker/trustpoint/Dockerfile . - - **-t**: Tags the image with the name `trustpoint` / `postgres`. - - **-f**: specifies the filepath of the `dockerfile`.` - - **.**: Specifies the current directory as the build context. - -3. **Run the Trustpoint Container with a Custom Name and Port Mappings** - - Start the database and Trustpoint container using the images you just built, with custom names and both port mappings: - - .. code-block:: bash - - docker run -d --name postgres -p5432:5432 -v"postgres_data":/var/lib/postgresql/data -ePOSTGRES_USER=admin -ePOSTGRES_PASSWORD=testing321 -ePOSTGRES_DB=trustpoint_db trustpointproject/postgres:latest - docker run -d --name trustpoint --link postgres -p80:80 -p443:443 -ePOSTGRES_DB=trustpoint_db -eDATABASE_USER=admin -eDATABASE_PASSWORD=testing321 -eDATABASE_HOST="postgres" -eDATABASE_PORT=5432 trustpointproject/trustpoint:latest - - **E.g.:** +2. Run the containers (replace ```` and ```` with strong values): .. code-block:: bash - docker run -d --name postgres-v2.0.0 -p5432:5432 -vpostgres_data-v2.0.0:/var/lib/postgresql/data -ePOSTGRES_USER=admin -ePOSTGRES_PASSWORD=testing321 -ePOSTGRES_DB=trustpoint_db trustpointproject/postgres:latest - docker run -d --name trustpoint-v2.0.0 --link postgres-v2.0.0 -p80:80 -p443:443 -ePOSTGRES_DB=trustpoint_db -eDATABASE_USER=admin -eDATABASE_PASSWORD=testing321 -eDATABASE_HOST=postgres-v2.0.0 -eDATABASE_PORT=5432 trustpointproject/trustpoint:latest - - - **-d**: Runs the container in detached mode. - - **--name**: Names the Trustpoint container `trustpoint` / `postgres`. - - **-p**: Maps the container's port to your local machine's port. - - **-v**: Creates a volume to persist data. - - **-e**: Sets environment variables. - - -Verify the Setup 🔍 -------------------- - -Once the containers are running, you can verify the setup: - -- **Web Interface**: Open `http://localhost` in your browser to access the Trustpoint setup wizard. -- **TLS Connection**: As the first step of the wizard, a TLS server certificate is generated. After this, only HTTPs connections will be accepted. - -.. note:: - You may need to accept a self-signed certificate in your browser to proceed. - -- **Set Credentials**: Be sure to choose a strong password for the admin user during the setup wizard. - -.. admonition:: 🥳 CONGRATULATIONS! - :class: tip + docker run -d --name postgres- \ + -v postgres_data-:/var/lib/postgresql \ + -p 127.0.0.1:5432:5432 \ + -e POSTGRES_USER= \ + -e POSTGRES_PASSWORD= \ + -e POSTGRES_DB=trustpoint_db \ + trustpointproject/postgres:latest + + docker run -d --name trustpoint- \ + --link postgres- \ + -p 80:80 -p 443:443 \ + -e POSTGRES_DB=trustpoint_db \ + -e DATABASE_USER= \ + -e DATABASE_PASSWORD= \ + -e DATABASE_HOST=postgres- \ + -e DATABASE_PORT=5432 \ + trustpointproject/trustpoint:latest + +Verify the Setup +---------------- - You’ve successfully set up Trustpoint! Your environment is now ready to securely manage digital identities for your industrial devices. You can start registering devices, issuing certificates, and building a trusted network. +Once the containers are running: -Change the Current Admin User Password ---------------------------------------- +- Open ``http://localhost`` in your browser to access the Trustpoint setup wizard. +- The wizard generates a TLS certificate on first run. After that, only HTTPS connections are accepted. You may need to accept a self-signed certificate in your browser. +- Set a strong password for the admin user when prompted. -To secure your Trustpoint setup, it may be important to change the default admin user password: +Change the Admin User Password +------------------------------- -- Go to https://localhost/admin -- Click on the **Users** section in the Django admin dashboard. -- Select the **admin** user from the list. -- Scroll down to the **password field** and click the "change password" link. -- Enter and confirm the new password. -- Click **Save** to update the password. +- Go to ``https://localhost/admin``. +- Click **Users**, select the **admin** user. +- Click the "change password" link, enter a new password, and click **Save**. -Tips and Troubleshooting +Tips and Troubleshooting ------------------------- -- **View Logs**: For troubleshooting, view logs with: +**View logs:** - .. code-block:: bash +.. code-block:: bash - docker logs -f trustpoint - docker logs -f postgres - docker compose logs trustpoint -f - docker compose logs postgres -f + docker compose logs trustpoint -f + docker compose logs postgres -f -- **Stop and Remove the Container**: Stop and remove the container with: +**Stop and remove containers and volumes:** - .. code-block:: bash - - docker stop trustpoint-container postgres && docker rm trustpoint-container postgres - docker compose down -v - - - - **-v**: Removes the volume. +.. code-block:: bash + docker compose down -v What to Do Next ---------------- -After setting up and Trustpoint, here are some recommended next steps to explore the full capabilities of the platform: - -1. **Explore Trustpoint with test data** 🧪: - Familiarize yourself with Trustpoint’s functionalities by running it with sample test data. To populate test data, navigate to **Home > Notifications > Populate Test Data** in the Trustpoint interface. +1. **Explore with test data**: Navigate to **Home > Notifications > Populate Test Data** in the Trustpoint interface. -2. **Use the Trustpoint in conjunction with the Trustpoint Client** 💻: - The easiest way to fully utilize Trustpoint is by pairing it with the associated Trustpoint Client, which is installed on end devices. The client enables streamlined identity management and certificate issuance. For more details, visit the `Trustpoint-Client Documentation `_. +2. **Use the Trustpoint Client**: Install the `Trustpoint Client `_ on end devices for streamlined certificate issuance. -3. **Issue your first certificate for an end device** 🛡️: - To do this, you need an Issuing CA certificate, a domain and a device that you must define in Trustpoint. Therefore follow the steps described in :ref:`quickstart-operation-guide` +3. **Issue your first certificate**: Follow the steps in :ref:`quickstart-operation-guide`. diff --git a/docs/source/security/pkcs11.rst b/docs/source/security/pkcs11.rst index 67f93ae9f..8dfba7a76 100644 --- a/docs/source/security/pkcs11.rst +++ b/docs/source/security/pkcs11.rst @@ -240,7 +240,7 @@ The Trustpoint container includes pre-configured SoftHSM support with token dire file: hsm_so_pin.txt environment: - POSTGRES_USER_FILE: /run/secrets/db_user - POSTGRES_PASSWORD_FILE: /run/secrets/db_password + DATABASE_USER_FILE: /run/secrets/db_user + DATABASE_PASSWORD_FILE: /run/secrets/db_password HSM_PIN_FILE: /run/secrets/hsm_pin HSM_SO_PIN_FILE: /run/secrets/hsm_so_pin diff --git a/scripts/tp_wizard/README.md b/scripts/tp_wizard/README.md new file mode 100644 index 000000000..8462f3463 --- /dev/null +++ b/scripts/tp_wizard/README.md @@ -0,0 +1,165 @@ +# tp_wizard + +`tp_wizard.sh` is the developer-facing setup helper for the local trustpoint Docker stack. + +It can run the interactive setup wizard or manage selected runtime services: trustpoint, PostgreSQL, Mailpit, SFTPGo, the optional workflows2 worker, Prometheus, and Grafana. + +## Commands + +Run from the repository root: + +```bash +./tp_wizard.sh +./tp_wizard.sh demo [light|full] [--skip-setup|--no-skip-setup] [--nowait] +./tp_wizard.sh up [trustpoint|db|mail|sftp|worker|prometheus|grafana|monitoring] [--skip-setup|--no-skip-setup] [--nowait] +./tp_wizard.sh down [trustpoint|db|mail|sftp|worker|prometheus|grafana|monitoring] +./tp_wizard.sh logs [trustpoint|db|mail|sftp|worker|prometheus|grafana] +./tp_wizard.sh status +./tp_wizard.sh nuke +``` + +Demo presets: + +```text +./tp_wizard.sh demo light = trustpoint + PostgreSQL +./tp_wizard.sh demo = trustpoint + PostgreSQL + Mailpit + SFTPGo + workflows2 worker +./tp_wizard.sh demo full = demo + Prometheus + Grafana +``` + + +`up` is intentionally only for explicit service targets. Demo presets are intentionally only available through `demo`. + +## Environment files + +The wizard reads `.env` as project input, but does not modify it by default. +Generated runtime values are written to `.env.tp_wizard`. + +Containers started by the wizard receive env files in this order: + +```text +.env -> .env.tp_wizard -> explicit docker run -e values +``` + +To intentionally let the wizard write into `.env` directly: + +```bash +TP_WIZARD_WRITE_PROJECT_ENV=true ./tp_wizard.sh demo light --skip-setup +``` + +The setup-skip variable defaults to `false`. Enable it per run with: + +```bash +./tp_wizard.sh demo full --skip-setup +./tp_wizard.sh up trustpoint db --skip-setup +``` + +If the application uses a different variable name: + +```bash +TRUSTPOINT_SKIP_SETUP_ENV_KEY=YOUR_ENV_NAME ./tp_wizard.sh demo light --skip-setup +``` + +## Output style + +`status`, the setup plan, and the final summary are intentionally compact: + +- one service table +- important access URLs only +- env-file overlay information +- database connection target without passwords +- only essential credentials, masked + + +## Monitoring auto-provisioning + +`demo full` starts Prometheus and Grafana with generated config under `.tp_wizard/`. +This avoids overwriting repository files such as `prometheus/prometheus.yml`. + +Generated files: + +```text +.tp_wizard/prometheus/prometheus.yml +.tp_wizard/grafana/provisioning/datasources/prometheus.yml +.tp_wizard/grafana/provisioning/dashboards/trustpoint.yml +.tp_wizard/grafana/dashboards/trustpoint-overview.json +``` + +Prometheus scrapes trustpoint with these defaults: + +```text +TRUSTPOINT_METRICS_SCHEME=https +TRUSTPOINT_METRICS_TARGET=trustpoint.local:443 +TRUSTPOINT_METRICS_PATH=/prometheus/metrics +``` + +Override them when needed: + +```bash +TRUSTPOINT_METRICS_PATH=/your/metrics/path ./tp_wizard.sh demo full +``` + +Grafana is provisioned with a Prometheus datasource and a `Trustpoint Overview` dashboard. + +## Design + +The root `tp_wizard.sh` is only the stable entrypoint. Implementation lives in `scripts/tp_wizard/`: + +```text +defaults.sh constants and defaults; loads .env early +state.sh mutable wizard/runtime state +cli.sh argument parsing and dispatch +wizard.sh interactive wizard flow +runtime.sh shared start/wait/provision/summary orchestration +summary.sh compact plan, status, and final output +lib/ generic helpers +services/ service-specific logic +commands/ command handlers +``` + +Dependency direction: + +```text +cli -> commands -> runtime -> services -> lib +wizard -> runtime -> services -> lib +``` + +Rules: `lib/` does not call services or commands; services do not parse CLI args; commands stay thin; `runtime.sh` owns shared orchestration. + + +## Demo/up split and setup skip + +`demo` is the only command for presets: + +```bash +./tp_wizard.sh demo light --skip-setup +./tp_wizard.sh demo --skip-setup +./tp_wizard.sh demo full --skip-setup +``` + +`up` is only for explicit services: + +```bash +./tp_wizard.sh up trustpoint db --skip-setup +./tp_wizard.sh up prometheus grafana +``` + +`up demo full` is intentionally invalid. + +The `--skip-setup` flag sets the configured trustpoint setup-skip environment variable to `true`. +By default the wizard writes: + +```text +TP_SKIP_SETUP=true +``` + +If the application-side variable name changes, override it without editing the script: + +```bash +TRUSTPOINT_SKIP_SETUP_ENV_KEY=REAL_ENV_NAME ./tp_wizard.sh demo full --skip-setup +``` + +To write more than one compatible variable name: + +```bash +TRUSTPOINT_SKIP_SETUP_ENV_KEYS="TP_SKIP_SETUP REAL_ENV_NAME" ./tp_wizard.sh demo full --skip-setup +``` diff --git a/scripts/tp_wizard/bootstrap.sh b/scripts/tp_wizard/bootstrap.sh new file mode 100644 index 000000000..a1ea10859 --- /dev/null +++ b/scripts/tp_wizard/bootstrap.sh @@ -0,0 +1,33 @@ +# shellcheck shell=bash +# Load tp_wizard modules in dependency order. + +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/defaults.sh" +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/state.sh" + +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/lib/ui.sh" +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/lib/validation.sh" +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/lib/ports.sh" +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/lib/docker.sh" +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/lib/input.sh" +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/lib/env.sh" + +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/summary.sh" + +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/services/postgres.sh" +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/services/trustpoint.sh" +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/services/mailpit.sh" +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/services/sftpgo.sh" +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/services/workflows2_worker.sh" +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/services/monitoring.sh" + +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/runtime.sh" +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/wizard.sh" + +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/commands/demo.sh" +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/commands/up.sh" +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/commands/down.sh" +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/commands/logs.sh" +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/commands/status.sh" +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/commands/nuke.sh" + +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/cli.sh" diff --git a/scripts/tp_wizard/cli.sh b/scripts/tp_wizard/cli.sh new file mode 100644 index 000000000..5dec66f5e --- /dev/null +++ b/scripts/tp_wizard/cli.sh @@ -0,0 +1,174 @@ +usage(){ + cat <<'EOF2' +Commands: + (no command) Run interactive wizard + + demo [light|full] [--skip-setup|--no-skip-setup] [--nowait] + up [trustpoint|db|mail|sftp|worker|prometheus|grafana|monitoring] [--skip-setup|--no-skip-setup] [--nowait] + down [trustpoint|db|mail|sftp|worker|prometheus|grafana|monitoring] + logs [trustpoint|db|mail|sftp|worker|prometheus|grafana] + status + nuke + help + +Demo presets: + demo light trustpoint + PostgreSQL + demo trustpoint + PostgreSQL + Mailpit + SFTPGo + workflows2 worker + demo full demo + Prometheus + Grafana + +Notes: + - Use `demo ...` for non-interactive demo presets. + - Use `up ...` for individual containers/services only. + - `up demo ...` is intentionally not supported. + - Use `--skip-setup` to make trustpoint skip its in-app setup wizard. +EOF2 +} + +map_demo_preset_to_flags(){ + local preset="${1:-default}" + case "$preset" in + light) + DEMO_PRESET="light" + ONLY_APP=true + ONLY_DB=true + ;; + default|demo) + DEMO_PRESET="demo" + ONLY_APP=true + ONLY_DB=true + ONLY_MAIL=true + ONLY_SFTP=true + ONLY_WF2_WORKER=true + ;; + full) + DEMO_PRESET="full" + ONLY_APP=true + ONLY_DB=true + ONLY_MAIL=true + ONLY_SFTP=true + ONLY_WF2_WORKER=true + ONLY_PROMETHEUS=true + ONLY_GRAFANA=true + ;; + *) + die "Unknown demo preset: $preset (use light|full, or omit it for default demo)" + ;; + esac +} + +map_only_to_flags(){ + case "$1" in + trustpoint|app) ONLY_APP=true ;; + db) ONLY_DB=true ;; + mail) ONLY_MAIL=true ;; + sftp) ONLY_SFTP=true ;; + worker) ONLY_WF2_WORKER=true ;; + prometheus|prom) ONLY_PROMETHEUS=true ;; + grafana) ONLY_GRAFANA=true ;; + monitoring|metrics) ONLY_PROMETHEUS=true; ONLY_GRAFANA=true ;; + demo|light|full|demo-light|demo-full) + die "Demo presets are not valid for 'up'. Use './tp_wizard.sh demo [light|full]' instead." + ;; + *) die "Unknown target: $1 (use trustpoint|db|mail|sftp|worker|prometheus|grafana|monitoring)" ;; + esac +} + +set_common_runtime_flag(){ + case "$1" in + --skip-setup|--skip-trustpoint-setup) + TP_SKIP_SETUP_VALUE="true" + ;; + --no-skip-setup|--no-skip-trustpoint-setup) + TP_SKIP_SETUP_VALUE="false" + ;; + --nowait) + NOWAIT=true + ;; + *) + return 1 + ;; + esac +} + +set_targets_from_args(){ + local any=false + while [[ $# -gt 0 ]]; do + case "$1" in + trustpoint|app|db|mail|sftp|worker|prometheus|prom|grafana|monitoring|metrics) + map_only_to_flags "$1" + any=true + shift + ;; + --only) + [[ $# -ge 2 ]] || die "--only requires a target" + map_only_to_flags "$2" + any=true + shift 2 + ;; + --skip-setup|--skip-trustpoint-setup|--no-skip-setup|--no-skip-trustpoint-setup|--nowait) + set_common_runtime_flag "$1" + shift + ;; + demo|light|full|demo-light|demo-full) + map_only_to_flags "$1" + ;; + *) die "Unknown option/target: $1" ;; + esac + done + + # Historical default for `up`: start trustpoint + DB when no target is given. + if ! $any; then + ONLY_APP=true + ONLY_DB=true + fi +} + +tp_main(){ + local cmd="${1:-}" + + case "$cmd" in + "" ) + preflight + wizard + ;; + help|-h|--help) + usage + ;; + demo) + preflight + shift || true + cmd_demo "$@" + ;; + up) + if [[ "${2:-}" == "demo" || "${2:-}" == "light" || "${2:-}" == "full" || "${2:-}" == "demo-light" || "${2:-}" == "demo-full" ]]; then + die "Demo presets are not valid for 'up'. Use './tp_wizard.sh demo [light|full]' instead." + fi + preflight + shift || true + cmd_up "$@" + ;; + down) + preflight + shift || true + cmd_down "$@" + ;; + logs) + preflight + shift || true + cmd_logs "$@" + ;; + status) + preflight + shift || true + cmd_status "$@" + ;; + nuke) + preflight + cmd_nuke + ;; + *) + usage + die "Unknown command: $cmd" + ;; + esac +} diff --git a/scripts/tp_wizard/commands/demo.sh b/scripts/tp_wizard/commands/demo.sh new file mode 100644 index 000000000..fb1a16d56 --- /dev/null +++ b/scripts/tp_wizard/commands/demo.sh @@ -0,0 +1,58 @@ +cmd_demo(){ + local preset="default" + local preset_seen=false + + while [[ $# -gt 0 ]]; do + case "$1" in + light|full) + if $preset_seen; then + die "Only one demo preset is allowed." + fi + preset="$1" + preset_seen=true + shift + ;; + --skip-setup|--skip-trustpoint-setup|--no-skip-setup|--no-skip-trustpoint-setup|--nowait) + set_common_runtime_flag "$1" + shift + ;; + demo|default) + if $preset_seen; then + die "Only one demo preset is allowed." + fi + preset="default" + preset_seen=true + shift + ;; + *) + die "Unknown demo option/preset: $1 (use light|full, --skip-setup, --no-skip-setup, --nowait)" + ;; + esac + done + + map_demo_preset_to_flags "$preset" + demo_apply_defaults + runtime_start_selected +} + +demo_apply_defaults(){ + # Demo mode must be non-interactive. Use deterministic defaults. + DB_INTERNAL=true + DB_HOST="$DEF_DB_HOST_INTERNAL" + + APP_DB_NAME="$DB_NAME" + APP_DB_USER="$DB_USER" + APP_DB_PASS="$DB_PASS" + APP_DB_HOST="$DEF_DB_HOST_INTERNAL" + APP_DB_PORT=5432 + + # Demo mode should run the current checkout by default. + # Set TP_WIZARD_DEMO_PULL=true to pull an image instead. + if bool_env_true "${TP_WIZARD_DEMO_PULL:-false}"; then + BUILD_LOCAL=false + APP_IMAGE="${TP_REPO}:${TP_WIZARD_DEMO_IMAGE_TAG:-latest}" + else + BUILD_LOCAL=true + APP_IMAGE="trustpoint:local" + fi +} diff --git a/scripts/tp_wizard/commands/down.sh b/scripts/tp_wizard/commands/down.sh new file mode 100644 index 000000000..f8bfd5dbc --- /dev/null +++ b/scripts/tp_wizard/commands/down.sh @@ -0,0 +1,17 @@ +down_selected(){ + local done=false + $ONLY_APP && { stop_one trustpoint; stop_one "$WF2_WORKER_NAME"; done=true; } + $ONLY_DB && stop_one postgres && done=true + $ONLY_MAIL && stop_one mailpit && done=true + $ONLY_SFTP && stop_one sftpgo && done=true + $ONLY_WF2_WORKER && stop_one "$WF2_WORKER_NAME" && done=true + $ONLY_PROMETHEUS && stop_one prometheus && done=true + $ONLY_GRAFANA && stop_one grafana && done=true + $done || { stop_one trustpoint; stop_one postgres; stop_one mailpit; stop_one sftpgo; stop_one "$WF2_WORKER_NAME"; stop_one prometheus; stop_one grafana; } + ok "Stopped." +} + +cmd_down(){ + set_targets_from_args "$@" + down_selected +} diff --git a/scripts/tp_wizard/commands/logs.sh b/scripts/tp_wizard/commands/logs.sh new file mode 100644 index 000000000..27954e5eb --- /dev/null +++ b/scripts/tp_wizard/commands/logs.sh @@ -0,0 +1,16 @@ +logs_selected(){ + local target="trustpoint" + $ONLY_DB && target="postgres" + $ONLY_MAIL && target="mailpit" + $ONLY_SFTP && target="sftpgo" + $ONLY_WF2_WORKER && target="$WF2_WORKER_NAME" + $ONLY_PROMETHEUS && target="prometheus" + $ONLY_GRAFANA && target="grafana" + exists "$target" || die "Container not found: $target" + docker logs -f "$target" +} + +cmd_logs(){ + set_targets_from_args "$@" + logs_selected +} diff --git a/scripts/tp_wizard/commands/nuke.sh b/scripts/tp_wizard/commands/nuke.sh new file mode 100644 index 000000000..d32f8fa14 --- /dev/null +++ b/scripts/tp_wizard/commands/nuke.sh @@ -0,0 +1,30 @@ +nuke_cmd(){ + read -r -p "Remove ALL project containers, network, DB/Grafana volumes, ./sftpgo-data, ./workflow2Folder, and ./grafana-provisioning? [y/N] " a + [[ "${a}" == "y" ]] || exit 0 + read -r -p "Are you sure? This is destructive. [y/N] " b + [[ "${b}" == "y" ]] || exit 0 + + mapfile -t project_volumes < <(collect_project_volumes) + stop_one trustpoint + stop_one postgres + stop_one mailpit + stop_one sftpgo + stop_one "$WF2_WORKER_NAME" + stop_one prometheus + stop_one grafana + docker network rm "$NET" >/dev/null 2>&1 || true + + for v in "${project_volumes[@]}"; do + [[ -n "$v" ]] || continue + docker volume rm "$v" >/dev/null 2>&1 || true + done + + if [[ -d "$SFTPGO_ROOT" ]]; then rm -rf "$SFTPGO_ROOT"; fi + if [[ -d "$WF2_FOLDER" ]]; then rm -rf "$WF2_FOLDER"; fi + if [[ -d "$GRAFANA_PROVISIONING_ROOT" ]]; then rm -rf "$GRAFANA_PROVISIONING_ROOT"; fi + ok "Project resources removed." +} + +cmd_nuke(){ + nuke_cmd +} diff --git a/scripts/tp_wizard/commands/status.sh b/scripts/tp_wizard/commands/status.sh new file mode 100644 index 000000000..cc343115e --- /dev/null +++ b/scripts/tp_wizard/commands/status.sh @@ -0,0 +1,4 @@ +cmd_status(){ + [[ $# -eq 0 ]] || die "status does not take targets. Use it without arguments." + show_runtime_status +} diff --git a/scripts/tp_wizard/commands/up.sh b/scripts/tp_wizard/commands/up.sh new file mode 100644 index 000000000..f0a4b0e30 --- /dev/null +++ b/scripts/tp_wizard/commands/up.sh @@ -0,0 +1,4 @@ +cmd_up(){ + set_targets_from_args "$@" + runtime_start_selected +} diff --git a/scripts/tp_wizard/defaults.sh b/scripts/tp_wizard/defaults.sh new file mode 100644 index 000000000..28aebea17 --- /dev/null +++ b/scripts/tp_wizard/defaults.sh @@ -0,0 +1,115 @@ +# -------------------------- Constants & defaults ------------------------------ +PROJECT="trustpoint" +NET="${PROJECT}-net" +VOL_DB="${PROJECT}_postgres_data" +VOL_GRAFANA="${PROJECT}_grafana_data" +ENV_FILE="${ENV_FILE:-${PWD}/.env}" +TP_WIZARD_ENV_FILE="${TP_WIZARD_ENV_FILE:-${PWD}/.env.tp_wizard}" +TP_WIZARD_WRITE_PROJECT_ENV="${TP_WIZARD_WRITE_PROJECT_ENV:-false}" + +# Load the repository .env as read-only input so defaults below can inherit +# developer-local configuration. The wizard writes generated values to +# TP_WIZARD_ENV_FILE by default and does not modify .env unless +# TP_WIZARD_WRITE_PROJECT_ENV=true is set explicitly. +if [[ -f "$ENV_FILE" ]]; then + set -a + # shellcheck source=/dev/null + source "$ENV_FILE" + set +a +fi + +# trustpoint image handling +TP_DOCKERFILE="docker/trustpoint/Dockerfile" +TP_REPO="trustpointproject/trustpoint" +APP_IMAGE="${TP_REPO}:latest" # overridden to trustpoint:local when BUILD_LOCAL=true +BUILD_LOCAL=false + +# Fixed images +PG_IMAGE="postgres:15.14" +MAILPIT_IMAGE="axllent/mailpit:v1.27" +SFTPGO_IMAGE="drakkan/sftpgo:2.6.x-slim" +PROMETHEUS_IMAGE="prom/prometheus:latest" +GRAFANA_IMAGE="grafana/grafana:latest" +WF2_WORKER_NAME="trustpoint-worker" + +# trustpoint host ports. These names match docker-compose.yml. +APP_HTTP_HOST="${TP_HTTP_PORT:-80}" +APP_HTTPS_HOST="${TP_HTTPS_PORT:-443}" + +# PostgreSQL defaults. These names match docker-compose.yml and .env. +DEF_DB_NAME="${POSTGRES_DB:-trustpoint_db}" +DEF_DB_USER="${DATABASE_USER:-admin}" +DEF_DB_PASS="${DATABASE_PASSWORD:-testing321}" +DEF_DB_PORT="${DATABASE_PORT:-5432}" +DEF_DB_HOST="${DATABASE_HOST:-postgres}" +DEF_DB_HOST_INTERNAL="postgres" # container name/hostname + +# trustpoint runtime environment. +DEF_TP_TLS_DNS_NAMES="${TP_TLS_DNS_NAMES:-trustpoint.local}" +DEF_TP_TLS_IPV4_ADDRESSES="${TP_TLS_IPV4_ADDRESSES:-}" +DEF_TP_TLS_IPV6_ADDRESSES="${TP_TLS_IPV6_ADDRESSES:-}" + +# Trustpoint auto-setup bypasses the in-app setup wizard. The key is +# configurable for local app changes, but the current app uses TP_AUTO_SETUP. +# Use --skip-setup to set it to true for CLI/demo runs. +TRUSTPOINT_SKIP_SETUP_ENV_KEY="${TRUSTPOINT_SKIP_SETUP_ENV_KEY:-TP_AUTO_SETUP}" +TRUSTPOINT_SKIP_SETUP_ENV_KEYS="${TRUSTPOINT_SKIP_SETUP_ENV_KEYS:-$TRUSTPOINT_SKIP_SETUP_ENV_KEY}" +TRUSTPOINT_SKIP_SETUP_ENV_VALUE="${TRUSTPOINT_SKIP_SETUP_ENV_VALUE:-false}" +if [[ -v "$TRUSTPOINT_SKIP_SETUP_ENV_KEY" ]]; then + DEF_TRUSTPOINT_SKIP_SETUP_VALUE="${!TRUSTPOINT_SKIP_SETUP_ENV_KEY}" +else + DEF_TRUSTPOINT_SKIP_SETUP_VALUE="$TRUSTPOINT_SKIP_SETUP_ENV_VALUE" +fi +DEF_TP_ADMIN_USERNAME="${TP_ADMIN_USERNAME:-admin}" +DEF_TP_ADMIN_PASSWORD="${TP_ADMIN_PASSWORD:-testing321}" +DEF_TP_ADMIN_EMAIL="${TP_ADMIN_EMAIL:-admin@trustpoint.local}" +DEF_TP_INJECT_DEMO_DATA="${TP_INJECT_DEMO_DATA:-true}" +DEF_TP_ENABLE_PROMETHEUS_METRICS="${TP_ENABLE_PROMETHEUS_METRICS:-true}" + +# Mailpit defaults +DEF_MAILPIT_SMTP_PORT=1025 +DEF_MAILPIT_UI_PORT=8025 + +# SFTPGo defaults +DEF_SFTPGO_SFTP_PORT=2222 +DEF_SFTPGO_WEB_PORT=8080 +DEF_SFTPGO_ADMIN_USER="admin" +DEF_SFTPGO_ADMIN_PASS="testing321" +SFTPGO_ROOT="${PWD}/sftpgo-data" + +# workflows2 worker defaults +WF2_FOLDER="${PWD}/workflow2Folder" +WF2_WORKER_ENV_FILE="${WF2_FOLDER}/worker.env" +WF2_WORKER_README="${WF2_FOLDER}/README.txt" +DEF_WF2_WORKER_LEASE=30 +DEF_WF2_WORKER_BATCH=10 +DEF_WF2_WORKER_SLEEP=1 +MAILPIT_PROBE_TIMEOUT=20 + +# Monitoring defaults +DEF_PROMETHEUS_PORT=9090 +DEF_GRAFANA_PORT=3000 +DEF_GRAFANA_ADMIN_USER="admin" +DEF_GRAFANA_ADMIN_PASS="testing321" + +# Wizard-generated observability config. Keep this separate from the repository's +# prometheus/prometheus.yml so existing project config is not overwritten. +TP_WIZARD_GENERATED_ROOT="${TP_WIZARD_GENERATED_ROOT:-${PWD}/.tp_wizard}" +PROMETHEUS_CONFIG="${PROMETHEUS_CONFIG:-${TP_WIZARD_GENERATED_ROOT}/prometheus/prometheus.yml}" +TRUSTPOINT_METRICS_SCHEME="${TRUSTPOINT_METRICS_SCHEME:-https}" +TRUSTPOINT_METRICS_TARGET="${TRUSTPOINT_METRICS_TARGET:-trustpoint.local:443}" +TRUSTPOINT_METRICS_PATH="${TRUSTPOINT_METRICS_PATH:-/prometheus/metrics}" +TRUSTPOINT_METRICS_TLS_INSECURE_SKIP_VERIFY="${TRUSTPOINT_METRICS_TLS_INSECURE_SKIP_VERIFY:-true}" +GRAFANA_PROVISIONING_ROOT="${TP_WIZARD_GENERATED_ROOT}/grafana/provisioning" +GRAFANA_DATASOURCES_DIR="${GRAFANA_PROVISIONING_ROOT}/datasources" +GRAFANA_DASHBOARD_PROVIDERS_DIR="${GRAFANA_PROVISIONING_ROOT}/dashboards" +GRAFANA_DASHBOARDS_DIR="${TP_WIZARD_GENERATED_ROOT}/grafana/dashboards" + +# Timeouts +READINESS_TIMEOUT=90 +TLS_FP_TIMEOUT=150 + +# Optional backup user provisioning +SFTPGO_BACKUP_USER="tpbackup" +SFTPGO_BACKUP_PASS="testing321" +SFTPGO_BACKUP_HOME="" diff --git a/scripts/tp_wizard/runtime.sh b/scripts/tp_wizard/runtime.sh new file mode 100644 index 000000000..0f2abdd50 --- /dev/null +++ b/scripts/tp_wizard/runtime.sh @@ -0,0 +1,103 @@ +# Shared orchestration used by wizard mode, demo mode, and CLI up mode. + +await_readiness(){ + local deadline=$(( $(date +%s) + READINESS_TIMEOUT )) + + if $DB_INTERNAL; then + echo "Waiting (<= ${READINESS_TIMEOUT}s) for PostgreSQL on localhost:${DB_PORT} ..." + while (( $(date +%s) < deadline )); do + if tcp_check 127.0.0.1 "$DB_PORT" 1; then + ok "PostgreSQL ready on :$DB_PORT" + break + fi + printf "." + sleep 1 + done + echo + fi + + if $EN_APP; then + echo "Waiting (<= ${READINESS_TIMEOUT}s) for trustpoint HTTP on localhost:${APP_HTTP_HOST} ..." + while (( $(date +%s) < deadline )); do + if tcp_check 127.0.0.1 "$APP_HTTP_HOST" 1; then + ok "trustpoint reachable on :$APP_HTTP_HOST" + break + fi + printf "." + sleep 1 + done + echo + fi + + await_sftpgo_ready + await_monitoring_ready +} + +runtime_after_start(){ + $NOWAIT || await_readiness + $NOWAIT || provision_sftpgo_backup_user + $NOWAIT || verify_mailpit_delivery + $NOWAIT || wait_tls_fingerprint || true + final_summary +} + +runtime_start_enabled(){ + ensure_network + sync_env_file + resolve_app_image + + $DB_INTERNAL && ensure_volumes + + start_postgres + start_mailpit + start_sftpgo + + $EN_WF2_WORKER || stop_one "$WF2_WORKER_NAME" + + start_app + start_workflows2_worker + start_prometheus + start_grafana + + runtime_after_start +} + +runtime_start_selected(){ + $ONLY_APP && EN_APP=true + $ONLY_DB && { + EN_PG=true + DB_INTERNAL=true + } + $ONLY_MAIL && EN_MAILPIT=true + $ONLY_SFTP && EN_SFTPGO=true + $ONLY_WF2_WORKER && EN_WF2_WORKER=true + $ONLY_PROMETHEUS && EN_PROMETHEUS=true + $ONLY_GRAFANA && EN_GRAFANA=true + + ensure_network + sync_env_file + resolve_app_image + + $ONLY_DB && { + ensure_volumes + start_postgres + } + + $ONLY_MAIL && start_mailpit + + $ONLY_SFTP && start_sftpgo + + $EN_WF2_WORKER || { + $ONLY_APP && stop_one "$WF2_WORKER_NAME" + } + + $ONLY_APP && start_app + + $EN_WF2_WORKER && start_workflows2_worker + + $ONLY_PROMETHEUS && start_prometheus + + $ONLY_GRAFANA && start_grafana + + runtime_after_start +} diff --git a/scripts/tp_wizard/services/mailpit.sh b/scripts/tp_wizard/services/mailpit.sh new file mode 100644 index 000000000..31b9b796c --- /dev/null +++ b/scripts/tp_wizard/services/mailpit.sh @@ -0,0 +1,71 @@ +mailpit_prompt_config(){ + EN_MAILPIT=$(ask_yes_no "Enable Mailpit (demo SMTP inbox)?" "n" && echo true || echo false) + if $EN_MAILPIT; then + MAILPIT_SMTP_PORT="$(ask_free_port 'Mailpit SMTP host port' "$MAILPIT_SMTP_PORT")" + MAILPIT_UI_PORT="$(ask_free_port 'Mailpit UI host port' "$MAILPIT_UI_PORT")" + fi +} + +start_mailpit(){ + $EN_MAILPIT || return 0 + local name="mailpit" + stop_one "$name" + if port_in_use "$MAILPIT_SMTP_PORT"; then die "Host port ${MAILPIT_SMTP_PORT} in use (Mailpit SMTP)."; fi + if port_in_use "$MAILPIT_UI_PORT"; then die "Host port ${MAILPIT_UI_PORT} in use (Mailpit UI)."; fi + log "Starting Mailpit..." + docker run -d --name "$name" --network "$NET" \ + -p "${MAILPIT_SMTP_PORT}:1025" \ + -p "${MAILPIT_UI_PORT}:8025" \ + "$MAILPIT_IMAGE" >/dev/null +} + + +mailpit_has_subject(){ + local subject="$1" + have curl || return 2 + local until=$(( $(date +%s) + MAILPIT_PROBE_TIMEOUT )) + local api="http://127.0.0.1:${MAILPIT_UI_PORT}/api/v1/messages" + while (( $(date +%s) < until )); do + if curl -fsS "$api" 2>/dev/null | grep -Fq "$subject"; then + return 0 + fi + sleep 1 + done + return 1 +} + + +probe_mailpit_from_container(){ + local container="$1" label="$2" + exists "$container" || return 0 + + local subject="trustpoint wizard ${label} mailpit probe $(date +%s)" + log "Sending Mailpit probe email from ${label} container..." + + if ! docker exec -e "PROBE_SUBJECT=${subject}" "$container" bash -lc \ + 'cd /var/www/html/trustpoint && uv run trustpoint/manage.py shell -c '\''import os; from django.conf import settings; from django.core.mail import send_mail; subject=os.environ["PROBE_SUBJECT"]; send_mail(subject, "Trustpoint Mailpit probe.", getattr(settings, "DEFAULT_FROM_EMAIL", None), ["demo@trustpoint.local"], fail_silently=False)'\''' \ + >/dev/null 2>&1; then + warn "Mailpit probe failed from ${label} container." + return 1 + fi + + if ! have curl; then + warn "curl not found on host; Mailpit probe from ${label} was sent but API verification was skipped." + return 0 + fi + + if mailpit_has_subject "$subject"; then + ok "Mailpit received the ${label} probe email." + return 0 + fi + + warn "Mailpit SMTP accepted the ${label} probe email, but it did not appear in the Mailpit UI within ${MAILPIT_PROBE_TIMEOUT}s." + return 1 +} + + +verify_mailpit_delivery(){ + $EN_MAILPIT || return 0 + $EN_APP && probe_mailpit_from_container trustpoint "web" || true + $EN_WF2_WORKER && probe_mailpit_from_container "$WF2_WORKER_NAME" "workflows2-worker" || true +} diff --git a/scripts/tp_wizard/services/monitoring.sh b/scripts/tp_wizard/services/monitoring.sh new file mode 100644 index 000000000..16c86c85c --- /dev/null +++ b/scripts/tp_wizard/services/monitoring.sh @@ -0,0 +1,2066 @@ +monitoring_prompt_config(){ + EN_PROMETHEUS=$(ask_yes_no "Enable Prometheus metrics stack?" "n" && echo true || echo false) + if $EN_PROMETHEUS; then + PROMETHEUS_PORT="$(ask_free_port 'Prometheus host port' "$PROMETHEUS_PORT")" + ask "Trustpoint metrics scheme" "$TRUSTPOINT_METRICS_SCHEME_VALUE" + TRUSTPOINT_METRICS_SCHEME_VALUE="$REPLY" + ask "Trustpoint metrics target from Prometheus container" "$TRUSTPOINT_METRICS_TARGET_VALUE" + TRUSTPOINT_METRICS_TARGET_VALUE="$REPLY" + ask "Trustpoint metrics path" "$TRUSTPOINT_METRICS_PATH_VALUE" + TRUSTPOINT_METRICS_PATH_VALUE="$REPLY" + ask "Prometheus generated config file" "$PROMETHEUS_CONFIG" + PROMETHEUS_CONFIG="$REPLY" + fi + + EN_GRAFANA=$(ask_yes_no "Enable Grafana dashboard UI?" "n" && echo true || echo false) + if $EN_GRAFANA; then + GRAFANA_PORT="$(ask_free_port 'Grafana host port' "$GRAFANA_PORT")" + GRAFANA_ADMIN_USER="$(ask_user 'Grafana admin user' "$GRAFANA_ADMIN_USER")" + GRAFANA_ADMIN_PASS="$(ask_password 'Grafana admin password' "$GRAFANA_ADMIN_PASS")" + fi +} + +ensure_prometheus_config(){ + mkdir -p "$(dirname "$PROMETHEUS_CONFIG")" + + cat > "$PROMETHEUS_CONFIG" <> "$PROMETHEUS_CONFIG" <<'EOF2' + tls_config: + insecure_skip_verify: true +EOF2 + fi + + cat >> "$PROMETHEUS_CONFIG" < "${GRAFANA_DATASOURCES_DIR}/prometheus.yml" <<'EOF2' +apiVersion: 1 + +datasources: + - name: Prometheus + uid: prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true +EOF2 +} + +prepare_grafana_dashboard_provider(){ + mkdir -p "$GRAFANA_DASHBOARD_PROVIDERS_DIR" "$GRAFANA_DASHBOARDS_DIR" + + cat > "${GRAFANA_DASHBOARD_PROVIDERS_DIR}/trustpoint.yml" <<'EOF2' +apiVersion: 1 + +providers: + - name: trustpoint + orgId: 1 + folder: Trustpoint + type: file + disableDeletion: false + allowUiUpdates: true + updateIntervalSeconds: 10 + options: + path: /var/lib/grafana/dashboards +EOF2 +} + +prepare_grafana_trustpoint_dashboard(){ + mkdir -p "$GRAFANA_DASHBOARDS_DIR" + + cat > "${GRAFANA_DASHBOARDS_DIR}/trustpoint-overview.json" <<'EOF2' +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Prometheus scrape health for the Trustpoint target.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "0": { + "color": "red", + "text": "DOWN" + }, + "1": { + "color": "green", + "text": "UP" + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "min(up{job=\"trustpoint\"})", + "legendFormat": "trustpoint", + "range": true, + "refId": "A" + } + ], + "title": "Trustpoint availability", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Application request throughput, excluding Prometheus scraping of the metrics endpoint.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 2, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 5, + "x": 4, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(django_http_requests_total_by_view_transport_method_total{job=\"trustpoint\",view!=\"prometheus-metrics\"}[$__rate_interval])) or vector(0)", + "legendFormat": "requests/s", + "range": true, + "refId": "A" + } + ], + "title": "Request rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Ratio of HTTP 4xx and 5xx responses over all HTTP responses.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 2, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 0.01 + }, + { + "color": "red", + "value": 0.05 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 5, + "x": 9, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(rate(django_http_responses_total_by_status_total{job=\"trustpoint\",status=~\"4..|5..\"}[$__rate_interval])) or vector(0)) / clamp_min((sum(rate(django_http_responses_total_by_status_total{job=\"trustpoint\"}[$__rate_interval])) or vector(0)), 0.001)", + "legendFormat": "error ratio", + "range": true, + "refId": "A" + } + ], + "title": "HTTP error ratio", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "95th percentile request latency including Django middleware.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 3, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 0.5 + }, + { + "color": "red", + "value": 1.5 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 5, + "x": 14, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by (le) (rate(django_http_requests_latency_including_middlewares_seconds_bucket{job=\"trustpoint\"}[$__rate_interval])))", + "legendFormat": "p95", + "range": true, + "refId": "A" + } + ], + "title": "p95 latency", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Open file descriptors divided by the process file descriptor limit.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 2, + "max": 1, + "min": 0, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 0.7 + }, + { + "color": "red", + "value": 0.9 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 5, + "x": 19, + "y": 0 + }, + "id": 5, + "options": { + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "process_open_fds{job=\"trustpoint\"} / process_max_fds{job=\"trustpoint\"}", + "legendFormat": "fd usage", + "range": true, + "refId": "A" + } + ], + "title": "FD usage", + "type": "gauge" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 4 + }, + "id": 100, + "panels": [], + "title": "HTTP traffic and latency", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Request throughput by Django view and HTTP method.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 5 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum by (view, method) (rate(django_http_requests_total_by_view_transport_method_total{job=\"trustpoint\",view!=\"prometheus-metrics\"}[$__rate_interval]))", + "legendFormat": "{{view}} {{method}}", + "range": true, + "refId": "A" + } + ], + "title": "Requests by view and method", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "HTTP status code rate.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 12, + "y": 5 + }, + "id": 7, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum by (status) (rate(django_http_responses_total_by_status_total{job=\"trustpoint\"}[$__rate_interval]))", + "legendFormat": "HTTP {{status}}", + "range": true, + "refId": "A" + } + ], + "title": "Responses by status", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Total responses by status in the selected time range.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 18, + "y": 5 + }, + "id": 8, + "options": { + "displayMode": "gradient", + "minVizHeight": 10, + "minVizWidth": 0, + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "valueMode": "color" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum by (status) (increase(django_http_responses_total_by_status_total{job=\"trustpoint\"}[$__range]))", + "legendFormat": "HTTP {{status}}", + "range": true, + "refId": "A" + } + ], + "title": "Status code mix", + "type": "bargauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Request latency percentiles and average latency from Django middleware histogram metrics.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 13 + }, + "id": 9, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum by (le) (rate(django_http_requests_latency_including_middlewares_seconds_bucket{job=\"trustpoint\"}[$__rate_interval])))", + "legendFormat": "p50", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by (le) (rate(django_http_requests_latency_including_middlewares_seconds_bucket{job=\"trustpoint\"}[$__rate_interval])))", + "legendFormat": "p95", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.99, sum by (le) (rate(django_http_requests_latency_including_middlewares_seconds_bucket{job=\"trustpoint\"}[$__rate_interval])))", + "legendFormat": "p99", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(django_http_requests_latency_including_middlewares_seconds_sum{job=\"trustpoint\"}[$__rate_interval]) / clamp_min(rate(django_http_requests_latency_including_middlewares_seconds_count{job=\"trustpoint\"}[$__rate_interval]), 0.001)", + "legendFormat": "avg", + "range": true, + "refId": "D" + } + ], + "title": "Latency percentiles", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Average view latency over the last five minutes. This helps identify the currently slowest Trustpoint views.", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 0.5 + }, + { + "color": "red", + "value": 1.5 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 13 + }, + "id": 10, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "topk(10, sum by (view, method) (rate(django_http_requests_latency_seconds_by_view_method_sum{job=\"trustpoint\",view!=\"prometheus-metrics\"}[5m])) / clamp_min(sum by (view, method) (rate(django_http_requests_latency_seconds_by_view_method_count{job=\"trustpoint\",view!=\"prometheus-metrics\"}[5m])), 0.001))", + "format": "table", + "instant": true, + "legendFormat": "{{view}} {{method}}", + "range": false, + "refId": "A" + } + ], + "title": "Slowest views now", + "type": "table" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 21 + }, + "id": 101, + "panels": [], + "title": "Runtime health", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 0, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 22 + }, + "id": 11, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "time() - process_start_time_seconds{job=\"trustpoint\"}", + "legendFormat": "uptime", + "range": true, + "refId": "A" + } + ], + "title": "Process uptime", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 0, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 22 + }, + "id": 12, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "process_resident_memory_bytes{job=\"trustpoint\"}", + "legendFormat": "resident memory", + "range": true, + "refId": "A" + } + ], + "title": "Resident memory", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 3, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 0.7 + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "cores" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 22 + }, + "id": 13, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(process_cpu_seconds_total{job=\"trustpoint\"}[$__rate_interval])", + "legendFormat": "cpu", + "range": true, + "refId": "A" + } + ], + "title": "CPU usage", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Should be zero in a healthy production deployment.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 0, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 22 + }, + "id": 14, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(django_migrations_unapplied_total{job=\"trustpoint\"}) or vector(0)", + "legendFormat": "unapplied migrations", + "range": true, + "refId": "A" + } + ], + "title": "Unapplied migrations", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 26 + }, + "id": 15, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "process_resident_memory_bytes{job=\"trustpoint\"}", + "legendFormat": "resident memory", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "process_virtual_memory_bytes{job=\"trustpoint\"}", + "legendFormat": "virtual memory", + "range": true, + "refId": "B" + } + ], + "title": "Memory usage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 26 + }, + "id": 16, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(process_cpu_seconds_total{job=\"trustpoint\"}[$__rate_interval])", + "legendFormat": "cpu cores", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "process_open_fds{job=\"trustpoint\"}", + "legendFormat": "open fds", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "process_max_fds{job=\"trustpoint\"}", + "legendFormat": "max fds", + "range": true, + "refId": "C" + } + ], + "title": "CPU and file descriptors", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 34 + }, + "id": 102, + "panels": [], + "title": "Django application details", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Django responses by template name. Useful for checking login and rendered pages during demos.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 35 + }, + "id": 17, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum by (templatename) (rate(django_http_responses_total_by_templatename_total{job=\"trustpoint\"}[$__rate_interval]))", + "legendFormat": "{{templatename}}", + "range": true, + "refId": "A" + } + ], + "title": "Responses by template", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Django exceptions by type. Empty or zero is good.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 35 + }, + "id": 18, + "options": { + "displayMode": "gradient", + "minVizHeight": 10, + "minVizWidth": 0, + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "valueMode": "color" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum by (type) (increase(django_http_exceptions_total_by_type_total{job=\"trustpoint\"}[$__range])) or vector(0)", + "legendFormat": "{{type}}", + "range": true, + "refId": "A" + } + ], + "title": "Exceptions by type", + "type": "bargauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Django model write activity. Empty or zero is expected until the demo creates, updates, or deletes objects.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 35 + }, + "id": 19, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(django_model_inserts_total{job=\"trustpoint\"}[$__rate_interval])) or vector(0)", + "legendFormat": "inserts/s", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(django_model_updates_total{job=\"trustpoint\"}[$__rate_interval])) or vector(0)", + "legendFormat": "updates/s", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(django_model_deletes_total{job=\"trustpoint\"}[$__rate_interval])) or vector(0)", + "legendFormat": "deletes/s", + "range": true, + "refId": "C" + } + ], + "title": "Model write activity", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 42 + }, + "id": 103, + "panels": [], + "title": "Observability pipeline", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Prometheus-side scrape health for the Trustpoint metrics endpoint.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 43 + }, + "id": 20, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "scrape_duration_seconds{job=\"trustpoint\"}", + "legendFormat": "scrape duration seconds", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "scrape_samples_scraped{job=\"trustpoint\"}", + "legendFormat": "samples scraped", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "scrape_series_added{job=\"trustpoint\"}", + "legendFormat": "series added", + "range": true, + "refId": "C" + } + ], + "title": "Scrape duration, samples, and series", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Metric families currently exported by Trustpoint. Useful for validating the demo setup.", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 43 + }, + "id": 21, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "count by (__name__)({job=\"trustpoint\"})", + "format": "table", + "instant": true, + "legendFormat": "{{__name__}}", + "range": false, + "refId": "A" + } + ], + "title": "Exported metric families", + "type": "table" + } + ], + "refresh": "10s", + "schemaVersion": 39, + "tags": [ + "trustpoint", + "tp_wizard", + "production", + "demo" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "Trustpoint Production Overview", + "uid": "trustpoint-overview", + "version": 2, + "weekStart": "" +} +EOF2 +} + +prepare_grafana_provisioning(){ + prepare_grafana_datasource + prepare_grafana_dashboard_provider + prepare_grafana_trustpoint_dashboard + ok "Generated Grafana datasource and dashboard provisioning under ${TP_WIZARD_GENERATED_ROOT}/grafana" +} + +start_prometheus(){ + $EN_PROMETHEUS || return 0 + + local name="prometheus" + stop_one "$name" + + if port_in_use "$PROMETHEUS_PORT"; then + die "Host port ${PROMETHEUS_PORT} is in use (Prometheus)." + fi + + ensure_prometheus_config + + log "Starting Prometheus..." + docker run -d --name "$name" --network "$NET" \ + -p "${PROMETHEUS_PORT}:9090" \ + -v "${PROMETHEUS_CONFIG}:/etc/prometheus/prometheus.yml:ro" \ + "$PROMETHEUS_IMAGE" \ + --config.file=/etc/prometheus/prometheus.yml >/dev/null +} + +start_grafana(){ + $EN_GRAFANA || return 0 + + local name="grafana" + stop_one "$name" + + if port_in_use "$GRAFANA_PORT"; then + die "Host port ${GRAFANA_PORT} is in use (Grafana)." + fi + + ensure_volume "$VOL_GRAFANA" + prepare_grafana_provisioning + + log "Starting Grafana..." + docker run -d --name "$name" --network "$NET" \ + -p "${GRAFANA_PORT}:3000" \ + -v "${VOL_GRAFANA}:/var/lib/grafana" \ + -v "${GRAFANA_PROVISIONING_ROOT}:/etc/grafana/provisioning:ro" \ + -v "${GRAFANA_DASHBOARDS_DIR}:/var/lib/grafana/dashboards:ro" \ + -e "GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER}" \ + -e "GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASS}" \ + -e "GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/trustpoint-overview.json" \ + "$GRAFANA_IMAGE" >/dev/null +} + +await_monitoring_ready(){ + if $EN_PROMETHEUS; then + echo "Waiting (<= ${READINESS_TIMEOUT}s) for Prometheus on localhost:${PROMETHEUS_PORT} ..." + local prometheus_deadline=$(( $(date +%s) + READINESS_TIMEOUT )) + while (( $(date +%s) < prometheus_deadline )); do + if tcp_check 127.0.0.1 "$PROMETHEUS_PORT" 1; then + ok "Prometheus reachable on :${PROMETHEUS_PORT}" + break + fi + printf "." + sleep 1 + done + echo + fi + + if $EN_GRAFANA; then + echo "Waiting (<= ${READINESS_TIMEOUT}s) for Grafana on localhost:${GRAFANA_PORT} ..." + local grafana_deadline=$(( $(date +%s) + READINESS_TIMEOUT )) + while (( $(date +%s) < grafana_deadline )); do + if tcp_check 127.0.0.1 "$GRAFANA_PORT" 1; then + ok "Grafana reachable on :${GRAFANA_PORT}" + break + fi + printf "." + sleep 1 + done + echo + fi +} diff --git a/scripts/tp_wizard/services/postgres.sh b/scripts/tp_wizard/services/postgres.sh new file mode 100644 index 000000000..5372b140c --- /dev/null +++ b/scripts/tp_wizard/services/postgres.sh @@ -0,0 +1,34 @@ +step_enable_postgres(){ + EN_PG=$(ask_yes_no "Start PostgreSQL container?" "y" && echo true || echo false) + DB_INTERNAL=$EN_PG + if $DB_INTERNAL; then DB_HOST="$DEF_DB_HOST_INTERNAL"; fi +} + + +step_postgres_config(){ + if $DB_INTERNAL; then + DB_NAME="$(ask_dbname 'PostgreSQL database name' "$DB_NAME")" + DB_USER="$(ask_user 'PostgreSQL username' "$DB_USER")" + DB_PASS="$(ask_password 'PostgreSQL password' "$DB_PASS")" + # Immediate check: host port must be free to publish + DB_PORT="$(ask_free_port 'PostgreSQL host port (mapped, loopback only)' "$DB_PORT")" + else + DB_HOST="$(ask 'External DB host/IP' "$DB_HOST"; echo "$REPLY")" + DB_PORT="$(ask_port 'External DB port' "$DB_PORT")" + DB_NAME="$(ask_dbname 'External DB database name' "$DB_NAME")" + DB_USER="$(ask_user 'External DB username' "$DB_USER")" + DB_PASS="$(ask_password 'External DB password' "$DB_PASS")" + fi +} + + +start_postgres(){ + $DB_INTERNAL || return 0 + ensure_volumes + local name="postgres" + stop_one "$name" + # safety: host port must still be free (non-interactive runs) + if port_in_use "$DB_PORT"; then die "Host port ${DB_PORT} is already in use. Choose another port or stop the process using it."; fi + log "Starting PostgreSQL..." + docker run -d --name "$name" --network "$NET" -p "127.0.0.1:${DB_PORT}:5432" -v "${VOL_DB}:/var/lib/postgresql/data" -e "POSTGRES_DB=$DB_NAME" -e "POSTGRES_USER=$DB_USER" -e "POSTGRES_PASSWORD=$DB_PASS" "$PG_IMAGE" >/dev/null +} diff --git a/scripts/tp_wizard/services/sftpgo.sh b/scripts/tp_wizard/services/sftpgo.sh new file mode 100644 index 000000000..11a1e15ef --- /dev/null +++ b/scripts/tp_wizard/services/sftpgo.sh @@ -0,0 +1,155 @@ +sftpgo_prompt_config(){ + EN_SFTPGO=$(ask_yes_no "Enable SFTPGo (demo SFTP + Web UI)?" "n" && echo true || echo false) + if $EN_SFTPGO; then + SFTPGO_SFTP_PORT="$(ask_free_port 'SFTPGo SFTP host port' "$SFTPGO_SFTP_PORT")" + SFTPGO_WEB_PORT="$(ask_free_port 'SFTPGo Web UI host port' "$SFTPGO_WEB_PORT")" + SFTPGO_ADMIN_USER="$(ask_user 'SFTPGo admin user' "$SFTPGO_ADMIN_USER")" + SFTPGO_ADMIN_PASS="$(ask_password 'SFTPGo admin password' "$SFTPGO_ADMIN_PASS")" + + # Mandatory backup user + ask_user "SFTPGo backup username" "$SFTPGO_BACKUP_USER"; SFTPGO_BACKUP_USER="$REPLY" + ask_password "SFTPGo backup password" "$SFTPGO_BACKUP_PASS"; SFTPGO_BACKUP_PASS="$REPLY" + SFTPGO_BACKUP_HOME="/srv/sftpgo/data/${SFTPGO_BACKUP_USER}" + ask "SFTPGo backup home (inside container)" "$SFTPGO_BACKUP_HOME"; SFTPGO_BACKUP_HOME="$REPLY" + fi +} + +start_sftpgo(){ + $EN_SFTPGO || return 0 + local name="sftpgo" + stop_one "$name" + + if port_in_use "$SFTPGO_SFTP_PORT"; then die "Host port ${SFTPGO_SFTP_PORT} in use (SFTPGo SFTP)."; fi + if port_in_use "$SFTPGO_WEB_PORT"; then die "Host port ${SFTPGO_WEB_PORT} in use (SFTPGo Web)."; fi + + mkdir -p "${SFTPGO_ROOT}/data" + if [[ -n "$SFTPGO_BACKUP_USER" ]]; then + mkdir -p "${SFTPGO_ROOT}/data/${SFTPGO_BACKUP_USER}" + fi + chown -R 1000:1000 "${SFTPGO_ROOT}" 2>/dev/null || true + + log "Starting SFTPGo with auto-created admin..." + docker run -d --name "$name" --network "$NET" \ + -p "${SFTPGO_SFTP_PORT}:2022" \ + -p "${SFTPGO_WEB_PORT}:8080" \ + -v "${SFTPGO_ROOT}:/srv/sftpgo" \ + -e SFTPGO_DATA_PROVIDER__CREATE_DEFAULT_ADMIN=true \ + -e SFTPGO_DEFAULT_ADMIN_USERNAME="$SFTPGO_ADMIN_USER" \ + -e SFTPGO_DEFAULT_ADMIN_PASSWORD="$SFTPGO_ADMIN_PASS" \ + -e SFTPGO_HTTPD__BINDINGS__0__ADDRESS="0.0.0.0" \ + -e SFTPGO_HTTPD__BINDINGS__0__ENABLE_REST_API=true \ + -e SFTPGO_HTTPD__BINDINGS__0__ENABLE_WEB_ADMIN=true \ + -e SFTPGO_HTTPD__BINDINGS__0__PORT=8080 \ + -e SFTPGO_SFTPD__BINDINGS__0__PORT=2022 \ + "$SFTPGO_IMAGE" >/dev/null +} + + +await_sftpgo_ready(){ + $EN_SFTPGO || return 0 + local PORT; PORT="$(sftpgo_web_port)" + echo "Waiting (<= ${READINESS_TIMEOUT}s) for SFTPGo API on localhost:${PORT} ..." + local until=$(( $(date +%s) + READINESS_TIMEOUT )) + while (( $(date +%s) < until )); do + if have curl && [[ "$(curl -fsS "http://127.0.0.1:${PORT}/healthz" 2>/dev/null || true)" == "ok" ]]; then + ok "SFTPGo API healthy on :${PORT}" + return 0 + fi + if tcp_check 127.0.0.1 "$PORT" 1; then + ok "SFTPGo API port open on :${PORT}" + return 0 + fi + printf "."; sleep 1 + done + echo + warn "SFTPGo API not confirmed after ${READINESS_TIMEOUT}s" +} + + +upsert_virtual_folder(){ + local vf_name="$1" mapped="$2" + read -r -d '' VF_PAYLOAD <&2 + return 1 + fi + ok "Virtual folder '${vf_name}' → '${mapped}' ready." +} + + +provision_sftpgo_backup_user(){ + $EN_SFTPGO || return 0 + have curl || { warn "curl not found on host; skipping SFTPGo user provisioning."; return 0; } + + [[ -z "${SFTPGO_BACKUP_HOME:-}" ]] && SFTPGO_BACKUP_HOME="/srv/sftpgo/data/${SFTPGO_BACKUP_USER}" + + local host_home="${SFTPGO_ROOT}${SFTPGO_BACKUP_HOME#/srv/sftpgo}" + mkdir -p "$host_home" + chown -R 1000:1000 "$host_home" 2>/dev/null || true + + local PORT; PORT="$(sftpgo_web_port)" + API="http://127.0.0.1:${PORT}" + + for _ in {1..30}; do + [[ "$(curl -fsS "${API}/healthz" 2>/dev/null || true)" == "ok" ]] && break + sleep 1 + done + + local token="" + for _ in 1 2 3; do + token="$(curl -fsS -u "${SFTPGO_ADMIN_USER}:${SFTPGO_ADMIN_PASS}" "${API}/api/v2/token" \ + | sed -nE 's/.*"access_token":"([^"]+)".*/\1/p')" || true + [[ -n "$token" ]] && break + sleep 1 + done + [[ -n "$token" ]] || { warn "Could not obtain SFTPGo admin token; skipping user provisioning."; return 0; } + + HDR=(-H "Authorization: Bearer ${token}" -H "Content-Type: application/json") + upsert_virtual_folder "trustpoint" "${SFTPGO_BACKUP_HOME}" || return 0 + + local code method url http + code="$(curl -s -o /dev/null -w '%{http_code}' "${HDR[@]}" "${API}/api/v2/users/${SFTPGO_BACKUP_USER}")" + if [[ "$code" == "200" ]]; then + method=PUT; url="${API}/api/v2/users/${SFTPGO_BACKUP_USER}" + else + method=POST; url="${API}/api/v2/users" + fi + + read -r -d '' payload <&2 + else + ok "SFTPGo user '${SFTPGO_BACKUP_USER}' provisioned; VF mounted at /upload." + fi +} + +# ---- TLS fingerprint wait ---------------------------------------------------- diff --git a/scripts/tp_wizard/services/trustpoint.sh b/scripts/tp_wizard/services/trustpoint.sh new file mode 100644 index 000000000..6a142f3b6 --- /dev/null +++ b/scripts/tp_wizard/services/trustpoint.sh @@ -0,0 +1,180 @@ +step_enable_trustpoint(){ EN_APP=$(ask_yes_no "Enable trustpoint application container?" "y" && echo true || echo false); } + + +step_trustpoint_source(){ + $EN_APP || return 0 + if ask_yes_no "Build trustpoint locally from ${TP_DOCKERFILE}? (No = pull from Docker Hub)" "y"; then + BUILD_LOCAL=true + APP_IMAGE="trustpoint:local" + else + BUILD_LOCAL=false + ask "Docker Hub image tag to pull (repository ${TP_REPO})" "latest"; local tag="$REPLY" + APP_IMAGE="${TP_REPO}:${tag}" + fi +} + + +step_app_db_binding(){ + $EN_APP || return 0 + if ask_yes_no "Should trustpoint reuse the PostgreSQL settings configured above?" "y"; then + APP_DB_NAME="$DB_NAME" + APP_DB_USER="$DB_USER" + APP_DB_PASS="$DB_PASS" + if $DB_INTERNAL; then + # Internal DB: always connect to the container directly + APP_DB_HOST="$DEF_DB_HOST_INTERNAL" + APP_DB_PORT=5432 + else + # External DB: use exactly what you entered + APP_DB_HOST="$DB_HOST" + APP_DB_PORT="$DB_PORT" + fi + else + local def_host def_port + if $DB_INTERNAL; then + def_host="$DEF_DB_HOST_INTERNAL"; def_port=5432 + else + def_host="$DB_HOST"; def_port="$DB_PORT" + fi + APP_DB_HOST="$(ask 'trustpoint DB host' "$def_host"; echo "$REPLY")" + APP_DB_PORT="$(ask_port 'trustpoint DB port' "$def_port")" + APP_DB_NAME="$(ask_dbname 'trustpoint DB name' "$DB_NAME")" + APP_DB_USER="$(ask_user 'trustpoint DB user' "$DB_USER")" + APP_DB_PASS="$(ask_password 'trustpoint DB password' "$DB_PASS")" + fi +} + + +step_trustpoint_runtime_env(){ + $EN_APP || return 0 + + ask "Trustpoint TLS DNS names (comma-separated, no protocol)" "$TP_TLS_DNS_NAMES_VALUE" + TP_TLS_DNS_NAMES_VALUE="$REPLY" + + ask "Trustpoint TLS IPv4 addresses (comma-separated, optional)" "$TP_TLS_IPV4_ADDRESSES_VALUE" + TP_TLS_IPV4_ADDRESSES_VALUE="$REPLY" + + ask "Trustpoint TLS IPv6 addresses (comma-separated, optional)" "$TP_TLS_IPV6_ADDRESSES_VALUE" + TP_TLS_IPV6_ADDRESSES_VALUE="$REPLY" + + if ask_yes_no "Skip trustpoint in-app setup wizard using ${TRUSTPOINT_SKIP_SETUP_ENV_KEY}?" "n"; then + TP_SKIP_SETUP_VALUE="true" + else + TP_SKIP_SETUP_VALUE="false" + fi +} + + +build_trustpoint_image(){ [[ -f "$TP_DOCKERFILE" ]] || log "Dockerfile not found: $TP_DOCKERFILE"; log "Building trustpoint image..."; docker build -f "$TP_DOCKERFILE" -t "trustpoint:local" .; } + +pull_trustpoint_image(){ log "Pulling ${APP_IMAGE} ..."; docker pull "${APP_IMAGE}" >/dev/null; } + +configure_app_image_prompt(){ + if ask_yes_no "Build trustpoint locally from ${TP_DOCKERFILE}? (No = pull from Docker Hub)" "y"; then + BUILD_LOCAL=true + APP_IMAGE="trustpoint:local" + else + BUILD_LOCAL=false + ask "Docker Hub image tag to pull (repository ${TP_REPO})" "latest" + local tag="$REPLY" + APP_IMAGE="${TP_REPO}:${tag}" + fi +} + +resolve_app_image(){ + if ! $EN_APP && ! $EN_WF2_WORKER; then + return 0 + fi + if $BUILD_LOCAL; then + build_trustpoint_image + else + pull_trustpoint_image + fi +} + +start_app(){ + $EN_APP || return 0 + local name="trustpoint" + stop_one "$name" + # die early if 80/443 are busy + if port_in_use "$APP_HTTP_HOST"; then die "Host port ${APP_HTTP_HOST} is in use (trustpoint HTTP)."; fi + if port_in_use "$APP_HTTPS_HOST"; then die "Host port ${APP_HTTPS_HOST} is in use (trustpoint HTTPS)."; fi + + log "Starting trustpoint..." + local env_file_arg=() wizard_env_target + wizard_env_target="$(tp_wizard_env_target)" + [[ -f "$ENV_FILE" ]] && env_file_arg+=( --env-file "$ENV_FILE" ) + if [[ "$wizard_env_target" != "$ENV_FILE" && -f "$wizard_env_target" ]]; then + env_file_arg+=( --env-file "$wizard_env_target" ) + fi + + local smtp_env=() + if $EN_MAILPIT; then + smtp_env+=( -e "EMAIL_HOST=mailpit" -e "EMAIL_PORT=1025" -e "EMAIL_USE_TLS=0" -e "EMAIL_USE_SSL=0" -e "DEFAULT_FROM_EMAIL=no-reply@trustpoint.local" ) + fi + + local skip_env=() skip_key + for skip_key in $TRUSTPOINT_SKIP_SETUP_ENV_KEYS; do + [[ -n "$skip_key" ]] || continue + skip_env+=( -e "${skip_key}=${TP_SKIP_SETUP_VALUE}" ) + done + if [[ " $TRUSTPOINT_SKIP_SETUP_ENV_KEYS " != *" TP_SKIP_SETUP "* ]]; then + skip_env+=( -e "TP_SKIP_SETUP=" ) + fi + if [[ "$TP_SKIP_SETUP_VALUE" == "true" ]]; then + skip_env+=( + -e "TP_ADMIN_USERNAME=${TP_ADMIN_USERNAME_VALUE}" + -e "TP_ADMIN_PASSWORD=${TP_ADMIN_PASSWORD_VALUE}" + -e "TP_ADMIN_EMAIL=${TP_ADMIN_EMAIL_VALUE}" + -e "TP_INJECT_DEMO_DATA=${TP_INJECT_DEMO_DATA_VALUE}" + -e "TP_ENABLE_PROMETHEUS_METRICS=${TP_ENABLE_PROMETHEUS_METRICS_VALUE}" + ) + fi + + docker run -d --name "$name" --network "$NET" \ + --network-alias trustpoint.local \ + -p "${APP_HTTP_HOST}:80" \ + -p "${APP_HTTPS_HOST}:443" \ + "${env_file_arg[@]}" \ + -e "POSTGRES_DB=$APP_DB_NAME" \ + -e "DATABASE_USER=$APP_DB_USER" \ + -e "DATABASE_PASSWORD=$APP_DB_PASS" \ + -e "DATABASE_HOST=$APP_DB_HOST" \ + -e "DATABASE_PORT=$APP_DB_PORT" \ + -e "TP_HTTP_PORT=$APP_HTTP_HOST" \ + -e "TP_HTTPS_PORT=$APP_HTTPS_HOST" \ + -e "TP_TLS_DNS_NAMES=$TP_TLS_DNS_NAMES_VALUE" \ + -e "TP_TLS_IPV4_ADDRESSES=$TP_TLS_IPV4_ADDRESSES_VALUE" \ + -e "TP_TLS_IPV6_ADDRESSES=$TP_TLS_IPV6_ADDRESSES_VALUE" \ + "${skip_env[@]}" \ + "${smtp_env[@]}" \ + "$APP_IMAGE" >/dev/null +} + + +extract_tls_fingerprint_once(){ + local logs="$1" + if [[ "$logs" =~ ([0-9A-Fa-f]{2}:){31}[0-9A-Fa-f]{2} ]]; then TLS_FP_FOUND="${BASH_REMATCH[0]}"; return 0; fi + if [[ "$logs" =~ ([0-9A-Fa-f]{64}) ]]; then TLS_FP_FOUND="${BASH_REMATCH[1]}"; return 0; fi + if [[ "$logs" =~ [Ss][Hh][Aa]-?256[:\ ]([A-Za-z0-9+/=_:-]{43,}) ]]; then TLS_FP_FOUND="SHA256:${BASH_REMATCH[1]}"; return 0; fi + return 1 +} + + +wait_tls_fingerprint(){ + $EN_APP || { TLS_FP_ELAPSED=0; return 0; } + local start; start="$(date +%s)" + local start_iso; start_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + local end=$(( start + TLS_FP_TIMEOUT )) + while (( $(date +%s) < end )); do + local chunk + chunk="$(docker logs --since "$start_iso" trustpoint 2>/dev/null || true)" + if extract_tls_fingerprint_once "$chunk"; then + TLS_FP_ELAPSED=$(( $(date +%s) - start )) + return 0 + fi + sleep 3 + done + TLS_FP_ELAPSED=$(( $(date +%s) - start )) + return 1 +} diff --git a/scripts/tp_wizard/services/workflows2_worker.sh b/scripts/tp_wizard/services/workflows2_worker.sh new file mode 100644 index 000000000..29d906298 --- /dev/null +++ b/scripts/tp_wizard/services/workflows2_worker.sh @@ -0,0 +1,77 @@ +step_workflows2_worker(){ + $EN_APP || return 0 + EN_WF2_WORKER=$( + ask_yes_no "Delegate workflows2 tasks to a dedicated worker container?" "n" && echo true || echo false + ) +} + + +prepare_workflows2_worker_folder(){ + $EN_WF2_WORKER || return 0 + mkdir -p "$WF2_FOLDER" + chmod 700 "$WF2_FOLDER" 2>/dev/null || true + cat > "$WF2_WORKER_README" </dev/null || true + + cat > "$WF2_WORKER_ENV_FILE" <> "$WF2_WORKER_ENV_FILE" + done + if [[ "$TP_SKIP_SETUP_VALUE" == "true" ]]; then + cat >> "$WF2_WORKER_ENV_FILE" <> "$WF2_WORKER_ENV_FILE" </dev/null || true +} + + +start_workflows2_worker(){ + $EN_WF2_WORKER || return 0 + local name="$WF2_WORKER_NAME" + stop_one "$name" + prepare_workflows2_worker_folder + log "Starting dedicated workflows2 worker..." + docker run -d --name "$name" --network "$NET" --env-file "$WF2_WORKER_ENV_FILE" "$APP_IMAGE" >/dev/null +} diff --git a/scripts/tp_wizard/state.sh b/scripts/tp_wizard/state.sh new file mode 100644 index 000000000..2292e0bda --- /dev/null +++ b/scripts/tp_wizard/state.sh @@ -0,0 +1,65 @@ +# -------------------------- Wizard state ------------------------------------- +EN_APP=false +EN_PG=false +EN_MAILPIT=false +EN_SFTPGO=false +EN_WF2_WORKER=false +EN_PROMETHEUS=false +EN_GRAFANA=false + +DB_INTERNAL=true +DB_HOST="$DEF_DB_HOST" +DB_PORT="$DEF_DB_PORT" # host-mapped port for convenience access +DB_NAME="$DEF_DB_NAME" +DB_USER="$DEF_DB_USER" +DB_PASS="$DEF_DB_PASS" + +APP_DB_HOST="$DB_HOST" +APP_DB_PORT="$DB_PORT" +APP_DB_NAME="$DB_NAME" +APP_DB_USER="$DB_USER" +APP_DB_PASS="$DEF_DB_PASS" + +TP_TLS_DNS_NAMES_VALUE="$DEF_TP_TLS_DNS_NAMES" +TP_TLS_IPV4_ADDRESSES_VALUE="$DEF_TP_TLS_IPV4_ADDRESSES" +TP_TLS_IPV6_ADDRESSES_VALUE="$DEF_TP_TLS_IPV6_ADDRESSES" +TP_SKIP_SETUP_VALUE="$DEF_TRUSTPOINT_SKIP_SETUP_VALUE" +TP_ADMIN_USERNAME_VALUE="$DEF_TP_ADMIN_USERNAME" +TP_ADMIN_PASSWORD_VALUE="$DEF_TP_ADMIN_PASSWORD" +TP_ADMIN_EMAIL_VALUE="$DEF_TP_ADMIN_EMAIL" +TP_INJECT_DEMO_DATA_VALUE="$DEF_TP_INJECT_DEMO_DATA" +TP_ENABLE_PROMETHEUS_METRICS_VALUE="$DEF_TP_ENABLE_PROMETHEUS_METRICS" + +MAILPIT_SMTP_PORT="$DEF_MAILPIT_SMTP_PORT" +MAILPIT_UI_PORT="$DEF_MAILPIT_UI_PORT" + +SFTPGO_SFTP_PORT="$DEF_SFTPGO_SFTP_PORT" +SFTPGO_WEB_PORT="$DEF_SFTPGO_WEB_PORT" +SFTPGO_ADMIN_USER="$DEF_SFTPGO_ADMIN_USER" +SFTPGO_ADMIN_PASS="$DEF_SFTPGO_ADMIN_PASS" + +PROMETHEUS_PORT="$DEF_PROMETHEUS_PORT" +GRAFANA_PORT="$DEF_GRAFANA_PORT" +GRAFANA_ADMIN_USER="$DEF_GRAFANA_ADMIN_USER" +GRAFANA_ADMIN_PASS="$DEF_GRAFANA_ADMIN_PASS" +TRUSTPOINT_METRICS_SCHEME_VALUE="$TRUSTPOINT_METRICS_SCHEME" +TRUSTPOINT_METRICS_TARGET_VALUE="$TRUSTPOINT_METRICS_TARGET" +TRUSTPOINT_METRICS_PATH_VALUE="$TRUSTPOINT_METRICS_PATH" +TRUSTPOINT_METRICS_TLS_INSECURE_SKIP_VERIFY_VALUE="$TRUSTPOINT_METRICS_TLS_INSECURE_SKIP_VERIFY" + +TLS_FP_FOUND="" +TLS_FP_ELAPSED=0 +WF2_WORKER_LEASE="$DEF_WF2_WORKER_LEASE" +WF2_WORKER_BATCH="$DEF_WF2_WORKER_BATCH" +WF2_WORKER_SLEEP="$DEF_WF2_WORKER_SLEEP" + +# CLI target flags +ONLY_APP=false +ONLY_DB=false +ONLY_MAIL=false +ONLY_SFTP=false +ONLY_WF2_WORKER=false +ONLY_PROMETHEUS=false +ONLY_GRAFANA=false +DEMO_PRESET="" +NOWAIT=false diff --git a/scripts/tp_wizard/summary.sh b/scripts/tp_wizard/summary.sh new file mode 100644 index 000000000..dc4a6f273 --- /dev/null +++ b/scripts/tp_wizard/summary.sh @@ -0,0 +1,221 @@ +# shellcheck shell=bash +# Compact, developer-friendly plan/status/summary output. + +_tp_line(){ + printf '%s\n' '------------------------------------------------------------' +} + +_tp_title(){ + echo + printf '%s\n' "$(bold)$1$(rst)" + _tp_line +} + +_tp_kv(){ + local key="$1" value="$2" + [[ -n "$value" ]] || return 0 + printf ' %-16s %s\n' "$key" "$value" +} + +_tp_join_words(){ + local first=true item + for item in "$@"; do + if $first; then + printf '%s' "$item" + first=false + else + printf ', %s' "$item" + fi + done +} + +_tp_env_summary(){ + local target + target="$(tp_wizard_env_target)" + if [[ "$target" == "$ENV_FILE" ]]; then + printf '%s' "${ENV_FILE} (wizard writes here)" + elif [[ -f "$ENV_FILE" ]]; then + printf '%s' "${ENV_FILE} + ${target}" + else + printf '%s' "${target}" + fi +} + +_tp_enabled_services(){ + local services=() + $EN_APP && services+=(trustpoint) + $DB_INTERNAL && services+=(postgres) + $EN_MAILPIT && services+=(mailpit) + $EN_SFTPGO && services+=(sftpgo) + $EN_WF2_WORKER && services+=(worker) + $EN_PROMETHEUS && services+=(prometheus) + $EN_GRAFANA && services+=(grafana) + + if ((${#services[@]} == 0)); then + printf '%s' 'none' + else + _tp_join_words "${services[@]}" + fi +} + +_tp_app_urls_from_state(){ + local urls=() + $EN_APP || return 0 + urls+=("http://localhost:${APP_HTTP_HOST}") + urls+=("https://localhost:${APP_HTTPS_HOST}") + _tp_join_words "${urls[@]}" +} + +_tp_status_text(){ + local state="$1" health="${2:-}" + case "$state" in + absent) printf '%s' 'absent' ;; + running) + case "$health" in + healthy) printf '%s' 'up/healthy' ;; + starting) printf '%s' 'up/starting' ;; + unhealthy) printf '%s' 'up/unhealthy' ;; + -|'') printf '%s' 'up' ;; + *) printf '%s' "up/${health}" ;; + esac + ;; + exited|dead) printf '%s' 'stopped' ;; + *) printf '%s' "$state" ;; + esac +} + +_tp_container_url(){ + local name="$1" port_spec="$2" scheme="$3" path="${4:-}" + local port + port="$(container_host_port "$name" "$port_spec")" + [[ -n "$port" ]] || return 0 + printf '%s://localhost:%s%s' "$scheme" "$port" "$path" +} + +_tp_live_trustpoint_urls(){ + local http_port https_port urls=() + http_port="$(container_host_port trustpoint 80/tcp)" + https_port="$(container_host_port trustpoint 443/tcp)" + [[ -n "$http_port" ]] && urls+=("http://localhost:${http_port}") + [[ -n "$https_port" ]] && urls+=("https://localhost:${https_port}") + ((${#urls[@]} > 0)) && _tp_join_words "${urls[@]}" +} + +_tp_live_sftpgo_urls(){ + local web_port sftp_port urls=() + web_port="$(container_host_port sftpgo 8080/tcp)" + sftp_port="$(container_host_port sftpgo 2022/tcp)" + [[ -n "$web_port" ]] && urls+=("http://localhost:${web_port}/web/admin") + [[ -n "$sftp_port" ]] && urls+=("sftp://localhost:${sftp_port}") + ((${#urls[@]} > 0)) && _tp_join_words "${urls[@]}" +} + +_tp_live_postgres_url(){ + local pg_port + pg_port="$(container_host_port postgres 5432/tcp)" + [[ -n "$pg_port" ]] && printf 'localhost:%s' "$pg_port" +} + +_tp_runtime_row(){ + local name="$1" label="$2" url="$3" + local state health status + state="$(container_state "$name")" + health="$(container_health "$name")" + status="$(_tp_status_text "$state" "$health")" + printf ' %-18s %-13s %s\n' "$label" "$status" "${url:--}" +} + +_tp_runtime_rows(){ + printf ' %-18s %-13s %s\n' 'Service' 'Status' 'Access' + printf ' %-18s %-13s %s\n' '-------' '------' '------' + _tp_runtime_row trustpoint 'trustpoint' "$(_tp_live_trustpoint_urls)" + _tp_runtime_row postgres 'postgres' "$(_tp_live_postgres_url)" + _tp_runtime_row mailpit 'mailpit' "$(_tp_container_url mailpit 8025/tcp http)" + _tp_runtime_row sftpgo 'sftpgo' "$(_tp_live_sftpgo_urls)" + _tp_runtime_row "$WF2_WORKER_NAME" 'worker' '-' + _tp_runtime_row prometheus 'prometheus' "$(_tp_container_url prometheus 9090/tcp http)" + _tp_runtime_row grafana 'grafana' "$(_tp_container_url grafana 3000/tcp http)" +} + +show_plan(){ + _tp_title 'trustpoint setup plan' + _tp_kv 'services' "$(_tp_enabled_services)" + _tp_kv 'env files' "$(_tp_env_summary)" + + if $EN_APP; then + _tp_kv 'app' "$(_tp_app_urls_from_state)" + _tp_kv 'image' "$($BUILD_LOCAL && printf 'build local -> trustpoint:local' || printf 'pull -> %s' "$APP_IMAGE")" + _tp_kv 'database' "${APP_DB_USER}@${APP_DB_HOST}:${APP_DB_PORT}/${APP_DB_NAME}" + _tp_kv 'setup skip' "${TRUSTPOINT_SKIP_SETUP_ENV_KEY}=${TP_SKIP_SETUP_VALUE}" + fi + + $EN_MAILPIT && _tp_kv 'mailpit' "http://localhost:${MAILPIT_UI_PORT} (smtp ${MAILPIT_SMTP_PORT})" + $EN_SFTPGO && _tp_kv 'sftpgo' "http://localhost:${SFTPGO_WEB_PORT}/web/admin, sftp ${SFTPGO_SFTP_PORT}" + $EN_PROMETHEUS && _tp_kv 'prometheus' "http://localhost:${PROMETHEUS_PORT} -> ${TRUSTPOINT_METRICS_SCHEME_VALUE}://${TRUSTPOINT_METRICS_TARGET_VALUE}${TRUSTPOINT_METRICS_PATH_VALUE}" + $EN_GRAFANA && _tp_kv 'grafana' "http://localhost:${GRAFANA_PORT} (${GRAFANA_ADMIN_USER} / $(mask "$GRAFANA_ADMIN_PASS"))" + $EN_WF2_WORKER && _tp_kv 'worker' "$WF2_WORKER_NAME" + + _tp_line +} + +show_runtime_status(){ + local net_state='absent' db_vol_state='absent' grafana_vol_state='absent' + docker network inspect "$NET" >/dev/null 2>&1 && net_state='present' + docker volume inspect "$VOL_DB" >/dev/null 2>&1 && db_vol_state='present' + docker volume inspect "$VOL_GRAFANA" >/dev/null 2>&1 && grafana_vol_state='present' + + _tp_title 'trustpoint runtime status' + _tp_runtime_rows + echo + _tp_kv 'network' "${NET} (${net_state})" + _tp_kv 'env files' "$(_tp_env_summary)" + _tp_kv 'volumes' "db=${db_vol_state}, grafana=${grafana_vol_state}" + + if exists trustpoint; then + local db_host db_port db_name db_user setup_skip + db_host="$(container_env trustpoint DATABASE_HOST)" + db_port="$(container_env trustpoint DATABASE_PORT)" + db_name="$(container_env trustpoint POSTGRES_DB)" + db_user="$(container_env trustpoint DATABASE_USER)" + setup_skip="$(container_env trustpoint "$TRUSTPOINT_SKIP_SETUP_ENV_KEY")" + [[ -n "$db_host$db_port$db_name$db_user" ]] && _tp_kv 'database' "${db_user:-?}@${db_host:-?}:${db_port:-?}/${db_name:-?}" + [[ -n "$setup_skip" ]] && _tp_kv 'setup skip' "${TRUSTPOINT_SKIP_SETUP_ENV_KEY}=${setup_skip}" + fi + + _tp_line +} + +final_summary(){ + _tp_title 'trustpoint stack ready' + _tp_runtime_rows + echo + _tp_kv 'env files' "$(_tp_env_summary)" + + if $EN_APP; then + _tp_kv 'database' "${APP_DB_USER}@${APP_DB_HOST}:${APP_DB_PORT}/${APP_DB_NAME}" + _tp_kv 'setup skip' "${TRUSTPOINT_SKIP_SETUP_ENV_KEY}=${TP_SKIP_SETUP_VALUE}" + fi + + if $EN_SFTPGO; then + _tp_kv 'sftp admin' "${SFTPGO_ADMIN_USER} / $(mask "$SFTPGO_ADMIN_PASS")" + _tp_kv 'backup user' "${SFTPGO_BACKUP_USER} / $(mask "$SFTPGO_BACKUP_PASS")" + fi + + if $EN_PROMETHEUS; then + _tp_kv 'metrics' "${TRUSTPOINT_METRICS_SCHEME_VALUE}://${TRUSTPOINT_METRICS_TARGET_VALUE}${TRUSTPOINT_METRICS_PATH_VALUE}" + fi + if $EN_GRAFANA; then + _tp_kv 'grafana admin' "${GRAFANA_ADMIN_USER} / $(mask "$GRAFANA_ADMIN_PASS")" + _tp_kv 'dashboard' "Grafana -> Dashboards -> Trustpoint -> Trustpoint Overview" + fi + + if $EN_APP; then + if [[ -n "$TLS_FP_FOUND" ]]; then + _tp_kv 'tls fingerprint' "$TLS_FP_FOUND" + elif ! $NOWAIT; then + _tp_kv 'tls fingerprint' "not found after ${TLS_FP_ELAPSED}s" + fi + fi + + _tp_line +} diff --git a/scripts/tp_wizard/wizard.sh b/scripts/tp_wizard/wizard.sh new file mode 100644 index 000000000..d74a35d4f --- /dev/null +++ b/scripts/tp_wizard/wizard.sh @@ -0,0 +1,17 @@ +wizard(){ + echo "$(bold)trustpoint Setup Wizard$(rst)" + ensure_network + step_enable_trustpoint + step_trustpoint_source + step_enable_postgres + step_postgres_config + step_app_db_binding + step_trustpoint_runtime_env + mailpit_prompt_config + sftpgo_prompt_config + step_workflows2_worker + monitoring_prompt_config + show_plan + ask_yes_no "Proceed with these settings?" "y" || { warn "Aborted by user."; exit 1; } + runtime_start_enabled +} diff --git a/tp_wizard.sh b/tp_wizard.sh index da9c5bd71..d6cb6894d 100755 --- a/tp_wizard.sh +++ b/tp_wizard.sh @@ -1,976 +1,10 @@ #!/usr/bin/env bash -# tp_wizard.sh — single-file wizard for trustpoint stack +# tp_wizard.sh — public entrypoint for the trustpoint setup wizard set -euo pipefail -# -------------------------- Constants & defaults ------------------------------ -PROJECT="trustpoint" -NET="${PROJECT}-net" -VOL_DB="${PROJECT}_postgres_data" +TP_WIZARD_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export TP_WIZARD_ROOT -# trustpoint image handling -TP_DOCKERFILE="docker/trustpoint/Dockerfile" -TP_REPO="trustpointproject/trustpoint" -APP_IMAGE="${TP_REPO}:latest" # overridden to trustpoint:local when BUILD_LOCAL=true -BUILD_LOCAL=false +source "${TP_WIZARD_ROOT}/scripts/tp_wizard/bootstrap.sh" -# Fixed images -PG_IMAGE="postgres:15.14" -MAILPIT_IMAGE="axllent/mailpit:v1.27" -SFTPGO_IMAGE="drakkan/sftpgo:2.6.x-slim" -WF2_WORKER_NAME="trustpoint-worker" - -# Fixed trustpoint ports -APP_HTTP_HOST=80 -APP_HTTPS_HOST=443 - -# PostgreSQL defaults -DEF_DB_NAME="trustpoint_db" -DEF_DB_USER="admin" -DEF_DB_PASS="testing321" -DEF_DB_PORT=5432 -DEF_DB_HOST_INTERNAL="postgres" # container name/hostname - -# Mailpit defaults -DEF_MAILPIT_SMTP_PORT=1025 -DEF_MAILPIT_UI_PORT=8025 - -# SFTPGo defaults -DEF_SFTPGO_SFTP_PORT=2222 -DEF_SFTPGO_WEB_PORT=8080 -DEF_SFTPGO_ADMIN_USER="admin" -DEF_SFTPGO_ADMIN_PASS="testing321" -SFTPGO_ROOT="${PWD}/sftpgo-data" -WF2_FOLDER="${PWD}/workflow2Folder" -WF2_WORKER_ENV_FILE="${WF2_FOLDER}/worker.env" -WF2_WORKER_README="${WF2_FOLDER}/README.txt" -DEF_WF2_WORKER_LEASE=30 -DEF_WF2_WORKER_BATCH=10 -DEF_WF2_WORKER_SLEEP=1 -MAILPIT_PROBE_TIMEOUT=20 - -# Timeouts -READINESS_TIMEOUT=90 -TLS_FP_TIMEOUT=150 - -# Optional backup user provisioning -SFTPGO_BACKUP_USER="tpbackup" -SFTPGO_BACKUP_PASS="testing321" -SFTPGO_BACKUP_HOME="" - -# -------------------------- UI helpers --------------------------------------- -bold(){ tput bold 2>/dev/null || true; } -rst(){ tput sgr0 2>/dev/null || true; } -ylw(){ tput setaf 3 2>/dev/null || true; } -grn(){ tput setaf 2 2>/dev/null || true; } -red(){ tput setaf 1 2>/dev/null || true; } -log(){ printf "%s\n" "$*" >&2; } -ok(){ log "$(grn)✔$(rst) $*"; } -warn(){ log "$(ylw)⚠$(rst) $*"; } -err(){ log "$(red)✖$(rst) $*"; } -die(){ err "$*"; exit 1; } -have(){ command -v "$1" >/dev/null 2>&1; } - -# -------------------------- Docker helpers ----------------------------------- -exists(){ docker ps -a --format '{{.Names}}' | grep -Fxq "$1"; } -running(){ docker ps --format '{{.Names}}' | grep -Fxq "$1"; } -ensure_network(){ docker network inspect "$NET" >/dev/null 2>&1 || docker network create "$NET" >/dev/null; } -ensure_volumes(){ docker volume inspect "$VOL_DB" >/dev/null 2>&1 || docker volume create --label "tp.project=${PROJECT}" "$VOL_DB" >/dev/null; } -stop_one(){ local n="$1"; exists "$n" || return 0; running "$n" && docker stop "$n" >/dev/null || true; docker rm "$n" >/dev/null || true; } -container_state(){ local n="$1"; exists "$n" || { echo "absent"; return; }; docker inspect -f '{{.State.Status}}' "$n" 2>/dev/null || echo "unknown"; } -container_health(){ local n="$1" h=""; exists "$n" || { echo "-"; return; }; h="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$n" 2>/dev/null || true)"; echo "${h:--}"; } -container_image(){ local n="$1"; exists "$n" || { echo "-"; return; }; docker inspect -f '{{.Config.Image}}' "$n" 2>/dev/null || echo "-"; } -container_host_port(){ local n="$1" spec="$2" p=""; exists "$n" || return 0; p="$(docker port "$n" "$spec" 2>/dev/null | awk -F: 'NR==1 {print $NF}')" || true; echo "${p}"; } -container_env(){ local n="$1" key="$2"; exists "$n" || return 0; docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$n" 2>/dev/null | sed -n "s/^${key}=//p" | head -n1; } -container_volume_names(){ - local n="$1" - exists "$n" || return 0 - docker inspect -f '{{range .Mounts}}{{if eq .Type "volume"}}{{println .Name}}{{end}}{{end}}' "$n" 2>/dev/null | sed '/^$/d' -} -collect_project_volumes(){ - { - echo "$VOL_DB" - container_volume_names trustpoint - container_volume_names postgres - container_volume_names mailpit - container_volume_names sftpgo - container_volume_names "$WF2_WORKER_NAME" - } | sed '/^$/d' | sort -u -} -print_container_status_row(){ - local n="$1" - printf "%-20s %-10s %-10s %s\n" "$n" "$(container_state "$n")" "$(container_health "$n")" "$(container_image "$n")" -} - -# quick TCP connect test (true if something accepts on host:port) -tcp_check(){ local host="$1" port="$2" ts=$(( $(date +%s) + ${3:-5} )); while (( $(date +%s) < ts )); do (exec 3<>"/dev/tcp/$host/$port") >/dev/null 2>&1 && { exec 3>&- 3<&-; return 0; }; sleep 1; done; return 1; } -port_in_use(){ tcp_check 127.0.0.1 "$1" 1; } - -# SFTPGo host web port resolver (avoid NGINX :80) -sftpgo_web_port(){ - local p; p="$(docker port sftpgo 8080/tcp 2>/dev/null | awk -F: '{print $2}')" || true - echo "${p:-$SFTPGO_WEB_PORT}" -} - -# -------------------------- Input helpers ------------------------------------ -ask(){ local prompt="$1" def="${2:-}"; if [[ -n "$def" ]]; then read -r -p "$(bold)${prompt}$(rst) [default: ${def}] > " REPLY || true; REPLY="${REPLY:-$def}"; else read -r -p "$(bold)${prompt}$(rst) > " REPLY || true; fi; } -ask_yes_no(){ local prompt="$1" def="${2:-y}" a; case "${def}" in y|yes) a="[Y/n]";; n|no) a="[y/N]";; *) a="[y/n]";; esac; read -r -p "$(bold)${prompt} ${a}$(rst) > " resp || true; resp="${resp:-$def}"; [[ "${resp}" =~ ^y ]]; } -ask_port(){ local prompt="$1" def="$2" p; while true; do ask "$prompt" "$def"; p="$REPLY"; [[ "$p" =~ ^[0-9]{1,5}$ ]] && (( p>0 && p<65536 )) && { echo "$p"; return; } ; warn "Invalid port. Enter 1..65535."; done; } -ask_free_port(){ local prompt="$1" def="$2" p; while true; do p="$(ask_port "$prompt" "$def")"; if port_in_use "$p"; then warn "Port ${p} is already in use on this host. Pick another."; else echo "$p"; return; fi; done; } -ask_user(){ local prompt="$1" def="$2" u; while true; do ask "$prompt" "$def"; u="$REPLY"; [[ "$u" =~ ^[A-Za-z0-9_][A-Za-z0-9._-]*$ ]] && { echo "$u"; return; } ; warn "Invalid username."; done; } -ask_dbname(){ local prompt="$1" def="$2" d; while true; do ask "$prompt" "$def"; d="$REPLY"; [[ "$d" =~ ^[A-Za-z0-9_-]+$ ]] && { echo "$d"; return; } ; warn "Invalid DB name."; done; } -ask_password(){ local prompt="$1" def="$2" pw; while true; do ask "$prompt" "$def"; pw="$REPLY"; (( ${#pw} >= 6 )) && { echo "$pw"; return; } ; warn "Password too short (min 6)."; done; } -mask(){ local s="$1" n=${#1}; (( n<=2 )) && { printf '%s' '**'; return; }; printf '%*s' $((n-2)) '' | tr ' ' '*'; printf '%s' "${s: -2}"; } - -# -------------------------- Wizard state ------------------------------------- -EN_APP=false; EN_PG=false; EN_MAILPIT=false; EN_SFTPGO=false; EN_WF2_WORKER=false - -DB_INTERNAL=true -DB_HOST="$DEF_DB_HOST_INTERNAL" # default host when internal -DB_PORT="$DEF_DB_PORT" # host-mapped port for convenience access -DB_NAME="$DEF_DB_NAME" -DB_USER="$DEF_DB_USER" -DB_PASS="$DEF_DB_PASS" - -APP_DB_HOST="$DB_HOST" -APP_DB_PORT="$DB_PORT" -APP_DB_NAME="$DB_NAME" -APP_DB_USER="$DB_USER" -APP_DB_PASS="$DEF_DB_PASS" - -MAILPIT_SMTP_PORT="$DEF_MAILPIT_SMTP_PORT" -MAILPIT_UI_PORT="$DEF_MAILPIT_UI_PORT" - -SFTPGO_SFTP_PORT="$DEF_SFTPGO_SFTP_PORT" -SFTPGO_WEB_PORT="$DEF_SFTPGO_WEB_PORT" -SFTPGO_ADMIN_USER="$DEF_SFTPGO_ADMIN_USER" -SFTPGO_ADMIN_PASS="$DEF_SFTPGO_ADMIN_PASS" - -TLS_FP_FOUND="" -TLS_FP_ELAPSED=0 -WF2_WORKER_LEASE="$DEF_WF2_WORKER_LEASE" -WF2_WORKER_BATCH="$DEF_WF2_WORKER_BATCH" -WF2_WORKER_SLEEP="$DEF_WF2_WORKER_SLEEP" - -# CLI target flags -ONLY_APP=false; ONLY_DB=false; ONLY_MAIL=false; ONLY_SFTP=false; ONLY_WF2_WORKER=false -NOWAIT=false - -# -------------------------- Steps -------------------------------------------- -preflight(){ have docker || die "docker not found"; docker version >/dev/null || die "docker daemon not reachable"; } - -step_enable_trustpoint(){ EN_APP=$(ask_yes_no "Enable trustpoint application container?" "y" && echo true || echo false); } - -step_trustpoint_source(){ - $EN_APP || return 0 - if ask_yes_no "Build trustpoint locally from ${TP_DOCKERFILE}? (No = pull from Docker Hub)" "y"; then - BUILD_LOCAL=true - APP_IMAGE="trustpoint:local" - else - BUILD_LOCAL=false - ask "Docker Hub image tag to pull (repository ${TP_REPO})" "latest"; local tag="$REPLY" - APP_IMAGE="${TP_REPO}:${tag}" - fi -} - -step_enable_postgres(){ - EN_PG=$(ask_yes_no "Start PostgreSQL container?" "y" && echo true || echo false) - DB_INTERNAL=$EN_PG - if $DB_INTERNAL; then DB_HOST="$DEF_DB_HOST_INTERNAL"; fi -} - -step_postgres_config(){ - if $DB_INTERNAL; then - DB_NAME="$(ask_dbname 'PostgreSQL database name' "$DB_NAME")" - DB_USER="$(ask_user 'PostgreSQL username' "$DB_USER")" - DB_PASS="$(ask_password 'PostgreSQL password' "$DB_PASS")" - # Immediate check: host port must be free to publish - DB_PORT="$(ask_free_port 'PostgreSQL host port (mapped)' "$DB_PORT")" - else - DB_HOST="$(ask 'External DB host/IP' '127.0.0.1'; echo "$REPLY")" - DB_PORT="$(ask_port 'External DB port' "$DB_PORT")" - DB_NAME="$(ask_dbname 'External DB database name' "$DB_NAME")" - DB_USER="$(ask_user 'External DB username' "$DB_USER")" - DB_PASS="$(ask_password 'External DB password' "$DB_PASS")" - fi -} - -step_app_db_binding(){ - $EN_APP || return 0 - if ask_yes_no "Should trustpoint reuse the PostgreSQL settings configured above?" "y"; then - APP_DB_NAME="$DB_NAME" - APP_DB_USER="$DB_USER" - APP_DB_PASS="$DB_PASS" - if $DB_INTERNAL; then - # Internal DB: always connect to the container directly - APP_DB_HOST="$DEF_DB_HOST_INTERNAL" - APP_DB_PORT=5432 - else - # External DB: use exactly what you entered - APP_DB_HOST="$DB_HOST" - APP_DB_PORT="$DB_PORT" - fi - else - local def_host def_port - if $DB_INTERNAL; then - def_host="$DEF_DB_HOST_INTERNAL"; def_port=5432 - else - def_host="$DB_HOST"; def_port="$DB_PORT" - fi - APP_DB_HOST="$(ask 'trustpoint DB host' "$def_host"; echo "$REPLY")" - APP_DB_PORT="$(ask_port 'trustpoint DB port' "$def_port")" - APP_DB_NAME="$(ask_dbname 'trustpoint DB name' "$DB_NAME")" - APP_DB_USER="$(ask_user 'trustpoint DB user' "$DB_USER")" - APP_DB_PASS="$(ask_password 'trustpoint DB password' "$DB_PASS")" - fi -} - -step_helpers(){ - EN_MAILPIT=$(ask_yes_no "Enable Mailpit (demo SMTP inbox)?" "n" && echo true || echo false) - if $EN_MAILPIT; then - MAILPIT_SMTP_PORT="$(ask_free_port 'Mailpit SMTP host port' "$MAILPIT_SMTP_PORT")" - MAILPIT_UI_PORT="$(ask_free_port 'Mailpit UI host port' "$MAILPIT_UI_PORT")" - fi - - EN_SFTPGO=$(ask_yes_no "Enable SFTPGo (demo SFTP + Web UI)?" "n" && echo true || echo false) - if $EN_SFTPGO; then - SFTPGO_SFTP_PORT="$(ask_free_port 'SFTPGo SFTP host port' "$SFTPGO_SFTP_PORT")" - SFTPGO_WEB_PORT="$(ask_free_port 'SFTPGo Web UI host port' "$SFTPGO_WEB_PORT")" - SFTPGO_ADMIN_USER="$(ask_user 'SFTPGo admin user' "$SFTPGO_ADMIN_USER")" - SFTPGO_ADMIN_PASS="$(ask_password 'SFTPGo admin password' "$SFTPGO_ADMIN_PASS")" - - # Mandatory backup user - ask_user "SFTPGo backup username" "$SFTPGO_BACKUP_USER"; SFTPGO_BACKUP_USER="$REPLY" - ask_password "SFTPGo backup password" "$SFTPGO_BACKUP_PASS"; SFTPGO_BACKUP_PASS="$REPLY" - SFTPGO_BACKUP_HOME="/srv/sftpgo/data/${SFTPGO_BACKUP_USER}" - ask "SFTPGo backup home (inside container)" "$SFTPGO_BACKUP_HOME"; SFTPGO_BACKUP_HOME="$REPLY" - fi -} - -step_workflows2_worker(){ - $EN_APP || return 0 - EN_WF2_WORKER=$( - ask_yes_no "Delegate workflows2 tasks to a dedicated worker container?" "n" && echo true || echo false - ) -} - -show_plan(){ - echo - echo "==================== Configuration Summary (Planned) ====================" - printf "%-22s %s\n" "Network:" "$NET" - printf "%-22s %s\n" "DB Volume:" "$VOL_DB" - echo - printf "%-22s %s\n" "trustpoint enabled:" "$EN_APP" - if $EN_APP; then - if $BUILD_LOCAL; then - printf "%-22s %s\n" "App image:" "Build local → trustpoint:local" - else - printf "%-22s %s\n" "App image:" "Pull → ${APP_IMAGE}" - fi - printf "%-22s %s\n" "Host ports:" "80→80 (HTTP), 443→443 (HTTPS)" - fi - echo - printf "%-22s %s\n" "Internal Postgres:" "$DB_INTERNAL" - printf "%-22s %s\n" "DB host:" "$DB_HOST" - printf "%-22s %s\n" "DB host port:" "$DB_PORT" - printf "%-22s %s\n" "DB name:" "$DB_NAME" - printf "%-22s %s\n" "DB user:" "$DB_USER" - printf "%-22s %s\n" "DB pass:" "$(mask "$DB_PASS")" - echo - if $EN_APP; then - printf "%-22s %s\n" "trustpoint DB host:" "$APP_DB_HOST" - printf "%-22s %s\n" "trustpoint DB port:" "$APP_DB_PORT" - printf "%-22s %s\n" "trustpoint DB name:" "$APP_DB_NAME" - printf "%-22s %s\n" "trustpoint DB user:" "$APP_DB_USER" - printf "%-22s %s\n" "trustpoint DB pass:" "$(mask "$APP_DB_PASS")" - fi - echo - printf "%-22s %s\n" "Mailpit enabled:" "$EN_MAILPIT" - $EN_MAILPIT && printf "%-22s %s\n" "Mailpit ports:" "SMTP ${MAILPIT_SMTP_PORT}, UI ${MAILPIT_UI_PORT}" - echo - printf "%-22s %s\n" "workflows2 worker:" "$EN_WF2_WORKER" - $EN_WF2_WORKER && { - printf "%-22s %s\n" "Worker container:" "${WF2_WORKER_NAME}" - printf "%-22s %s\n" "workflow2 folder:" "${WF2_FOLDER}" - } - echo - printf "%-22s %s\n" "SFTPGo enabled:" "$EN_SFTPGO" - $EN_SFTPGO && { - printf "%-22s %s\n" "SFTPGo ports:" "SFTP ${SFTPGO_SFTP_PORT}, Web ${SFTPGO_WEB_PORT}" - printf "%-22s %s\n" "SFTPGo admin:" "${SFTPGO_ADMIN_USER} / $(mask "$SFTPGO_ADMIN_PASS")" - printf "%-22s %s\n" "SFTP backup user:" "${SFTPGO_BACKUP_USER} / $(mask "$SFTPGO_BACKUP_PASS")" - printf "%-22s %s\n" "Backup home:" "${SFTPGO_BACKUP_HOME}" - } - echo "=========================================================================" -} - -# -------------------------- Build/Pull & Start ------------------------------- -build_trustpoint_image(){ [[ -f "$TP_DOCKERFILE" ]] || log "Dockerfile not found: $TP_DOCKERFILE"; log "Building trustpoint image..."; docker build -f "$TP_DOCKERFILE" -t "trustpoint:local" .; } -pull_trustpoint_image(){ log "Pulling ${APP_IMAGE} ..."; docker pull "${APP_IMAGE}" >/dev/null; } -configure_app_image_prompt(){ - if ask_yes_no "Build trustpoint locally from ${TP_DOCKERFILE}? (No = pull from Docker Hub)" "y"; then - BUILD_LOCAL=true - APP_IMAGE="trustpoint:local" - else - BUILD_LOCAL=false - ask "Docker Hub image tag to pull (repository ${TP_REPO})" "latest" - local tag="$REPLY" - APP_IMAGE="${TP_REPO}:${tag}" - fi -} -resolve_app_image(){ - if ! $EN_APP && ! $EN_WF2_WORKER; then - return 0 - fi - if $BUILD_LOCAL; then - build_trustpoint_image - else - pull_trustpoint_image - fi -} -configure_selected(){ - if $ONLY_DB; then - EN_PG=true - DB_INTERNAL=true - fi - $ONLY_MAIL && EN_MAILPIT=true - $ONLY_SFTP && EN_SFTPGO=true - - if $ONLY_APP; then - EN_APP=true - configure_app_image_prompt - EN_WF2_WORKER=$( - ask_yes_no "Delegate workflows2 tasks to a dedicated worker container?" "n" && echo true || echo false - ) - elif $ONLY_WF2_WORKER; then - EN_WF2_WORKER=true - configure_app_image_prompt - fi - - if $ONLY_APP || $ONLY_WF2_WORKER; then - if $DB_INTERNAL; then - APP_DB_HOST="$DEF_DB_HOST_INTERNAL" - APP_DB_PORT=5432 - else - APP_DB_HOST="$DB_HOST" - APP_DB_PORT="$DB_PORT" - fi - APP_DB_NAME="$DB_NAME" - APP_DB_USER="$DB_USER" - APP_DB_PASS="$DB_PASS" - fi -} - -start_postgres(){ - $DB_INTERNAL || return 0 - ensure_volumes - local name="postgres" - stop_one "$name" - # safety: host port must still be free (non-interactive runs) - if port_in_use "$DB_PORT"; then die "Host port ${DB_PORT} is already in use. Choose another port or stop the process using it."; fi - log "Starting PostgreSQL..." - docker run -d --name "$name" --network "$NET" \ - -p "${DB_PORT}:5432" \ - -v "${VOL_DB}:/var/lib/postgresql/data" \ - -e "POSTGRES_DB=$DB_NAME" \ - -e "POSTGRES_USER=$DB_USER" \ - -e "POSTGRES_PASSWORD=$DB_PASS" \ - "$PG_IMAGE" >/dev/null -} - -start_mailpit(){ - $EN_MAILPIT || return 0 - local name="mailpit" - stop_one "$name" - if port_in_use "$MAILPIT_SMTP_PORT"; then die "Host port ${MAILPIT_SMTP_PORT} in use (Mailpit SMTP)."; fi - if port_in_use "$MAILPIT_UI_PORT"; then die "Host port ${MAILPIT_UI_PORT} in use (Mailpit UI)."; fi - log "Starting Mailpit..." - docker run -d --name "$name" --network "$NET" \ - -p "${MAILPIT_SMTP_PORT}:1025" \ - -p "${MAILPIT_UI_PORT}:8025" \ - "$MAILPIT_IMAGE" >/dev/null -} - -start_sftpgo(){ - $EN_SFTPGO || return 0 - local name="sftpgo" - stop_one "$name" - - if port_in_use "$SFTPGO_SFTP_PORT"; then die "Host port ${SFTPGO_SFTP_PORT} in use (SFTPGo SFTP)."; fi - if port_in_use "$SFTPGO_WEB_PORT"; then die "Host port ${SFTPGO_WEB_PORT} in use (SFTPGo Web)."; fi - - mkdir -p "${SFTPGO_ROOT}/data" - if [[ -n "$SFTPGO_BACKUP_USER" ]]; then - mkdir -p "${SFTPGO_ROOT}/data/${SFTPGO_BACKUP_USER}" - fi - chown -R 1000:1000 "${SFTPGO_ROOT}" 2>/dev/null || true - - log "Starting SFTPGo with auto-created admin..." - docker run -d --name "$name" --network "$NET" \ - -p "${SFTPGO_SFTP_PORT}:2022" \ - -p "${SFTPGO_WEB_PORT}:8080" \ - -v "${SFTPGO_ROOT}:/srv/sftpgo" \ - -e SFTPGO_DATA_PROVIDER__CREATE_DEFAULT_ADMIN=true \ - -e SFTPGO_DEFAULT_ADMIN_USERNAME="$SFTPGO_ADMIN_USER" \ - -e SFTPGO_DEFAULT_ADMIN_PASSWORD="$SFTPGO_ADMIN_PASS" \ - -e SFTPGO_HTTPD__BINDINGS__0__ADDRESS="0.0.0.0" \ - -e SFTPGO_HTTPD__BINDINGS__0__ENABLE_REST_API=true \ - -e SFTPGO_HTTPD__BINDINGS__0__ENABLE_WEB_ADMIN=true \ - -e SFTPGO_HTTPD__BINDINGS__0__PORT=8080 \ - -e SFTPGO_SFTPD__BINDINGS__0__PORT=2022 \ - "$SFTPGO_IMAGE" >/dev/null -} - -prepare_workflows2_worker_folder(){ - $EN_WF2_WORKER || return 0 - mkdir -p "$WF2_FOLDER" - chmod 700 "$WF2_FOLDER" 2>/dev/null || true - cat > "$WF2_WORKER_README" </dev/null || true - - cat > "$WF2_WORKER_ENV_FILE" <> "$WF2_WORKER_ENV_FILE" </dev/null || true -} - -start_app(){ - $EN_APP || return 0 - local name="trustpoint" - stop_one "$name" - # die early if 80/443 are busy - if port_in_use "$APP_HTTP_HOST"; then die "Host port ${APP_HTTP_HOST} is in use (trustpoint HTTP)."; fi - if port_in_use "$APP_HTTPS_HOST"; then die "Host port ${APP_HTTPS_HOST} is in use (trustpoint HTTPS)."; fi - - log "Starting trustpoint..." - local smtp_env=() - if $EN_MAILPIT; then - smtp_env+=( -e "EMAIL_HOST=mailpit" -e "EMAIL_PORT=1025" -e "EMAIL_USE_TLS=0" -e "EMAIL_USE_SSL=0" -e "DEFAULT_FROM_EMAIL=no-reply@trustpoint.local" ) - fi - docker run -d --name "$name" --network "$NET" \ - -p "${APP_HTTP_HOST}:80" \ - -p "${APP_HTTPS_HOST}:443" \ - -e "POSTGRES_DB=$APP_DB_NAME" \ - -e "DATABASE_USER=$APP_DB_USER" \ - -e "DATABASE_PASSWORD=$APP_DB_PASS" \ - -e "DATABASE_HOST=$APP_DB_HOST" \ - -e "DATABASE_PORT=$APP_DB_PORT" \ - "${smtp_env[@]}" \ - "$APP_IMAGE" >/dev/null -} - -start_workflows2_worker(){ - $EN_WF2_WORKER || return 0 - local name="$WF2_WORKER_NAME" - stop_one "$name" - prepare_workflows2_worker_folder - log "Starting dedicated workflows2 worker..." - docker run -d --name "$name" --network "$NET" \ - --env-file "$WF2_WORKER_ENV_FILE" \ - "$APP_IMAGE" >/dev/null -} - -# -------------------------- Readiness & Provision ----------------------------- -await_sftpgo_ready(){ - $EN_SFTPGO || return 0 - local PORT; PORT="$(sftpgo_web_port)" - echo "Waiting (<= ${READINESS_TIMEOUT}s) for SFTPGo API on localhost:${PORT} ..." - local until=$(( $(date +%s) + READINESS_TIMEOUT )) - while (( $(date +%s) < until )); do - if have curl && [[ "$(curl -fsS "http://127.0.0.1:${PORT}/healthz" 2>/dev/null || true)" == "ok" ]]; then - ok "SFTPGo API healthy on :${PORT}" - return 0 - fi - if tcp_check 127.0.0.1 "$PORT" 1; then - ok "SFTPGo API port open on :${PORT}" - return 0 - fi - printf "."; sleep 1 - done - echo - warn "SFTPGo API not confirmed after ${READINESS_TIMEOUT}s" -} - -await_readiness(){ - local deadline=$(( $(date +%s) + READINESS_TIMEOUT )) - if $DB_INTERNAL; then - echo "Waiting (<= ${READINESS_TIMEOUT}s) for PostgreSQL on localhost:${DB_PORT} ..." - while (( $(date +%s) < deadline )); do - if tcp_check 127.0.0.1 "$DB_PORT" 1; then ok "PostgreSQL ready on :$DB_PORT"; break; fi - printf "."; sleep 1 - done - echo - fi - if $EN_APP; then - echo "Waiting (<= ${READINESS_TIMEOUT}s) for trustpoint HTTP on localhost:${APP_HTTP_HOST} ..." - while (( $(date +%s) < deadline )); do - if tcp_check 127.0.0.1 "$APP_HTTP_HOST" 1; then ok "trustpoint reachable on :$APP_HTTP_HOST"; break; fi - printf "."; sleep 1 - done - echo - fi - await_sftpgo_ready -} - -# ---- SFTPGo provisioning via REST ------------------------------------------- -upsert_virtual_folder(){ - local vf_name="$1" mapped="$2" - read -r -d '' VF_PAYLOAD <&2 - return 1 - fi - ok "Virtual folder '${vf_name}' → '${mapped}' ready." -} - -provision_sftpgo_backup_user(){ - $EN_SFTPGO || return 0 - have curl || { warn "curl not found on host; skipping SFTPGo user provisioning."; return 0; } - - [[ -z "${SFTPGO_BACKUP_HOME:-}" ]] && SFTPGO_BACKUP_HOME="/srv/sftpgo/data/${SFTPGO_BACKUP_USER}" - - local host_home="${SFTPGO_ROOT}${SFTPGO_BACKUP_HOME#/srv/sftpgo}" - mkdir -p "$host_home" - chown -R 1000:1000 "$host_home" 2>/dev/null || true - - local PORT; PORT="$(sftpgo_web_port)" - API="http://127.0.0.1:${PORT}" - - for _ in {1..30}; do - [[ "$(curl -fsS "${API}/healthz" 2>/dev/null || true)" == "ok" ]] && break - sleep 1 - done - - local token="" - for _ in 1 2 3; do - token="$(curl -fsS -u "${SFTPGO_ADMIN_USER}:${SFTPGO_ADMIN_PASS}" "${API}/api/v2/token" \ - | sed -nE 's/.*"access_token":"([^"]+)".*/\1/p')" || true - [[ -n "$token" ]] && break - sleep 1 - done - [[ -n "$token" ]] || { warn "Could not obtain SFTPGo admin token; skipping user provisioning."; return 0; } - - HDR=(-H "Authorization: Bearer ${token}" -H "Content-Type: application/json") - upsert_virtual_folder "trustpoint" "${SFTPGO_BACKUP_HOME}" || return 0 - - local code method url http - code="$(curl -s -o /dev/null -w '%{http_code}' "${HDR[@]}" "${API}/api/v2/users/${SFTPGO_BACKUP_USER}")" - if [[ "$code" == "200" ]]; then - method=PUT; url="${API}/api/v2/users/${SFTPGO_BACKUP_USER}" - else - method=POST; url="${API}/api/v2/users" - fi - - read -r -d '' payload <&2 - else - ok "SFTPGo user '${SFTPGO_BACKUP_USER}' provisioned; VF mounted at /upload." - fi -} - -# ---- TLS fingerprint wait ---------------------------------------------------- -extract_tls_fingerprint_once(){ - local logs="$1" - if [[ "$logs" =~ ([0-9A-Fa-f]{2}:){31}[0-9A-Fa-f]{2} ]]; then TLS_FP_FOUND="${BASH_REMATCH[0]}"; return 0; fi - if [[ "$logs" =~ ([0-9A-Fa-f]{64}) ]]; then TLS_FP_FOUND="${BASH_REMATCH[1]}"; return 0; fi - if [[ "$logs" =~ [Ss][Hh][Aa]-?256[:\ ]([A-Za-z0-9+/=_:-]{43,}) ]]; then TLS_FP_FOUND="SHA256:${BASH_REMATCH[1]}"; return 0; fi - return 1 -} - -wait_tls_fingerprint(){ - $EN_APP || { TLS_FP_ELAPSED=0; return 0; } - local start; start="$(date +%s)" - local start_iso; start_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - local end=$(( start + TLS_FP_TIMEOUT )) - while (( $(date +%s) < end )); do - local chunk - chunk="$(docker logs --since "$start_iso" trustpoint 2>/dev/null || true)" - if extract_tls_fingerprint_once "$chunk"; then - TLS_FP_ELAPSED=$(( $(date +%s) - start )) - return 0 - fi - sleep 3 - done - TLS_FP_ELAPSED=$(( $(date +%s) - start )) - return 1 -} - -mailpit_has_subject(){ - local subject="$1" - have curl || return 2 - local until=$(( $(date +%s) + MAILPIT_PROBE_TIMEOUT )) - local api="http://127.0.0.1:${MAILPIT_UI_PORT}/api/v1/messages" - while (( $(date +%s) < until )); do - if curl -fsS "$api" 2>/dev/null | grep -Fq "$subject"; then - return 0 - fi - sleep 1 - done - return 1 -} - -probe_mailpit_from_container(){ - local container="$1" label="$2" - exists "$container" || return 0 - - local subject="trustpoint wizard ${label} mailpit probe $(date +%s)" - log "Sending Mailpit probe email from ${label} container..." - - if ! docker exec -e "PROBE_SUBJECT=${subject}" "$container" bash -lc \ - 'cd /var/www/html/trustpoint && uv run trustpoint/manage.py shell -c '\''import os; from django.conf import settings; from django.core.mail import send_mail; subject=os.environ["PROBE_SUBJECT"]; send_mail(subject, "Trustpoint Mailpit probe.", getattr(settings, "DEFAULT_FROM_EMAIL", None), ["demo@trustpoint.local"], fail_silently=False)'\''' \ - >/dev/null 2>&1; then - warn "Mailpit probe failed from ${label} container." - return 1 - fi - - if ! have curl; then - warn "curl not found on host; Mailpit probe from ${label} was sent but API verification was skipped." - return 0 - fi - - if mailpit_has_subject "$subject"; then - ok "Mailpit received the ${label} probe email." - return 0 - fi - - warn "Mailpit SMTP accepted the ${label} probe email, but it did not appear in the Mailpit UI within ${MAILPIT_PROBE_TIMEOUT}s." - return 1 -} - -verify_mailpit_delivery(){ - $EN_MAILPIT || return 0 - $EN_APP && probe_mailpit_from_container trustpoint "web" || true - $EN_WF2_WORKER && probe_mailpit_from_container "$WF2_WORKER_NAME" "workflows2-worker" || true -} - -show_runtime_status(){ - local net_state="absent" vol_state="absent" - docker network inspect "$NET" >/dev/null 2>&1 && net_state="present" - docker volume inspect "$VOL_DB" >/dev/null 2>&1 && vol_state="present" - - echo - echo "=========================== Runtime Status (Live) ========================" - printf "%-22s %s\n" "Network:" "${NET} (${net_state})" - printf "%-22s %s\n" "DB volume:" "${VOL_DB} (${vol_state})" - printf "%-22s %s\n" "workflow2 folder:" "$([ -d "$WF2_FOLDER" ] && echo "${WF2_FOLDER} (present)" || echo "${WF2_FOLDER} (absent)")" - echo - printf "%-20s %-10s %-10s %s\n" "Container" "State" "Health" "Image" - print_container_status_row trustpoint - print_container_status_row postgres - print_container_status_row mailpit - print_container_status_row sftpgo - print_container_status_row "$WF2_WORKER_NAME" - echo - - if exists trustpoint; then - local http_port https_port db_host db_port db_name db_user db_pass - http_port="$(container_host_port trustpoint 80/tcp)" - https_port="$(container_host_port trustpoint 443/tcp)" - db_host="$(container_env trustpoint DATABASE_HOST)" - db_port="$(container_env trustpoint DATABASE_PORT)" - db_name="$(container_env trustpoint POSTGRES_DB)" - db_user="$(container_env trustpoint DATABASE_USER)" - db_pass="$(container_env trustpoint DATABASE_PASSWORD)" - - [[ -n "$http_port" ]] && printf "%-22s %s\n" "trustpoint HTTP:" "http://localhost:${http_port}" - [[ -n "$https_port" ]] && printf "%-22s %s\n" "trustpoint HTTPS:" "https://localhost:${https_port}" - printf "%-22s %s\n" "workflows2 mode:" "managed in Trustpoint settings" - if [[ -n "$db_host" || -n "$db_port" || -n "$db_name" || -n "$db_user" ]]; then - printf "%-22s %s\n" "DB connect:" "host=${db_host:-?} port=${db_port:-?} db=${db_name:-?} user=${db_user:-?} pass=$(mask "${db_pass:-}")" - fi - fi - - if exists postgres; then - local pg_port - pg_port="$(container_host_port postgres 5432/tcp)" - [[ -n "$pg_port" ]] && printf "%-22s %s\n" "PostgreSQL:" "tcp://localhost:${pg_port}" - fi - - if exists mailpit; then - local mailpit_ui mailpit_smtp - mailpit_ui="$(container_host_port mailpit 8025/tcp)" - mailpit_smtp="$(container_host_port mailpit 1025/tcp)" - [[ -n "$mailpit_ui" ]] && printf "%-22s %s\n" "Mailpit UI:" "http://localhost:${mailpit_ui}" - [[ -n "$mailpit_smtp" ]] && printf "%-22s %s\n" "Mailpit SMTP:" "localhost:${mailpit_smtp}" - fi - - if exists "$WF2_WORKER_NAME"; then - local worker_db worker_lease worker_batch worker_sleep - worker_db="$(container_env "$WF2_WORKER_NAME" DATABASE_HOST)" - worker_lease="$(container_env "$WF2_WORKER_NAME" WORKFLOWS2_WORKER_LEASE)" - worker_batch="$(container_env "$WF2_WORKER_NAME" WORKFLOWS2_WORKER_BATCH)" - worker_sleep="$(container_env "$WF2_WORKER_NAME" WORKFLOWS2_WORKER_SLEEP)" - printf "%-22s %s\n" "workflows2 worker:" "${WF2_WORKER_NAME}" - [[ -n "$worker_db" ]] && printf "%-22s %s\n" "worker DB host:" "${worker_db}" - [[ -n "$worker_lease" || -n "$worker_batch" || -n "$worker_sleep" ]] && \ - printf "%-22s %s\n" "worker tuning:" "lease=${worker_lease:-?} batch=${worker_batch:-?} sleep=${worker_sleep:-?}" - fi - - if exists sftpgo; then - local sftpgo_web sftpgo_sftp sftpgo_admin - sftpgo_web="$(container_host_port sftpgo 8080/tcp)" - sftpgo_sftp="$(container_host_port sftpgo 2022/tcp)" - sftpgo_admin="$(container_env sftpgo SFTPGO_DEFAULT_ADMIN_USERNAME)" - [[ -n "$sftpgo_web" ]] && printf "%-22s %s\n" "SFTPGo Web:" "http://localhost:${sftpgo_web}/web/admin" - [[ -n "$sftpgo_sftp" ]] && printf "%-22s %s\n" "SFTPGo SFTP:" "sftp://localhost:${sftpgo_sftp}" - [[ -n "$sftpgo_admin" ]] && printf "%-22s %s\n" "SFTPGo admin:" "${sftpgo_admin}" - printf "%-22s %s\n" "SFTPGo data dir:" "${SFTPGO_ROOT}" - fi - - echo "=========================================================================" -} - -# -------------------------- Summary ------------------------------------------ -final_summary(){ - echo - echo "========================= Runtime Summary (Actual) =======================" - printf "%-22s %s\n" "Network:" "$NET" - printf "%-22s %s\n" "Containers:" "$(docker ps --format '{{.Names}}' | grep -E '^(trustpoint|postgres|mailpit|sftpgo|trustpoint-worker)$' || true)" - echo - if $EN_APP; then - printf "%-22s %s\n" "trustpoint:" "http://localhost:80 | https://localhost:443" - printf "%-22s %s\n" "workflows2 mode:" "managed in Trustpoint settings (default: auto)" - fi - if $DB_INTERNAL; then - printf "%-22s %s\n" "PostgreSQL:" "tcp://localhost:${DB_PORT} (container port 5432)" - fi - if $EN_APP; then - printf "%-22s %s\n" "DB connect:" "host=${APP_DB_HOST} port=${APP_DB_PORT} db=${APP_DB_NAME} user=${APP_DB_USER} pass=$(mask "$APP_DB_PASS")" - fi - $EN_MAILPIT && printf "%-22s %s\n" "Mailpit UI:" "http://localhost:${MAILPIT_UI_PORT} (SMTP :${MAILPIT_SMTP_PORT})" - if $EN_WF2_WORKER; then - printf "%-22s %s\n" "workflows2 worker:" "${WF2_WORKER_NAME}" - printf "%-22s %s\n" "workflow2 folder:" "${WF2_FOLDER}" - fi - if $EN_SFTPGO; then - local PORT; PORT="$(sftpgo_web_port)" - printf "%-22s %s\n" "SFTPGo Web:" "http://localhost:${PORT}/web/admin" - printf "%-22s %s\n" "SFTPGo SFTP:" "sftp://localhost:${SFTPGO_SFTP_PORT}" - printf "%-22s %s\n" "SFTPGo admin:" "${SFTPGO_ADMIN_USER} / $(mask "$SFTPGO_ADMIN_PASS")" - printf "%-22s %s\n" "Backup user:" "${SFTPGO_BACKUP_USER} / $(mask "$SFTPGO_BACKUP_PASS")" - printf "%-22s %s\n" "Backup home:" "${SFTPGO_BACKUP_HOME}" - printf "%-22s %s\n" "Backup URL:" "sftp://${SFTPGO_BACKUP_USER}:***@127.0.0.1:${SFTPGO_SFTP_PORT}/" - printf "%-22s %s\n" "Data dir:" "${SFTPGO_ROOT}" - fi - if $EN_APP; then - if [[ -n "$TLS_FP_FOUND" ]]; then - printf "%-22s %s\n" "TLS fingerprint:" "$TLS_FP_FOUND" - else - if $NOWAIT; then - printf "%-22s %s\n" "TLS fingerprint:" "skipped (NOWAIT)" - else - printf "%-22s %s\n" "TLS fingerprint:" "not found yet (polled ${TLS_FP_ELAPSED}s; timeout ${TLS_FP_TIMEOUT}s)" - fi - fi - fi - echo "=========================================================================" -} - -# -------------------------- High-level orchestration ------------------------- -wizard(){ - echo "$(bold)trustpoint Setup Wizard$(rst)" - ensure_network - step_enable_trustpoint - step_trustpoint_source - step_enable_postgres - step_postgres_config - step_app_db_binding - step_helpers - step_workflows2_worker - show_plan - ask_yes_no "Proceed with these settings?" "y" || { warn "Aborted by user."; exit 1; } - resolve_app_image - $DB_INTERNAL && ensure_volumes - start_postgres - start_mailpit - start_sftpgo - $EN_WF2_WORKER || stop_one "$WF2_WORKER_NAME" - start_app - start_workflows2_worker - $NOWAIT || await_readiness - $NOWAIT || provision_sftpgo_backup_user - $NOWAIT || verify_mailpit_delivery - $NOWAIT || wait_tls_fingerprint || true - final_summary -} - -# -------------------------- Service selection & CLI -------------------------- -usage(){ - cat <<'EOF2' -Commands: - (no command) Run interactive wizard - up [demo|trustpoint|db|mail|sftp|worker] [--nowait] - down [demo|trustpoint|db|mail|sftp|worker] - logs [trustpoint|db|mail|sftp|worker] - status - nuke - help - -Also supported (legacy): --only trustpoint|db|mail|sftp|worker|demo -EOF2 -} - -map_only_to_flags(){ - case "$1" in - demo) ONLY_APP=true; ONLY_DB=true; ONLY_MAIL=true; ONLY_SFTP=true ;; - trustpoint|app) ONLY_APP=true ;; - db) ONLY_DB=true ;; - mail) ONLY_MAIL=true ;; - sftp) ONLY_SFTP=true ;; - worker) ONLY_WF2_WORKER=true ;; - *) die "Unknown target: $1 (use trustpoint|db|mail|sftp|worker|demo)";; - esac -} - -set_targets_from_args(){ - local any=false - while [[ $# -gt 0 ]]; do - case "$1" in - demo|trustpoint|app|db|mail|sftp|worker) map_only_to_flags "$1"; any=true; shift ;; - --only) map_only_to_flags "${2:-}"; any=true; shift 2 ;; - --nowait) NOWAIT=true; shift ;; - *) die "Unknown option/target: $1" ;; - esac - done - if ! $any; then ONLY_APP=true; ONLY_DB=true; fi -} - -start_selected(){ - configure_selected - ensure_network - resolve_app_image - $ONLY_DB && { EN_PG=true; ensure_volumes; start_postgres; } - $ONLY_MAIL && { EN_MAILPIT=true; start_mailpit; } - $ONLY_SFTP && { EN_SFTPGO=true; start_sftpgo; } - $EN_WF2_WORKER || { $ONLY_APP && stop_one "$WF2_WORKER_NAME"; } - $ONLY_APP && start_app - $EN_WF2_WORKER && start_workflows2_worker - - $NOWAIT || await_readiness - $NOWAIT || provision_sftpgo_backup_user - $NOWAIT || verify_mailpit_delivery - $NOWAIT || wait_tls_fingerprint || true - final_summary -} - -down_selected(){ - local done=false - $ONLY_APP && { stop_one trustpoint; stop_one "$WF2_WORKER_NAME"; done=true; } - $ONLY_DB && stop_one postgres && done=true - $ONLY_MAIL && stop_one mailpit && done=true - $ONLY_SFTP && stop_one sftpgo && done=true - $ONLY_WF2_WORKER && stop_one "$WF2_WORKER_NAME" && done=true - $done || { stop_one trustpoint; stop_one postgres; stop_one mailpit; stop_one sftpgo; stop_one "$WF2_WORKER_NAME"; } - ok "Stopped." -} - -logs_selected(){ - local target="trustpoint" - $ONLY_DB && target="postgres" - $ONLY_MAIL && target="mailpit" - $ONLY_SFTP && target="sftpgo" - $ONLY_WF2_WORKER && target="$WF2_WORKER_NAME" - exists "$target" || die "Container not found: $target" - docker logs -f "$target" -} - -nuke_cmd(){ - read -r -p "Remove ALL project containers, network, DB volume, ./sftpgo-data, and ./workflow2Folder? [y/N] " a; [[ "${a}" == "y" ]] || exit 0 - read -r -p "Are you sure? This is destructive. [y/N] " b; [[ "${b}" == "y" ]] || exit 0 - mapfile -t project_volumes < <(collect_project_volumes) - stop_one trustpoint; stop_one postgres; stop_one mailpit; stop_one sftpgo; stop_one "$WF2_WORKER_NAME" - docker network rm "$NET" >/dev/null 2>&1 || true - for v in "${project_volumes[@]}"; do - [[ -n "$v" ]] || continue - docker volume rm "$v" >/dev/null 2>&1 || true - done - if [[ -d "$SFTPGO_ROOT" ]]; then rm -rf "$SFTPGO_ROOT"; fi - if [[ -d "$WF2_FOLDER" ]]; then rm -rf "$WF2_FOLDER"; fi - ok "Project resources removed." -} - -# -------------------------- Arg parsing & dispatch ---------------------------- -cmd="${1:-}" -preflight -case "$cmd" in - "" ) wizard ;; - help) usage ;; - up) - shift || true - set_targets_from_args "$@" - start_selected - ;; - down) - shift || true - set_targets_from_args "$@" - down_selected - ;; - logs) - shift || true - set_targets_from_args "$@" - logs_selected - ;; - status) - shift || true - [[ $# -eq 0 ]] || die "status does not take targets. Use it without arguments." - show_runtime_status - ;; - nuke) nuke_cmd ;; - *) usage; die "Unknown command: $cmd" ;; -esac +tp_main "$@" diff --git a/trustpoint/devices/revocation.py b/trustpoint/devices/revocation.py index 4adc2606e..87cbb88e3 100644 --- a/trustpoint/devices/revocation.py +++ b/trustpoint/devices/revocation.py @@ -13,7 +13,10 @@ class DeviceCredentialRevocation: def revoke_certificate(issued_credential_id: int, reason: str) -> tuple[bool, str]: """Revokes a certificate given an ID of an IssuedCredentialModel instance.""" try: - issued_credential = IssuedCredentialModel.objects.get(id=issued_credential_id) + issued_credential = IssuedCredentialModel.objects.select_related( + 'credential__certificate__revoked_certificate', + 'domain__issuing_ca', + ).get(id=issued_credential_id) except IssuedCredentialModel.DoesNotExist: return False, 'The credential to revoke does not exist.' diff --git a/trustpoint/devices/views/revoke.py b/trustpoint/devices/views/revoke.py index 0effd91d6..46994e931 100644 --- a/trustpoint/devices/views/revoke.py +++ b/trustpoint/devices/views/revoke.py @@ -266,7 +266,7 @@ def post(self, request: HttpRequest, *_args: Any, **_kwargs: Any) -> HttpRespons device__in=self.queryset, credential__certificate__revoked_certificate__isnull=True, credential__certificate__not_valid_after__gte=now, - ) + ).select_related('device', 'domain') n_revoked = 0 for credential in issued_credentials_to_revoke_qs: diff --git a/trustpoint/help_pages/devices_help_views.py b/trustpoint/help_pages/devices_help_views.py index d24a8d646..57f396e1c 100644 --- a/trustpoint/help_pages/devices_help_views.py +++ b/trustpoint/help_pages/devices_help_views.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, override from cryptography import x509 +from django.conf import settings from django.contrib import messages from django.core.management import call_command from django.http import FileResponse, Http404, HttpResponseRedirect @@ -81,7 +82,8 @@ def _make_context(self, host_ip: str = '127.0.0.1') -> HelpContext: if not domain: raise Http404(_('No domain is configured for this device.')) - host_base = f'https://{host_ip}:{self.request.META.get("SERVER_PORT", "443")}' + https_port = settings.TP_HTTPS_PORT or '443' + host_base = f'https://{host_ip}:{https_port}' if https_port != '443' else f'https://{host_ip}' cred_count = IssuedCredentialModel.objects.filter(device=device).count() public_key_info = domain.public_key_info @@ -1876,7 +1878,8 @@ def _make_context(self, host_ip: str = '127.0.0.1') -> HelpContext: except DomainModel.DoesNotExist as exc: raise Http404(_('No domains configured in the system.')) from exc - host_base = f'https://{host_ip}:{self.request.META.get("SERVER_PORT", "443")}' + https_port = settings.TP_HTTPS_PORT or '443' + host_base = f'https://{host_ip}:{https_port}' if https_port != '443' else f'https://{host_ip}' public_key_info = domain.public_key_info if not public_key_info: @@ -1960,7 +1963,8 @@ def _make_context(self, host_ip: str = '127.0.0.1') -> HelpContext: except DomainModel.DoesNotExist as exc: raise Http404(_('No domains configured in the system.')) from exc - host_base = f'https://{host_ip}:{self.request.META.get("SERVER_PORT", "443")}' + https_port = settings.TP_HTTPS_PORT or '443' + host_base = f'https://{host_ip}:{https_port}' if https_port != '443' else f'https://{host_ip}' public_key_info = domain.public_key_info if not public_key_info: diff --git a/trustpoint/help_pages/pki_help_views.py b/trustpoint/help_pages/pki_help_views.py index 1a63cab51..9658fb101 100644 --- a/trustpoint/help_pages/pki_help_views.py +++ b/trustpoint/help_pages/pki_help_views.py @@ -4,6 +4,7 @@ from typing import Any, override +from django.conf import settings from django.http import Http404 from django.urls import reverse from django.utils.html import format_html @@ -55,7 +56,9 @@ def _make_context(self) -> HelpContext: if not domain: raise Http404(_('Failed to get domain from DevidRegistration.')) - host_base = f'https://{TlsSettings.get_first_ipv4_address()}:{self.request.META.get("SERVER_PORT", "443")}' + https_port = settings.TP_HTTPS_PORT or '443' + first_ip = TlsSettings.get_first_ipv4_address() + host_base = f'https://{first_ip}:{https_port}' if https_port != '443' else f'https://{first_ip}' public_key_info = domain.public_key_info if not public_key_info: @@ -261,7 +264,9 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) ca = self.object - host_base = f'https://{TlsSettings.get_first_ipv4_address()}:{self.request.META.get("SERVER_PORT", "443")}' + https_port = settings.TP_HTTPS_PORT or '443' + first_ip = TlsSettings.get_first_ipv4_address() + host_base = f'https://{first_ip}:{https_port}' if https_port != '443' else f'https://{first_ip}' crl_endpoint = f'{host_base}/crl/{ca.pk}/' has_crl = bool(ca.crl_pem) diff --git a/trustpoint/help_pages/tests/test_devices_help_views.py b/trustpoint/help_pages/tests/test_devices_help_views.py index 70d0eabad..1809297a3 100644 --- a/trustpoint/help_pages/tests/test_devices_help_views.py +++ b/trustpoint/help_pages/tests/test_devices_help_views.py @@ -54,10 +54,11 @@ def test_make_context_success( self.view.object = mock_device request = self.factory.get('/') - request.META['SERVER_PORT'] = '8443' self.view.request = request - context = self.view._make_context('192.168.1.1') + with patch('help_pages.devices_help_views.settings') as mock_settings: + mock_settings.TP_HTTPS_PORT = '8443' + context = self.view._make_context('192.168.1.1') assert context.domain == mock_domain assert context.domain_unique_name == 'test-domain' @@ -135,10 +136,11 @@ def test_get_context_data_success( self.view.page_category = 'devices' self.view.page_name = 'devices' request = self.factory.get('/') - request.META['SERVER_PORT'] = '443' self.view.request = request - context = self.view.get_context_data() + with patch('help_pages.devices_help_views.settings') as mock_settings: + mock_settings.TP_HTTPS_PORT = '443' + context = self.view.get_context_data() assert 'help_page' in context assert context['help_page'].heading == 'Test Heading' diff --git a/trustpoint/help_pages/tests/test_pki_help_views.py b/trustpoint/help_pages/tests/test_pki_help_views.py index 83256abfe..c6c89ab75 100644 --- a/trustpoint/help_pages/tests/test_pki_help_views.py +++ b/trustpoint/help_pages/tests/test_pki_help_views.py @@ -39,10 +39,11 @@ def test_make_context_success(self, mock_get_ip: Mock) -> None: self.view.object = mock_registration request = self.factory.get('/') - request.META['SERVER_PORT'] = '8443' self.view.request = request - context = self.view._make_context() + with patch('help_pages.pki_help_views.settings') as mock_settings: + mock_settings.TP_HTTPS_PORT = '8443' + context = self.view._make_context() assert context.domain == mock_domain assert context.domain_unique_name == 'test-domain' @@ -99,10 +100,11 @@ def test_get_context_data_success(self, mock_get_ip: Mock) -> None: self.view.page_category = 'pki' self.view.page_name = 'domains' request = self.factory.get('/') - request.META['SERVER_PORT'] = '443' self.view.request = request - context = self.view.get_context_data() + with patch('help_pages.pki_help_views.settings') as mock_settings: + mock_settings.TP_HTTPS_PORT = '443' + context = self.view.get_context_data() assert 'help_page' in context assert context['help_page'].heading == 'Test Heading' @@ -246,10 +248,11 @@ def test_get_context_data_with_crl( self.view.page_category = 'pki' self.view.page_name = 'issuing_cas' request = self.factory.get('/') - request.META['SERVER_PORT'] = '443' self.view.request = request - context = self.view.get_context_data() + with patch('help_pages.pki_help_views.settings') as mock_settings: + mock_settings.TP_HTTPS_PORT = '443' + context = self.view.get_context_data() assert 'help_page' in context help_page = context['help_page'] @@ -283,10 +286,11 @@ def test_get_context_data_without_crl( self.view.page_category = 'pki' self.view.page_name = 'issuing_cas' request = self.factory.get('/') - request.META['SERVER_PORT'] = '443' self.view.request = request - context = self.view.get_context_data() + with patch('help_pages.pki_help_views.settings') as mock_settings: + mock_settings.TP_HTTPS_PORT = '443' + context = self.view.get_context_data() help_page = context['help_page'] status_section = help_page.sections[1] @@ -314,10 +318,11 @@ def test_get_context_data_curl_commands( self.view.page_category = 'pki' self.view.page_name = 'issuing_cas' request = self.factory.get('/') - request.META['SERVER_PORT'] = '443' self.view.request = request - context = self.view.get_context_data() + with patch('help_pages.pki_help_views.settings') as mock_settings: + mock_settings.TP_HTTPS_PORT = '443' + context = self.view.get_context_data() help_page = context['help_page'] download_section = help_page.sections[3] # Download CRL section diff --git a/trustpoint/management/management/commands/auto_setup_from_env.py b/trustpoint/management/management/commands/auto_setup_from_env.py new file mode 100644 index 000000000..1e923fb81 --- /dev/null +++ b/trustpoint/management/management/commands/auto_setup_from_env.py @@ -0,0 +1,226 @@ +"""Django management command to auto-configure Trustpoint from environment variables.""" + +import ipaddress +import os +from pathlib import Path +from typing import Any + +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError as DjangoValidationError +from django.core.management import call_command +from django.core.management.base import BaseCommand, CommandError +from django.db import DatabaseError, transaction +from django.db.models import ProtectedError + +from management.models import KeyStorageConfig, PrometheusConfig +from management.nginx_paths import NGINX_CERT_CHAIN_PATH, NGINX_CERT_PATH, NGINX_KEY_PATH +from pki.models import CredentialModel +from pki.models.truststore import ActiveTrustpointTlsServerCredentialModel +from setup_wizard.models import SetupWizardCompletedModel +from setup_wizard.tls_credential import TlsServerCredentialGenerator +from setup_wizard.views import execute_shell_script + + +UPDATE_TLS_NGINX = Path('/etc/trustpoint/wizard/update_tls_nginx.sh') + + +class Command(BaseCommand): + """Auto-configure Trustpoint from environment variables.""" + + help = 'Auto-configure Trustpoint from environment variables, bypassing the setup wizard' + + def _env_value(self, name: str, *, required: bool = True, default: str | None = None) -> str | None: + """Get an environment variable value.""" + value = os.getenv(name) + if value is None or value.strip() == '': + if required: + err_msg = f'Required environment variable {name} is not set' + raise CommandError(err_msg) + return default + return value.strip() + + def _env_bool(self, name: str, *, default: bool = False) -> bool: + """Get a boolean environment variable.""" + raw_value = os.getenv(name) + if raw_value is None or raw_value.strip() == '': + return default + return raw_value.strip().lower() in {'1', 'true', 'yes', 'on'} + + def _create_superuser(self, username: str, password: str, email: str) -> None: + """Create the superuser account.""" + self.stdout.write('Creating superuser...') + try: + if User.objects.filter(username=username).exists(): + self.stdout.write(self.style.WARNING(f'User {username} already exists, skipping creation')) + return + + call_command('createsuperuser', interactive=False, username=username, email=email) + user = User.objects.get(username=username) + user.set_password(password) + user.save() + self.stdout.write(self.style.SUCCESS(f'Superuser {username} created successfully')) + except Exception as e: + err_msg = f'Failed to create superuser: {e}' + raise CommandError(err_msg) from e + + def _configure_storage(self) -> None: + """Configure cryptographic storage (currently only SOFTWARE supported).""" + self.stdout.write('Configuring crypto storage...') + try: + key_storage_config = KeyStorageConfig.get_or_create_default() + key_storage_config.storage_type = KeyStorageConfig.StorageType.SOFTWARE + key_storage_config.save(update_fields=['storage_type']) + self.stdout.write(self.style.SUCCESS('Crypto storage configured')) + except Exception as e: + err_msg = f'Failed to configure storage: {e}' + raise CommandError(err_msg) from e + + def _configure_prometheus_metrics(self) -> None: + """Enable the Prometheus metrics endpoint when requested.""" + if not self._env_bool('TP_ENABLE_PROMETHEUS_METRICS', default=False): + return + + config = PrometheusConfig.get() + if config.enabled: + self.stdout.write(self.style.WARNING('Prometheus metrics endpoint already enabled')) + return + + config.enabled = True + config.save(update_fields=['enabled']) + self.stdout.write(self.style.SUCCESS('Prometheus metrics endpoint enabled')) + + def _parse_csv_list(self, value: str | None) -> list[str]: + """Parse comma-separated values into a list.""" + if not value: + return [] + return [item.strip() for item in value.split(',') if item.strip()] + + def _generate_tls_credential( + self, + ipv4_addresses: list[str], + ipv6_addresses: list[str], + dns_names: list[str], + ) -> CredentialModel: + """Generate TLS server credential.""" + self.stdout.write('Generating TLS server credential...') + try: + parsed_ipv4 = [ipaddress.IPv4Address(addr) for addr in ipv4_addresses] + parsed_ipv6 = [ipaddress.IPv6Address(addr) for addr in ipv6_addresses] + + generator = TlsServerCredentialGenerator( + ipv4_addresses=parsed_ipv4, + ipv6_addresses=parsed_ipv6, + domain_names=dns_names, + ) + tls_credential_serializer = generator.generate_tls_server_credential() + + with transaction.atomic(): + credential_model = CredentialModel.save_credential_serializer( + credential_serializer=tls_credential_serializer, + credential_type=CredentialModel.CredentialTypeChoice.TRUSTPOINT_TLS_SERVER, + ) + + self.stdout.write(self.style.SUCCESS('TLS server credential generated')) + return credential_model + + except (ValueError, DjangoValidationError, ProtectedError, TypeError) as e: + err_msg = f'Failed to generate TLS credential: {e}' + raise CommandError(err_msg) from e + + def _apply_tls_credential(self, credential_model: CredentialModel) -> None: + """Apply TLS credential to nginx.""" + self.stdout.write('Applying TLS credential...') + try: + active_tls, _ = ActiveTrustpointTlsServerCredentialModel.objects.get_or_create(id=1) + active_tls.credential = credential_model + active_tls.save() + + self._write_pem_files(credential_model) + execute_shell_script(UPDATE_TLS_NGINX, 'no_hsm') + + self.stdout.write(self.style.SUCCESS('TLS credential applied')) + except Exception as e: + err_msg = f'Failed to apply TLS credential: {e}' + raise CommandError(err_msg) from e + + def _write_pem_files(self, credential_model: CredentialModel) -> None: + """Write TLS certificate and key files to disk.""" + private_key_pem = credential_model.get_private_key_serializer().as_pkcs8_pem().decode() + certificate_pem = credential_model.get_certificate_serializer().as_pem().decode() + trust_store_pem = credential_model.get_certificate_chain_serializer().as_pem().decode() + + NGINX_KEY_PATH.write_text(private_key_pem) + NGINX_CERT_PATH.write_text(certificate_pem) + + if trust_store_pem.strip(): + NGINX_CERT_CHAIN_PATH.write_text(trust_store_pem) + elif NGINX_CERT_CHAIN_PATH.exists(): + NGINX_CERT_CHAIN_PATH.unlink() + + def handle(self, *args: Any, **options: Any) -> None: + """Execute the auto-setup command.""" + del args + del options + + self.stdout.write(self.style.WARNING('=== Trustpoint Auto-Setup from Environment Variables ===')) + + if SetupWizardCompletedModel.setup_wizard_completed(): + self._configure_prometheus_metrics() + self.stdout.write(self.style.WARNING('Setup wizard already completed, skipping auto-setup')) + return + + try: + username = self._env_value('TP_ADMIN_USERNAME', required=True) + password = self._env_value('TP_ADMIN_PASSWORD', required=True) + email = self._env_value('TP_ADMIN_EMAIL', required=False, default='') or '' + + inject_demo_data = self._env_bool('TP_INJECT_DEMO_DATA', default=False) + + tls_ipv4_raw = self._env_value('TP_TLS_IPV4_ADDRESSES', required=False, default='') or '' + tls_ipv6_raw = self._env_value('TP_TLS_IPV6_ADDRESSES', required=False, default='') or '' + tls_dns_raw = self._env_value('TP_TLS_DNS_NAMES', required=False, default='') or '' + + tls_ipv4 = self._parse_csv_list(tls_ipv4_raw) + tls_ipv6 = self._parse_csv_list(tls_ipv6_raw) + tls_dns = self._parse_csv_list(tls_dns_raw) + + if not tls_ipv4 and not tls_ipv6 and not tls_dns: + tls_ipv4 = ['127.0.0.1'] + tls_ipv6 = ['::1'] + tls_dns = ['localhost'] + + with transaction.atomic(): + if not isinstance(username, str) or not isinstance(password, str): + err_msg = 'Username and password must be strings' + raise CommandError(err_msg) + self._create_superuser(username, password, email) + + self._configure_storage() + + self.stdout.write('Creating default certificate profiles...') + call_command('create_default_cert_profiles') + self.stdout.write(self.style.SUCCESS('Certificate profiles created')) + + if inject_demo_data: + self.stdout.write('Injecting demo data...') + call_command('add_domains_and_devices') + self.stdout.write(self.style.SUCCESS('Demo data injected')) + + self.stdout.write('Executing notifications...') + call_command('execute_all_notifications') + self.stdout.write(self.style.SUCCESS('Notifications executed')) + + credential_model = self._generate_tls_credential(tls_ipv4, tls_ipv6, tls_dns) + self._apply_tls_credential(credential_model) + + self._configure_prometheus_metrics() + SetupWizardCompletedModel.mark_setup_complete_once() + self.stdout.write(self.style.SUCCESS('Setup marked as complete')) + + self.stdout.write(self.style.SUCCESS('=== Auto-setup completed successfully ===')) + + except CommandError: + raise + except (DatabaseError, FileNotFoundError, OSError, ProtectedError, RuntimeError, TypeError, ValueError) as e: + err_msg = f'Auto-setup failed: {e}' + raise CommandError(err_msg) from e diff --git a/trustpoint/management/management/commands/startup_manager.py b/trustpoint/management/management/commands/startup_manager.py index 6f8f67116..32e06794e 100644 --- a/trustpoint/management/management/commands/startup_manager.py +++ b/trustpoint/management/management/commands/startup_manager.py @@ -4,8 +4,12 @@ Copy the relevant parts into managestartup.py to complete the refactoring. """ +import os +import sys +from pathlib import Path + from django.conf import settings as django_settings -from django.core.management import CommandError +from django.core.management import CommandError, call_command from django.core.management.base import BaseCommand from django.db.utils import OperationalError, ProgrammingError from packaging.version import InvalidVersion, Version @@ -14,6 +18,7 @@ from management.util.output_wrapper import CommandOutputWrapper from management.util.startup_context import StartupContextBuilder from management.util.startup_strategies import StartupStrategySelector +from setup_wizard.models import SetupWizardCompletedModel class Command(BaseCommand): @@ -23,7 +28,40 @@ class Command(BaseCommand): def handle(self, **_options: dict[str, str]) -> None: """Entrypoint for the command.""" + self._check_env_file_exists() self.manage_startup() + self._check_auto_setup() + + def _check_env_file_exists(self) -> None: + """Check if .env file exists, fail startup if it does not.""" + env_file = Path('/var/www/html/trustpoint/.env') + if not env_file.exists(): + self.stdout.write( + self.style.ERROR( + 'FATAL: No .env file found. Trustpoint requires a .env file to start.\n' + 'Please create /var/www/html/trustpoint/.env with the required configuration.\n' + 'See .env.example for reference.' + ) + ) + sys.exit(1) + + def _check_auto_setup(self) -> None: + """Check if auto-setup should be performed from environment variables.""" + auto_setup = os.getenv('TP_AUTO_SETUP', '').strip().lower() in {'1', 'true', 'yes', 'on'} + + if not auto_setup: + return + + if SetupWizardCompletedModel.setup_wizard_completed(): + self.stdout.write(self.style.WARNING('TP_AUTO_SETUP is enabled but setup already completed, skipping')) + return + + self.stdout.write(self.style.WARNING('TP_AUTO_SETUP is enabled, running auto-setup from environment...')) + try: + call_command('auto_setup_from_env') + except CommandError as e: + self.stdout.write(self.style.ERROR(f'Auto-setup failed: {e}')) + raise def manage_startup(self) -> None: """Checks current state of trustpoint and acts accordingly.""" diff --git a/trustpoint/management/tests/test_commands.py b/trustpoint/management/tests/test_commands.py index 85155f01f..48226ee3a 100644 --- a/trustpoint/management/tests/test_commands.py +++ b/trustpoint/management/tests/test_commands.py @@ -156,14 +156,21 @@ def test_inittrustpoint_creates_app_version( class StartupManagerCommandTest(TestCase): """Test suite for startup_manager command.""" + @patch('management.management.commands.startup_manager.Path') @patch('management.management.commands.startup_manager.StartupStrategySelector') @patch('management.management.commands.startup_manager.StartupContextBuilder') def test_startup_manager_db_not_initialized( self, mock_builder: MagicMock, - mock_selector: MagicMock + mock_selector: MagicMock, + mock_path_class: MagicMock ) -> None: """Test startup_manager when database is not initialized.""" + # Mock .env file existence check + mock_env_path = Mock() + mock_env_path.exists.return_value = True + mock_path_class.return_value = mock_env_path + # Simulate ProgrammingError when querying AppVersion with patch('management.management.commands.startup_manager.AppVersion.objects.first') as mock_first: from django.db.utils import ProgrammingError @@ -186,14 +193,21 @@ def test_startup_manager_db_not_initialized( ) mock_strategy.execute.assert_called_once_with(mock_context) + @patch('management.management.commands.startup_manager.Path') @patch('management.management.commands.startup_manager.StartupStrategySelector') @patch('management.management.commands.startup_manager.StartupContextBuilder') def test_startup_manager_db_initialized_no_version( self, mock_builder: MagicMock, - mock_selector: MagicMock + mock_selector: MagicMock, + mock_path_class: MagicMock ) -> None: """Test startup_manager when database is initialized but no version record.""" + # Mock .env file existence check + mock_env_path = Mock() + mock_env_path.exists.return_value = True + mock_path_class.return_value = mock_env_path + with patch('management.management.commands.startup_manager.AppVersion.objects.first') as mock_first: mock_first.return_value = None diff --git a/trustpoint/pki/migrations/0002_tp_v0_6_0_dev1.py b/trustpoint/pki/migrations/0002_tp_v0_6_0_dev1.py index fd8dfd488..9a6935495 100644 --- a/trustpoint/pki/migrations/0002_tp_v0_6_0_dev1.py +++ b/trustpoint/pki/migrations/0002_tp_v0_6_0_dev1.py @@ -19,4 +19,28 @@ class Migration(migrations.Migration): name='domain_credential_profile', field=models.ForeignKey(blank=True, help_text='Certificate profile used for issuing domain credentials. Defaults to "domain_credential".', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='domains_as_credential_profile', to='pki.certificateprofilemodel', verbose_name='Domain Credential Profile'), ), + migrations.AddIndex( + model_name='certificatemodel', + index=models.Index(fields=['not_valid_after'], name='pki_cert_not_valid_after_idx'), + ), + migrations.AddIndex( + model_name='certificatemodel', + index=models.Index(fields=['not_valid_before'], name='pki_cert_not_valid_before_idx'), + ), + migrations.AddIndex( + model_name='certificatemodel', + index=models.Index(fields=['serial_number'], name='pki_cert_serial_num_idx'), + ), + migrations.AddIndex( + model_name='certificatemodel', + index=models.Index(fields=['subject_public_bytes'], name='pki_cert_subj_pub_bytes_idx'), + ), + migrations.AddIndex( + model_name='certificatemodel', + index=models.Index(fields=['issuer_public_bytes', 'issuer_id'], name='pki_cert_iss_pub_bytes_isid_idx'), + ), + migrations.AddIndex( + model_name='revokedcertificatemodel', + index=models.Index(fields=['ca', 'revoked_at'], name='pki_revoked_ca_revoked_at_idx'), + ), ] diff --git a/trustpoint/pki/models/certificate.py b/trustpoint/pki/models/certificate.py index 660f319fe..34837750e 100644 --- a/trustpoint/pki/models/certificate.py +++ b/trustpoint/pki/models/certificate.py @@ -4,7 +4,7 @@ import datetime from types import MappingProxyType -from typing import Any +from typing import Any, ClassVar from cryptography import x509 from cryptography.exceptions import InvalidSignature @@ -362,6 +362,16 @@ class PublicKeyEcCurveOidChoices(models.TextChoices): class Meta(TypedModelMeta): """Meta class configuration.""" + indexes: ClassVar[list[models.Index]] = [ + models.Index(fields=['not_valid_after'], name='pki_cert_not_valid_after_idx'), + models.Index(fields=['not_valid_before'], name='pki_cert_not_valid_before_idx'), + models.Index(fields=['serial_number'], name='pki_cert_serial_num_idx'), + models.Index(fields=['subject_public_bytes'], + name='pki_cert_subj_pub_bytes_idx'), + models.Index(fields=['issuer_public_bytes', 'issuer_id'], + name='pki_cert_iss_pub_bytes_isid_idx'), + ] + # ------------------------------------------ Magic and default methods ------------------------------------------- def __repr__(self) -> str: @@ -796,6 +806,10 @@ class ReasonCode(models.TextChoices): class Meta(TypedModelMeta): """Meta class configuration.""" + indexes: ClassVar[list[models.Index]] = [ + models.Index(fields=['ca', 'revoked_at'], name='pki_revoked_ca_revoked_at_idx'), + ] + def __str__(self) -> str: """String representation of the RevokedCertificateModel instance.""" return f'RevokedCertificate({self.certificate.common_name})' diff --git a/trustpoint/pki/models/issued_credential.py b/trustpoint/pki/models/issued_credential.py index 747a05e2c..ee45c67e2 100644 --- a/trustpoint/pki/models/issued_credential.py +++ b/trustpoint/pki/models/issued_credential.py @@ -2,6 +2,7 @@ from __future__ import annotations +import datetime from typing import TYPE_CHECKING from cryptography.hazmat.primitives import hashes @@ -86,10 +87,11 @@ def revoke(self) -> None: if domain is None or domain.issuing_ca is None: return ca = domain.issuing_ca + now = datetime.datetime.now(datetime.UTC) cert: CertificateModel - for cert in self.credential.certificates.all(): - status = cert.certificate_status - if status in (CertificateModel.CertificateStatus.REVOKED, CertificateModel.CertificateStatus.EXPIRED): + for cert in self.credential.certificates.select_related('revoked_certificate').all(): + + if hasattr(cert, 'revoked_certificate') or cert.not_valid_after <= now: continue RevokedCertificateModel.objects.create( certificate=cert, revocation_reason=RevokedCertificateModel.ReasonCode.CESSATION, ca=ca diff --git a/trustpoint/pki/tests/test_views_issuing_cas.py b/trustpoint/pki/tests/test_views_issuing_cas.py new file mode 100644 index 000000000..4d504a8d1 --- /dev/null +++ b/trustpoint/pki/tests/test_views_issuing_cas.py @@ -0,0 +1,58 @@ +"""Tests for PKI issuing CA views.""" + +from unittest.mock import Mock, call, patch + +from pki.views.issuing_cas import IssuingCaRequestCertCmpView + + +def _mock_certificate(subject: bytes = b'subject', issuer: bytes = b'issuer') -> Mock: + """Return a certificate-like mock with public subject and issuer bytes.""" + cert = Mock() + cert.subject.public_bytes.return_value = subject + cert.issuer.public_bytes.return_value = issuer + return cert + + +def test_find_existing_ca_for_certificate_prefers_keyless_match() -> None: + """The lookup should return the keyless CA match before checking credential CAs.""" + cert = _mock_certificate() + keyless_ca = Mock() + keyless_queryset = Mock() + keyless_queryset.first.return_value = keyless_ca + + with patch('pki.views.issuing_cas.CaModel.objects.filter', return_value=keyless_queryset) as filter_mock: + result = IssuingCaRequestCertCmpView()._find_existing_ca_for_certificate(cert) + + assert result is keyless_ca + filter_mock.assert_called_once_with( + certificate__subject_public_bytes=b'subject'.hex().upper(), + certificate__issuer_public_bytes=b'issuer'.hex().upper(), + ) + + +def test_find_existing_ca_for_certificate_falls_back_to_credential_match() -> None: + """The lookup should check credential-backed CAs when no keyless CA matches.""" + cert = _mock_certificate() + credential_ca = Mock() + keyless_queryset = Mock() + keyless_queryset.first.return_value = None + credential_queryset = Mock() + credential_queryset.first.return_value = credential_ca + + with patch( + 'pki.views.issuing_cas.CaModel.objects.filter', + side_effect=[keyless_queryset, credential_queryset], + ) as filter_mock: + result = IssuingCaRequestCertCmpView()._find_existing_ca_for_certificate(cert) + + assert result is credential_ca + assert filter_mock.call_args_list == [ + call( + certificate__subject_public_bytes=b'subject'.hex().upper(), + certificate__issuer_public_bytes=b'issuer'.hex().upper(), + ), + call( + credential__certificate__subject_public_bytes=b'subject'.hex().upper(), + credential__certificate__issuer_public_bytes=b'issuer'.hex().upper(), + ), + ] diff --git a/trustpoint/pki/views/certificates.py b/trustpoint/pki/views/certificates.py index 96d1bf11a..2444f4ac5 100644 --- a/trustpoint/pki/views/certificates.py +++ b/trustpoint/pki/views/certificates.py @@ -108,14 +108,18 @@ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def get_base_queryset(self) -> QuerySet[CertificateModel]: """Return the annotated base queryset used by the certificates table.""" now = timezone.now() - return CertificateModel.objects.annotate( - certificate_status_sort=Case( - When(revoked_certificate__isnull=False, then=Value(3)), - When(not_valid_before__gt=now, then=Value(4)), - When(not_valid_after__lte=now, then=Value(2)), - default=Value(0), - output_field=IntegerField(), - ), + return ( + CertificateModel.objects + .select_related('revoked_certificate') + .annotate( + certificate_status_sort=Case( + When(revoked_certificate__isnull=False, then=Value(3)), + When(not_valid_before__gt=now, then=Value(4)), + When(not_valid_after__lte=now, then=Value(2)), + default=Value(0), + output_field=IntegerField(), + ), + ) ) def get_queryset(self) -> QuerySet[CertificateModel]: @@ -162,6 +166,20 @@ class CertificateDetailView(CertificatesContextMixin, DetailView[CertificateMode template_name = 'pki/certificates/details.html' context_object_name = 'cert' + def get_queryset(self) -> QuerySet[CertificateModel]: + """Return the queryset with relations eagerly loaded for the detail template.""" + return ( + CertificateModel.objects + .select_related( + 'subject_alternative_name_extension__subject_alt_name', + ) + .prefetch_related( + 'subject', + 'issuer', + 'subject_alternative_name_extension__subject_alt_name__ip_addresses', + ) + ) + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: """Adding map of attribute and its oid with its values. diff --git a/trustpoint/pki/views/issuing_cas.py b/trustpoint/pki/views/issuing_cas.py index 5898fe674..51a9d4cc0 100644 --- a/trustpoint/pki/views/issuing_cas.py +++ b/trustpoint/pki/views/issuing_cas.py @@ -1402,28 +1402,22 @@ def _build_certificate_chain_for_credential(self, credential: CredentialModel, p def _find_existing_ca_for_certificate(self, cert: x509.Certificate) -> CaModel | None: """Find an existing CA that has the given certificate.""" - # TODO(FHK): comparing the subject public bytes is not sufficient # noqa: FIX002 - for existing_ca in CaModel.objects.filter(certificate__isnull=False): - try: - ca_cert = existing_ca.get_certificate() - if ca_cert and (ca_cert.subject.public_bytes() == cert.subject.public_bytes() and - ca_cert.issuer.public_bytes() == cert.issuer.public_bytes()): - return existing_ca - except (AttributeError, ValueError) as e: - self.logger.debug('Error checking existing keyless CA certificate: %s', e) - continue - - for existing_ca in CaModel.objects.filter(credential__isnull=False): - try: - ca_cert = existing_ca.get_certificate() - if ca_cert and (ca_cert.subject.public_bytes() == cert.subject.public_bytes() and - ca_cert.issuer.public_bytes() == cert.issuer.public_bytes()): - return existing_ca - except (AttributeError, ValueError) as e: - self.logger.debug('Error checking existing issuing CA certificate: %s', e) - continue - - return None + # TODO(FHK): subject+issuer byte equality is not sufficient for # noqa: FIX002 + + subject_bytes = cert.subject.public_bytes().hex().upper() + issuer_bytes = cert.issuer.public_bytes().hex().upper() + + keyless_match = CaModel.objects.filter( + certificate__subject_public_bytes=subject_bytes, + certificate__issuer_public_bytes=issuer_bytes, + ).first() + if keyless_match: + return keyless_match + + return CaModel.objects.filter( + credential__certificate__subject_public_bytes=subject_bytes, + credential__certificate__issuer_public_bytes=issuer_bytes, + ).first() class KeylessCaConfigView(LoggerMixin, KeylessCaContextMixin, DetailView[CaModel]): diff --git a/trustpoint/request/authentication/est.py b/trustpoint/request/authentication/est.py index c35abd33f..ac9387fbe 100644 --- a/trustpoint/request/authentication/est.py +++ b/trustpoint/request/authentication/est.py @@ -33,7 +33,9 @@ def authenticate(self, context: BaseRequestContext) -> None: password = context.est_password try: - device = DeviceModel.objects.select_related().filter( + device = DeviceModel.objects.select_related( + 'onboarding_config', 'no_onboarding_config' + ).filter( common_name=username ).first() diff --git a/trustpoint/request/authentication/rest.py b/trustpoint/request/authentication/rest.py index 2b6105b7c..ae7098611 100644 --- a/trustpoint/request/authentication/rest.py +++ b/trustpoint/request/authentication/rest.py @@ -30,7 +30,9 @@ def authenticate(self, context: BaseRequestContext) -> None: password = context.rest_password try: - device = DeviceModel.objects.select_related().filter( + device = DeviceModel.objects.select_related( + 'onboarding_config', 'no_onboarding_config' + ).filter( common_name=username ).first() diff --git a/trustpoint/request/operation_processor/issue_cert.py b/trustpoint/request/operation_processor/issue_cert.py index 4884b48ec..120f63c92 100644 --- a/trustpoint/request/operation_processor/issue_cert.py +++ b/trustpoint/request/operation_processor/issue_cert.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, cast, get_args from cryptography import x509 +from django.conf import settings from trustpoint_core.crypto_types import AllowedCertSignHashAlgos from trustpoint_core.oid import SignatureSuite from trustpoint_core.serializer import CredentialSerializer @@ -107,10 +108,9 @@ def _get_crl_distribution_point_url(self, context: BaseRequestContext, ca_id: in request_meta = getattr(request, 'META', {}) if request else {} if not isinstance(request_meta, dict): request_meta = {} - port = request_meta.get('SERVER_PORT', '') - if port == '443': # CRL always served via HTTP - port = '' - port_str = f':{port}' if port else '' + + http_port = settings.TP_HTTP_PORT or '80' + port_str = f':{http_port}' if http_port != '80' else '' return f'http://{TlsSettings.get_first_ipv4_address()}{port_str}/crl/{ca_id}' def _save_credential( diff --git a/trustpoint/request/tests/test_authentication.py b/trustpoint/request/tests/test_authentication.py index e60d428a0..80986b20e 100644 --- a/trustpoint/request/tests/test_authentication.py +++ b/trustpoint/request/tests/test_authentication.py @@ -8,7 +8,8 @@ from request.authentication.base import ClientCertificateAuthentication from request.authentication.est import UsernamePasswordAuthentication -from request.request_context import BaseRequestContext, EstBaseRequestContext +from request.authentication.rest import RestUsernamePasswordAuthentication +from request.request_context import BaseRequestContext, EstBaseRequestContext, RestBaseRequestContext class TestUsernamePasswordAuthentication: @@ -83,6 +84,25 @@ def test_authenticate_missing_password(self, device_instance): assert result is None +class TestRestUsernamePasswordAuthentication: + """Test cases for REST username/password authentication.""" + + def setup_method(self): + """Set up test fixtures.""" + self.auth = RestUsernamePasswordAuthentication() + self.context = Mock(spec=RestBaseRequestContext) + + def test_authenticate_success(self, est_device_without_onboarding): + """Test successful REST username/password authentication.""" + device = est_device_without_onboarding['device'] + self.context.rest_username = device.common_name + self.context.rest_password = device.no_onboarding_config.est_password + + self.auth.authenticate(self.context) + + assert self.context.device == device + + class TestClientCertificateAuthentication: """Test cases for ClientCertificateAuthentication.""" diff --git a/trustpoint/request/tests/test_certificate_request_processors.py b/trustpoint/request/tests/test_certificate_request_processors.py index c4d477316..769594da3 100644 --- a/trustpoint/request/tests/test_certificate_request_processors.py +++ b/trustpoint/request/tests/test_certificate_request_processors.py @@ -53,6 +53,8 @@ class _StoredRequest: operation='certification', ) - url = LocalCaCertificateIssueProcessor()._get_crl_distribution_point_url(context, ca_id=7) # noqa: SLF001 + with patch('request.operation_processor.issue_cert.settings') as mock_settings: + mock_settings.TP_HTTP_PORT = '80' + url = LocalCaCertificateIssueProcessor()._get_crl_distribution_point_url(context, ca_id=7) # noqa: SLF001 assert url.endswith('/crl/7') diff --git a/trustpoint/trustpoint/settings.py b/trustpoint/trustpoint/settings.py index 7a8294330..ad8aacdd8 100644 --- a/trustpoint/trustpoint/settings.py +++ b/trustpoint/trustpoint/settings.py @@ -18,7 +18,6 @@ from importlib.metadata import PackageNotFoundError, version from pathlib import Path from typing import Any, ClassVar -from urllib.parse import urlparse import django_stubs_ext import psycopg @@ -93,6 +92,31 @@ def app_version(_request: Any) -> dict[str, str]: # ------------- Functions -------------- +def _env_bool(name: str, *, default: bool) -> bool: + """Return a boolean setting from an environment variable.""" + raw_value = os.getenv(name) + if raw_value is None or raw_value.strip() == '': + return default + return raw_value.strip().lower() in {'1', 'true', 'yes', 'on'} + + +def _env_value(name: str, default: str, *, file_var: str | None = None) -> str: + """Return a setting from an environment variable or Docker secret file.""" + value = os.getenv(name) + if value is not None: + return value + + if file_var: + file_path = os.getenv(file_var) + if file_path is not None: + try: + return Path(file_path).read_text().strip() + except OSError: + return default + + return default + + def is_postgre_available() -> bool: """Checks whether PostgreSQL is available and issues differentiated error messages. @@ -106,11 +130,11 @@ def is_postgre_available() -> bool: print('PostgreSQL is disabled. Set POSTGRESQL=True in settings.') return False - host = os.environ.get('DATABASE_HOST', DATABASE_HOST) - port = int(os.environ.get('DATABASE_PORT', DATABASE_PORT)) - user = os.environ.get('DATABASE_USER', DATABASE_USER) - password = os.environ.get('DATABASE_PASSWORD', DATABASE_PASSWORD) - db_name = os.environ.get('POSTGRES_DB', POSTGRES_DB) + host = DATABASE_HOST + port = int(DATABASE_PORT) + user = DATABASE_USER + password = DATABASE_PASSWORD + db_name = POSTGRES_DB try: print(f'Trying to connect to {host}:{port}...') @@ -164,50 +188,65 @@ def is_postgre_available() -> bool: ALLOWED_HOSTS = ['localhost', '127.0.0.1', '[::1]'] CSRF_TRUSTED_ORIGINS = ['http://localhost:8000', 'http://127.0.0.1:8000'] -raw_urls = os.getenv('TP_URLS', '') - -if raw_urls: - # Split by comma and clean up whitespace - url_list = [url.strip() for url in raw_urls.split(',') if url.strip()] - - for url in url_list: - # Ensure scheme is present (fallback to https) - parsed = urlparse(url) if url.startswith(('http://', 'https://')) else urlparse(f'https://{url}') - - host_with_port = parsed.netloc - - # 1. Extract just host/IP for ALLOWED_HOSTS (strip port) - host_only = host_with_port.split(':')[0] - if host_only not in ALLOWED_HOSTS: - ALLOWED_HOSTS.append(host_only) - - # If mDNS domain, trust subdomains too - if host_only.endswith('.local') and f'.{host_only}' not in ALLOWED_HOSTS: - ALLOWED_HOSTS.append(f'.{host_only}') - - # 2. Extract the exact origin (scheme + host + port) for CSRF - # e.g., "https://trustpoint.local:8443" or "http://10.10.0.2" - exact_origin = f'{parsed.scheme}://{host_with_port}' - if exact_origin not in CSRF_TRUSTED_ORIGINS: - CSRF_TRUSTED_ORIGINS.append(exact_origin) - - -# Basic SMTP backend -if DEBUG: - EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' -else: - EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' - DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', default='no-reply@trustpoint.ai') - - -# Settings for postgreql database -POSTGRESQL = True -DATABASE_ENGINE = 'django.db.backends.postgresql' -DATABASE_HOST = 'localhost' -DATABASE_PORT = '5432' -POSTGRES_DB = 'trustpoint_db' -DATABASE_USER = 'admin' -DATABASE_PASSWORD = 'testing321' # noqa: S105 +TP_TLS_IPV4_ADDRESSES = _env_value('TP_TLS_IPV4_ADDRESSES', '') +TP_TLS_IPV6_ADDRESSES = _env_value('TP_TLS_IPV6_ADDRESSES', '') +TP_TLS_DNS_NAMES = _env_value('TP_TLS_DNS_NAMES', '') + +TP_HTTP_PORT = _env_value('TP_HTTP_PORT', '80') +TP_HTTPS_PORT = _env_value('TP_HTTPS_PORT', '443') + +tls_ipv4_list = [addr.strip() for addr in TP_TLS_IPV4_ADDRESSES.split(',') if addr.strip()] +tls_ipv6_list = [addr.strip() for addr in TP_TLS_IPV6_ADDRESSES.split(',') if addr.strip()] +tls_dns_list = [name.strip() for name in TP_TLS_DNS_NAMES.split(',') if name.strip()] + +def _format_origin(scheme: str, host: str, port: str) -> str: + """Format an origin URL with optional port.""" + default_port = '80' if scheme == 'http' else '443' + if port == default_port: + return f'{scheme}://{host}' + return f'{scheme}://{host}:{port}' + +for ipv4 in tls_ipv4_list: + if ipv4 not in ALLOWED_HOSTS: + ALLOWED_HOSTS.append(ipv4) + http_origin = _format_origin('http', ipv4, TP_HTTP_PORT) + https_origin = _format_origin('https', ipv4, TP_HTTPS_PORT) + if http_origin not in CSRF_TRUSTED_ORIGINS: + CSRF_TRUSTED_ORIGINS.append(http_origin) + if https_origin not in CSRF_TRUSTED_ORIGINS: + CSRF_TRUSTED_ORIGINS.append(https_origin) + +for ipv6 in tls_ipv6_list: + if ipv6 not in ALLOWED_HOSTS: + ALLOWED_HOSTS.append(ipv6) + http_origin = _format_origin('http', f'[{ipv6}]', TP_HTTP_PORT) + https_origin = _format_origin('https', f'[{ipv6}]', TP_HTTPS_PORT) + if http_origin not in CSRF_TRUSTED_ORIGINS: + CSRF_TRUSTED_ORIGINS.append(http_origin) + if https_origin not in CSRF_TRUSTED_ORIGINS: + CSRF_TRUSTED_ORIGINS.append(https_origin) + +for dns_name in tls_dns_list: + if dns_name not in ALLOWED_HOSTS: + ALLOWED_HOSTS.append(dns_name) + if dns_name.endswith('.local') and f'.{dns_name}' not in ALLOWED_HOSTS: + ALLOWED_HOSTS.append(f'.{dns_name}') + http_origin = _format_origin('http', dns_name, TP_HTTP_PORT) + https_origin = _format_origin('https', dns_name, TP_HTTPS_PORT) + if http_origin not in CSRF_TRUSTED_ORIGINS: + CSRF_TRUSTED_ORIGINS.append(http_origin) + if https_origin not in CSRF_TRUSTED_ORIGINS: + CSRF_TRUSTED_ORIGINS.append(https_origin) + + +# Settings for PostgreSQL database +POSTGRESQL = _env_bool('POSTGRESQL', default=True) +DATABASE_ENGINE = _env_value('DATABASE_ENGINE', 'django.db.backends.postgresql') +DATABASE_HOST = _env_value('DATABASE_HOST', 'localhost') +DATABASE_PORT = _env_value('DATABASE_PORT', '5432') +POSTGRES_DB = _env_value('POSTGRES_DB', 'trustpoint_db') +DATABASE_USER = _env_value('DATABASE_USER', 'admin', file_var='DATABASE_USER_FILE') +DATABASE_PASSWORD = _env_value('DATABASE_PASSWORD', 'testing321', file_var='DATABASE_PASSWORD_FILE') # Setting for email backend @@ -217,23 +256,21 @@ def is_postgre_available() -> bool: EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' # If EMAIL_HOST is present, switch to SMTP -_email_host = os.getenv('EMAIL_HOST') # e.g. "smtp.customer.tld" or "mailpit" +_email_host = _env_value('EMAIL_HOST', '') # e.g. "smtp.customer.tld" or "mailpit" if _email_host: EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_HOST = _email_host - EMAIL_PORT = int(os.getenv('EMAIL_PORT', '587')) + EMAIL_PORT = int(_env_value('EMAIL_PORT', '587')) # Sensible defaults based on port; env can override _SMTP_TLS_PORT = 587 _SMTP_SSL_PORT = 465 - _use_tls_env = os.getenv('EMAIL_USE_TLS') - _use_ssl_env = os.getenv('EMAIL_USE_SSL') - EMAIL_USE_TLS = (_use_tls_env.lower() in ('1', 'true', 'yes')) if _use_tls_env else (EMAIL_PORT == _SMTP_TLS_PORT) - EMAIL_USE_SSL = (_use_ssl_env.lower() in ('1', 'true', 'yes')) if _use_ssl_env else (EMAIL_PORT == _SMTP_SSL_PORT) + EMAIL_USE_TLS = _env_bool('EMAIL_USE_TLS', default=EMAIL_PORT == _SMTP_TLS_PORT) + EMAIL_USE_SSL = _env_bool('EMAIL_USE_SSL', default=EMAIL_PORT == _SMTP_SSL_PORT) - EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '') # auth only if both non-empty - EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '') - EMAIL_TIMEOUT = int(os.getenv('EMAIL_TIMEOUT', '10')) + EMAIL_HOST_USER = _env_value('EMAIL_HOST_USER', '') # auth only if both non-empty + EMAIL_HOST_PASSWORD = _env_value('EMAIL_HOST_PASSWORD', '') + EMAIL_TIMEOUT = int(_env_value('EMAIL_TIMEOUT', '10')) STORAGES = { @@ -385,11 +422,11 @@ def is_postgre_available() -> bool: DATABASES = { 'default': { 'ENGINE': DATABASE_ENGINE, - 'NAME': os.environ.get('POSTGRES_DB', POSTGRES_DB), - 'USER': os.environ.get('DATABASE_USER', DATABASE_USER), - 'PASSWORD': os.environ.get('DATABASE_PASSWORD', DATABASE_PASSWORD), - 'HOST': os.environ.get('DATABASE_HOST', DATABASE_HOST), - 'PORT': os.environ.get('DATABASE_PORT', DATABASE_PORT), + 'NAME': POSTGRES_DB, + 'USER': DATABASE_USER, + 'PASSWORD': DATABASE_PASSWORD, + 'HOST': DATABASE_HOST, + 'PORT': DATABASE_PORT, } } else: diff --git a/trustpoint/trustpoint/tests/test_settings.py b/trustpoint/trustpoint/tests/test_settings.py index 86692c0b8..13f5c5429 100644 --- a/trustpoint/trustpoint/tests/test_settings.py +++ b/trustpoint/trustpoint/tests/test_settings.py @@ -27,9 +27,11 @@ def test_debug_setting(): assert settings.DEBUG is (not settings.DOCKER_CONTAINER), 'DEBUG should be the inverse of DOCKER_CONTAINER.' -def test_tp_urls_not_set_keeps_default_hosts_and_origins(monkeypatch): - """Ensure defaults remain unchanged when TP_URLS is not set.""" - monkeypatch.delenv('TP_URLS', raising=False) +def test_tls_addresses_not_set_keeps_default_hosts_and_origins(monkeypatch): + """Ensure defaults remain unchanged when TLS address variables are not set.""" + monkeypatch.delenv('TP_TLS_IPV4_ADDRESSES', raising=False) + monkeypatch.delenv('TP_TLS_IPV6_ADDRESSES', raising=False) + monkeypatch.delenv('TP_TLS_DNS_NAMES', raising=False) importlib.reload(settings) @@ -40,34 +42,163 @@ def test_tp_urls_not_set_keeps_default_hosts_and_origins(monkeypatch): assert 'http://127.0.0.1:8000' in settings.CSRF_TRUSTED_ORIGINS -def test_tp_urls_derives_allowed_hosts_and_csrf_origins(monkeypatch): - """Ensure TP_URLS entries are parsed into ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS.""" - monkeypatch.setenv('TP_URLS', 'trustpoint.local:8443, http://10.10.0.2, https://example.org') +def test_tls_ipv4_addresses_derives_allowed_hosts_and_csrf_origins(monkeypatch): + """Ensure TP_TLS_IPV4_ADDRESSES entries are parsed into ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS.""" + monkeypatch.setenv('TP_TLS_IPV4_ADDRESSES', '10.10.0.2, 192.168.1.100') + monkeypatch.setenv('TP_HTTP_PORT', '8080') + monkeypatch.setenv('TP_HTTPS_PORT', '8443') + + importlib.reload(settings) + + assert '10.10.0.2' in settings.ALLOWED_HOSTS + assert '192.168.1.100' in settings.ALLOWED_HOSTS + + assert 'http://10.10.0.2:8080' in settings.CSRF_TRUSTED_ORIGINS + assert 'https://10.10.0.2:8443' in settings.CSRF_TRUSTED_ORIGINS + assert 'http://192.168.1.100:8080' in settings.CSRF_TRUSTED_ORIGINS + assert 'https://192.168.1.100:8443' in settings.CSRF_TRUSTED_ORIGINS + + +def test_tls_ipv6_addresses_derives_allowed_hosts_and_csrf_origins(monkeypatch): + """Ensure TP_TLS_IPV6_ADDRESSES entries are parsed into ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS.""" + monkeypatch.setenv('TP_TLS_IPV6_ADDRESSES', 'fe80::1, 2001:db8::1') + monkeypatch.setenv('TP_HTTP_PORT', '80') + monkeypatch.setenv('TP_HTTPS_PORT', '443') + + importlib.reload(settings) + + assert 'fe80::1' in settings.ALLOWED_HOSTS + assert '2001:db8::1' in settings.ALLOWED_HOSTS + + assert 'http://[fe80::1]' in settings.CSRF_TRUSTED_ORIGINS + assert 'https://[fe80::1]' in settings.CSRF_TRUSTED_ORIGINS + assert 'http://[2001:db8::1]' in settings.CSRF_TRUSTED_ORIGINS + assert 'https://[2001:db8::1]' in settings.CSRF_TRUSTED_ORIGINS + + +def test_tls_dns_names_derives_allowed_hosts_and_csrf_origins(monkeypatch): + """Ensure TP_TLS_DNS_NAMES entries are parsed into ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS.""" + monkeypatch.setenv('TP_TLS_DNS_NAMES', 'trustpoint.local, example.org') + monkeypatch.setenv('TP_HTTP_PORT', '8080') + monkeypatch.setenv('TP_HTTPS_PORT', '8443') importlib.reload(settings) assert 'trustpoint.local' in settings.ALLOWED_HOSTS assert '.trustpoint.local' in settings.ALLOWED_HOSTS - assert '10.10.0.2' in settings.ALLOWED_HOSTS assert 'example.org' in settings.ALLOWED_HOSTS + assert 'http://trustpoint.local:8080' in settings.CSRF_TRUSTED_ORIGINS assert 'https://trustpoint.local:8443' in settings.CSRF_TRUSTED_ORIGINS - assert 'http://10.10.0.2' in settings.CSRF_TRUSTED_ORIGINS - assert 'https://example.org' in settings.CSRF_TRUSTED_ORIGINS + assert 'http://example.org:8080' in settings.CSRF_TRUSTED_ORIGINS + assert 'https://example.org:8443' in settings.CSRF_TRUSTED_ORIGINS -def test_tp_urls_deduplicates_hosts_and_origins(monkeypatch): - """Ensure repeated TP_URLS values do not create duplicate entries.""" - monkeypatch.setenv( - 'TP_URLS', - ' https://dup.local:9443,https://dup.local:9443 , dup.local:9443 ', - ) +def test_tls_dns_names_adds_wildcard_for_local_domains(monkeypatch): + """Ensure .local domains get wildcard subdomain entries in ALLOWED_HOSTS.""" + monkeypatch.setenv('TP_TLS_DNS_NAMES', 'trustpoint.local, other.local') + + importlib.reload(settings) + + assert 'trustpoint.local' in settings.ALLOWED_HOSTS + assert '.trustpoint.local' in settings.ALLOWED_HOSTS + assert 'other.local' in settings.ALLOWED_HOSTS + assert '.other.local' in settings.ALLOWED_HOSTS + + +def test_tls_addresses_deduplicates_hosts_and_origins(monkeypatch): + """Ensure repeated TLS address values do not create duplicate entries.""" + monkeypatch.setenv('TP_TLS_IPV4_ADDRESSES', '10.10.0.2, 10.10.0.2') + monkeypatch.setenv('TP_TLS_DNS_NAMES', 'dup.local, dup.local') importlib.reload(settings) + assert settings.ALLOWED_HOSTS.count('10.10.0.2') == 1 assert settings.ALLOWED_HOSTS.count('dup.local') == 1 assert settings.ALLOWED_HOSTS.count('.dup.local') == 1 - assert settings.CSRF_TRUSTED_ORIGINS.count('https://dup.local:9443') == 1 + + +def test_tls_addresses_handles_default_ports(monkeypatch): + """Ensure default ports (80/443) are omitted from CSRF_TRUSTED_ORIGINS.""" + monkeypatch.setenv('TP_TLS_IPV4_ADDRESSES', '10.10.0.2') + monkeypatch.setenv('TP_HTTP_PORT', '80') + monkeypatch.setenv('TP_HTTPS_PORT', '443') + + importlib.reload(settings) + + assert 'http://10.10.0.2' in settings.CSRF_TRUSTED_ORIGINS + assert 'https://10.10.0.2' in settings.CSRF_TRUSTED_ORIGINS + assert 'http://10.10.0.2:80' not in settings.CSRF_TRUSTED_ORIGINS + assert 'https://10.10.0.2:443' not in settings.CSRF_TRUSTED_ORIGINS + + +def test_env_bool_uses_default_when_variable_is_missing(monkeypatch): + """Ensure boolean environment settings keep their default when unset.""" + monkeypatch.delenv('POSTGRESQL', raising=False) + + assert settings._env_bool('POSTGRESQL', default=True) is True + assert settings._env_bool('POSTGRESQL', default=False) is False + + +def test_env_bool_uses_default_when_variable_is_blank(monkeypatch): + """Ensure blank boolean environment settings keep their default.""" + monkeypatch.setenv('EMAIL_USE_TLS', '') + + assert settings._env_bool('EMAIL_USE_TLS', default=True) is True + assert settings._env_bool('EMAIL_USE_TLS', default=False) is False + + +def test_env_bool_parses_truthy_values(monkeypatch): + """Ensure common truthy strings enable boolean settings.""" + for value in ('1', 'true', 'yes', 'on', ' TRUE '): + monkeypatch.setenv('POSTGRESQL', value) + + assert settings._env_bool('POSTGRESQL', default=False) is True + + +def test_env_bool_treats_other_values_as_false(monkeypatch): + """Ensure non-truthy strings disable boolean settings.""" + for value in ('0', 'false', 'no', 'off', 'unexpected'): + monkeypatch.setenv('POSTGRESQL', value) + + assert settings._env_bool('POSTGRESQL', default=True) is False + + +def test_env_value_prefers_direct_environment_variable(monkeypatch, tmp_path): + """Ensure direct environment variables win over Docker secret files.""" + secret_file = tmp_path / 'db_user' + secret_file.write_text('secret-user\n') + monkeypatch.setenv('DATABASE_USER', 'env-user') + monkeypatch.setenv('DATABASE_USER_FILE', str(secret_file)) + + assert settings._env_value('DATABASE_USER', 'admin', file_var='DATABASE_USER_FILE') == 'env-user' + + +def test_env_value_reads_docker_secret_file(monkeypatch, tmp_path): + """Ensure settings can be loaded from Docker secret files.""" + secret_file = tmp_path / 'db_password' + secret_file.write_text('secret-password\n') + monkeypatch.delenv('DATABASE_PASSWORD', raising=False) + monkeypatch.setenv('DATABASE_PASSWORD_FILE', str(secret_file)) + + assert settings._env_value( + 'DATABASE_PASSWORD', + 'testing321', + file_var='DATABASE_PASSWORD_FILE', + ) == 'secret-password' + + +def test_env_value_falls_back_when_secret_file_is_unreadable(monkeypatch, tmp_path): + """Ensure unreadable Docker secret paths do not break settings import.""" + missing_file = tmp_path / 'missing_secret' + monkeypatch.delenv('DATABASE_PASSWORD', raising=False) + monkeypatch.setenv('DATABASE_PASSWORD_FILE', str(missing_file)) + + assert settings._env_value( + 'DATABASE_PASSWORD', + 'testing321', + file_var='DATABASE_PASSWORD_FILE', + ) == 'testing321' def test_database_settings(monkeypatch):