diff --git a/AI_CONTRIBUTING_RULES.md b/AI_CONTRIBUTING_RULES.md new file mode 100644 index 0000000..1a1368c --- /dev/null +++ b/AI_CONTRIBUTING_RULES.md @@ -0,0 +1,13 @@ +# Global AI Agent Instructions (Jules) + +**1. The "Living Wiki" Mandate:** +This repository maintains its documentation as a living wiki. The primary sources of truth are the `SYSTEM_ARCHITECTURE.md` and `SECURITY_ARCHITECTURE.md` files. + +**2. Mandatory PR Checklist:** +For *every single Pull Request* you generate, regardless of the specific issue assigned to you, you MUST perform the following checks before committing: +* **Diff Review:** Analyze your proposed code changes. +* **Wiki Sync:** If your code alters the network flow, port configurations, authentication methods, database schemas, or deployment commands, you MUST open `SYSTEM_ARCHITECTURE.md`, `SECURITY_ARCHITECTURE.md` and all other markdown files in the root directory and `/docs/` directory to update the relevant sections to reflect your exact code changes. Also update the `docs/system_data_flow.svg` as appropriate. +* **Commit Inclusion:** The documentation updates must be included in the same commit/PR as the code changes. Do not submit a PR where the code and the architecture wiki are out of sync. + +**3. Global Build Constraint:** +Whenever generating or modifying Dockerfiles for this project, you MUST ensure the font package is explicitly set to `fonts-freefont-ttf` to prevent downstream rendering failures. diff --git a/CONTAINERIZATION_PLAN.md b/CONTAINERIZATION_PLAN.md new file mode 100644 index 0000000..c5273a7 --- /dev/null +++ b/CONTAINERIZATION_PLAN.md @@ -0,0 +1,326 @@ +# CONTAINERIZATION_PLAN.md - OSCAR Full Stack Containerization + +This document outlines the proposal for migrating the OSCAR (Open Source Central Alarm Station) architecture to a fully containerized deployment model orchestrated by Docker Compose. + +## 1. Proposed `docker-compose.yml` + +The new `docker-compose.yml` will unify the PostGIS database, the OSH Backend, and the Caddy Reverse Proxy into a single orchestration unit. + +### 1.0 Startup Sequencing & Initialization Lifecycles +Based on the existing hybrid deployment scripts, a strict startup sequence is critical for a stable environment. The containerized stack must mirror this logic: + +#### Pre-flight Bootstrapper (Initialization of Secrets) +In the hybrid system, bash scripts (`launch-all.sh`) generate the `.db_password` *before* Docker starts. To replicate this securely in a pure `docker-compose.yml` environment without relying on host-side bash scripts, an `init-secrets` ephemeral container is introduced to generate the 32-byte Base64 passwords (`.db_password` and `.app_secrets`) and persist them to a shared volume. This prevents the database and backend from ever starting without their foundational credentials. + +#### 1. PostGIS First +The database container starts and provisions the `gis` database, loading spatial extensions. `pg_isready` health checks ensure it is fully online. It relies on the `.db_password` generated by the `init-secrets` container. + +#### 2. Backend Delay & Persistent CA +The OSH Backend (`osh-backend`) must wait for PostGIS to become `service_healthy`. On initial boot, the backend requires significant processing time to: +- Generate local certificates via `LocalCAUtility.java`. This utility generates a random keystore password (if `.app_secrets` is missing), a 20-year Root CA (`root-ca.crt`), and a 1-year leaf certificate (`osh-keystore.p12`). These artifacts are written to a persistent volume so they survive restarts. *Note: `LocalCAUtility.java` must be programmed to export `osh-leaf.crt` and `osh-leaf.key` to the filesystem for the proxy to consume.* +- Evaluate the `SecurityManagerImpl.isUninitialized()` state (checking for default passwords and missing TOTP secrets). + +#### 3. Proxy Delay +Because authorization configuration (completing the Setup Wizard, changing the default admin password, and generating TOTP secrets) requires substantial time on first boot, the Caddy Reverse Proxy (`osh-proxy`) is strictly delayed from routing traffic until the backend passes its own HTTP health check. This ensures users are not met with 502 Bad Gateway errors while the Java context initializes. + +### 1.1 Service Definitions + +```yaml +services: + # Pre-flight container to generate secrets exactly like launch-all.sh + init-secrets: + image: alpine:latest + container_name: oscar-init-secrets + volumes: + - oscar_secrets:/secrets + command: > + /bin/sh -c " + apk add --no-cache openssl; + if [ ! -f /secrets/.db_password ]; then + echo 'Generating new database password...'; + openssl rand -base64 32 > /secrets/.db_password; + fi; + if [ ! -f /secrets/.app_secrets ]; then + echo 'Generating new keystore password...'; + openssl rand -base64 32 > /secrets/.app_secrets; + fi; + " + + osh-postgis: + depends_on: + init-secrets: + condition: service_completed_successfully + build: + context: ./dist/release/postgis + dockerfile: Dockerfile + image: oscar-postgis:latest + container_name: oscar-postgis-container + environment: + - POSTGRES_DB=gis + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD_FILE=/secrets/.db_password + # Performance Tuning from .env + - POSTGRES_SHARED_BUFFERS=${DB_SHARED_BUFFERS:-128MB} + - POSTGRES_EFFECTIVE_CACHE_SIZE=${DB_EFFECTIVE_CACHE_SIZE:-512MB} + - POSTGRES_WORK_MEM=${DB_WORK_MEM:-4MB} + - POSTGRES_MAX_WAL_SIZE=${DB_MAX_WAL_SIZE:-1GB} + - POSTGRES_MAX_CONNECTIONS=${DB_MAX_CONNECTIONS:-50} + - POSTGRES_MAINTENANCE_WORK_MEM=${DB_MAINTENANCE_WORK_MEM:-64MB} + ports: + - "5432:5432" + volumes: + - ./pgdata:/var/lib/postgresql/data + - oscar_secrets:/secrets:ro + networks: + - osh-internal + deploy: + resources: + limits: + memory: ${DB_MEM_LIMIT:-1G} + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d gis"] + interval: 10s + timeout: 5s + retries: 5 + + osh-backend: + build: + context: . + dockerfile: Dockerfile.osh + image: oscar-backend:latest + container_name: oscar-backend-container + environment: + - DB_HOST=${DB_HOST:-osh-postgis} + - POSTGRES_PASSWORD_FILE=/secrets/.db_password + - KEYSTORE=/app/config/osh-keystore.p12 + - KEYSTORE_TYPE=PKCS12 + - SHOW_CMD=true + # JVM Tuning from .env + - JAVA_OPTS=-Xmx${BACKEND_MEM_LIMIT:-2G} -Xms${BACKEND_MEM_LIMIT:-2G} + volumes: + # Use named volumes or bind mounts for persistent backend state + - ./osh-node-oscar/config:/app/config + - ./osh-node-oscar/db:/app/db + - ./osh-node-oscar/files:/app/files + # The secrets volume guarantees persistence for the CA and database passwords + - oscar_secrets:/secrets:ro + networks: + - osh-internal + depends_on: + osh-postgis: + condition: service_healthy + deploy: + resources: + limits: + memory: ${BACKEND_MEM_LIMIT:-2G} + restart: unless-stopped + # Port 8282 is not exposed to the host network to ensure it's only accessible via the proxy + # For local debugging, it can be bound to 127.0.0.1:8282 + ports: + - "127.0.0.1:8282:8282" + healthcheck: + # Wait-State Logic: The backend takes significant time to generate certificates, + # process the Setup Wizard, and validate TOTP/Admin states before it is ready. + # The -L flag ensures curl follows the 302 redirect to the Setup Wizard on first boot. + test: ["CMD", "curl", "-f", "-L", "-k", "https://localhost:8282/sensorhub/admin"] + interval: 15s + timeout: 10s + retries: 15 + start_period: 60s + + osh-proxy: + image: caddy:2-alpine + depends_on: + osh-backend: + condition: service_healthy + container_name: oscar-proxy-container + environment: + - TAILSCALE_DOMAIN=${TAILSCALE_DOMAIN:-} + - LOCAL_DOMAIN=${LOCAL_DOMAIN:-localhost} + ports: + - "80:80" + - "443:443" + volumes: + - ./caddy:/etc/caddy + - caddy_data:/data + - caddy_config:/config + - /var/run/tailscale/tailscaled.sock:/var/run/tailscale/tailscaled.sock + # Mount the generated certificates from the backend's persistent config volume + - ./osh-node-oscar/config/osh-leaf.crt:/etc/caddy/certs/osh-leaf.crt:ro + - ./osh-node-oscar/config/osh-leaf.key:/etc/caddy/certs/osh-leaf.key:ro + networks: + - osh-internal + restart: unless-stopped + +networks: + osh-internal: + driver: bridge + +volumes: + caddy_data: + caddy_config: + oscar_secrets: +``` + +### 1.2 Internal Network Routing and Port Mappings +- **Isolation**: All services reside on the `osh-internal` bridge network. +- **osh-postgis**: Port 5432 is internal only. +- **osh-backend**: Port 8282 is bound specifically to `127.0.0.1` on the host, preventing external access except through the reverse proxy. Within the Docker network, it is reachable by `osh-proxy` at `http://osh-backend:8282`. +- **osh-proxy**: Ports 80 and 443 are exposed to the host for public/LAN access. + +--- + +## 2. Proposed TLS & Routing Strategy (Caddy Dynamic Switching) + +The Caddy reverse proxy will handle TLS termination and dynamic routing based on environment variables. + +### 2.1 Caddyfile Structure + +The Caddyfile will implement a "Dual-Listener" setup, ensuring the local LAN fallback is always active even if Tailscale is enabled. + +**Main Caddyfile (`/etc/caddy/Caddyfile`):** +```caddy +# 1. Local LAN Block (Always Active Fallback) +{$LOCAL_DOMAIN:localhost}, 127.0.0.1 { + # Forward headers to the backend + reverse_proxy https://osh-backend:8282 { + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + transport http { + tls_insecure_skip_verify + } + } + + # Use local Java certificates for LAN encryption + tls /etc/caddy/certs/osh-leaf.crt /etc/caddy/certs/osh-leaf.key +} + +# 2. Tailscale Block (Conditional Federated Access) +{$TAILSCALE_DOMAIN} { + @has_tailscale expression "{env.TAILSCALE_DOMAIN} != ''" + handle @has_tailscale { + # Forward headers to the backend + reverse_proxy https://osh-backend:8282 { + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + transport http { + tls_insecure_skip_verify + } + } + + # Use Tailscale's automatic TLS + tls { + get_certificate tailscale + } + } +} +``` + +### 2.2 Operational Details +- **Dual-Listener Reliability**: Caddy simultaneously serves the local LAN/localhost IP and the Tailscale domain. If Tailscale fails or loses internet connectivity, operators can immediately fall back to the LAN address without restarting services. +- **Local Mode**: Uses the locally generated Java Leaf certificates (`osh-leaf.crt` and `osh-leaf.key`). +- **Federated Mode (Tailscale)**: Uses the `get_certificate tailscale` directive. This block is only active when `TAILSCALE_DOMAIN` is populated. +- **Header Forwarding**: Standard headers (`X-Forwarded-For`, `X-Forwarded-Proto`, etc.) are forwarded to ensure the OSH backend correctly identifies the client's origin. + +--- + +## 3. Proposed Backend Dockerfile + +The OSH Backend will be containerized using a lightweight Alpine-based Java image. + +To accommodate the wait-state logic, the entrypoint integrates the required `curl` package for health checks and incorporates the necessary sleep/delay loops mirroring the original bash scripts. It also generates the required Java truststore dynamically to ensure federated HTTPS requests succeed. + +### 3.1 Dockerfile Structure + +```dockerfile +# Dockerfile.osh +FROM eclipse-temurin:21-jre-alpine + +# Set the working directory +WORKDIR /app + +# GLOBAL BUILD CONSTRAINT: Explicitly set the font package to fonts-freefont-ttf +# GLOBAL BUILD CONSTRAINT: Bypass HTTPS for corporate SSL inspection during build +RUN sed -i 's/https/http/g' /etc/apk/repositories && \ + apk update && \ + apk add --no-cache fonts-freefont-ttf openssl bash curl && \ + rm -rf /var/cache/apk/* + +# Copy build artifacts +COPY ./osh-node-oscar/lib /app/lib +COPY ./osh-node-oscar/config /app/config +COPY ./osh-node-oscar/web /app/web +COPY ./osh-node-oscar/logback.xml /app/logback.xml + +# The ENTRYPOINT ensures pre-launch checks (Local CA generation and fail-secure secret loading) run before the JVM starts. +# It implements a 30-second buffer delay after the database is reachable to allow PostGIS extensions to fully load. +# It dynamically copies the Alpine JRE cacerts to build a valid truststore for federation, using the default 'changeit' password. +# JAVA_OPTS is used to pass memory limits from the .env file. +ENTRYPOINT ["/bin/bash", "-c", "echo 'Waiting 30 seconds for PostGIS spatial extensions to settle...'; sleep 30; cd /app/config; if [ ! -f .app_secrets ]; then cp /secrets/.app_secrets .; fi; java -cp '../lib/*' com.botts.impl.security.LocalCAUtility && export KEYSTORE_PASSWORD=$(head -n 1 .app_secrets) && cp $JAVA_HOME/lib/security/cacerts truststore.jks && chmod 644 truststore.jks && java $JAVA_OPTS -Djavax.net.ssl.keyStorePassword=$KEYSTORE_PASSWORD -Djavax.net.ssl.trustStorePassword=changeit -Djavax.net.ssl.trustStore=./truststore.jks -cp '../lib/*' com.botts.impl.security.SensorHubWrapper ./config.json ../db"] +``` + +--- + +## 4. Scaled Deployment Profiles (.env Templates) + +These profiles define the environment variables required to scale the system for different hardware scenarios. + +### Scenario A: "Edge Node" (1 Lane, All-in-One) +**Hardware**: Raspberry Pi (4GB-8GB RAM) + +```ini +DB_HOST=osh-postgis +BACKEND_MEM_LIMIT=2G +DB_MEM_LIMIT=1G +DB_MAX_CONNECTIONS=50 +DB_SHARED_BUFFERS=128MB +DB_EFFECTIVE_CACHE_SIZE=512MB +DB_WORK_MEM=4MB +DB_MAX_WAL_SIZE=1GB +``` + +### Scenario B: "Tactical Hub" (10 Lanes / 20 Cameras, All-in-One) +**Hardware**: Powerful Laptop (16GB RAM) + +```ini +DB_HOST=osh-postgis +BACKEND_MEM_LIMIT=8G +DB_MEM_LIMIT=4G +DB_MAX_CONNECTIONS=100 +DB_SHARED_BUFFERS=1GB +DB_EFFECTIVE_CACHE_SIZE=3GB +DB_WORK_MEM=16MB +DB_MAX_WAL_SIZE=4GB +``` + +### Scenario C: "Enterprise Central Hub" (50 Lanes / 100 Cameras, Distributed LAN) +**Hardware**: Machine 1 (App Server, 16GB), Machine 2 (DB Server, 16GB) + +**Machine 1 (Application Server) Profile**: +```ini +DB_HOST= +BACKEND_MEM_LIMIT=14G +# (DB variables omitted/ignored as PostGIS does not run on this machine) +``` + +**Machine 2 (Database Server) Profile**: +```ini +DB_MEM_LIMIT=14G +DB_MAX_CONNECTIONS=200 +DB_SHARED_BUFFERS=4GB +DB_EFFECTIVE_CACHE_SIZE=10GB +DB_MAINTENANCE_WORK_MEM=1GB +DB_WORK_MEM=64MB +DB_MAX_WAL_SIZE=8GB +``` + +--- + +## 5. Global Build Constraint Acknowledgment +- **Font Package**: All Alpine-based Dockerfiles explicitly set the font package to `fonts-freefont-ttf` to ensure application reporting and charting render correctly. +- **HTTP Bypass**: All `apk add` steps use `sed -i 's/https/http/g' /etc/apk/repositories` to ensure reliability behind corporate firewalls. diff --git a/MAPPING.md b/MAPPING.md new file mode 100644 index 0000000..8a9a53c --- /dev/null +++ b/MAPPING.md @@ -0,0 +1,15 @@ +# Upstream to Oscar-Flat Mapping + +This document defines how upstream modules are mapped into the flattened structure of the oscar-flat repository. + +| Upstream Path | Oscar-Flat Path (Internal) | +|---------------|----------------------------| +| sensors/ | include/osh-oakridge-modules/sensors/ | +| services/ | include/osh-oakridge-modules/services/ | +| processing/ | include/osh-oakridge-modules/processing/ | +| tools/ | include/osh-oakridge-modules/tools/ | +| core/ | include/osh-core/ | +| addons/ | include/osh-addons/ | +| web/ | web/ | + +Note: The integration branch 'integration/oscar-v3.1.0-upgrades-4785495677883353489' already follows this mapping. diff --git a/README.md b/README.md index d586e32..675f69e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ This repository combines all the OSH modules and dependencies to deploy the OSH - [Java 21](https://www.oracle.com/java/technologies/downloads/#java21) - [Oakridge Build Node Repository](https://github.com/Botts-Innovative-Research/osh-oakridge-buildnode) - Node v22 +- [Docker](https://docs.docker.com/get-docker/) (Required to run the PostGIS database system) ## Installation Clone the repository and update all submodules recursively @@ -44,28 +45,80 @@ After the build completes, it can be located in `build/distributions/` Option 1: Command Line ```bash - unzip build/distributions/osh-node-oscar-1.0.zip - cd osh-node-oscar-1.0/osh-node-oscar-1.0 + # Note: Replace with the current version, e.g. 3.0.0 + unzip build/distributions/osh-node-oscar-.zip + cd osh-node-oscar-/osh-node-oscar- ``` ```bash - tar -xf build/distributions/osh-node-oscar-1.0.zip - cd osh-node-oscar-1.0/osh-node-oscar-1.0 + # Note: Replace with the current version, e.g. 3.0.0 + tar -xf build/distributions/osh-node-oscar-.zip + cd osh-node-oscar-/osh-node-oscar- ``` Option 2: Use File Explorer 1. Navigate to `path/to/osh-oakridge-buildnode/build/distributions/` - 2. Right-click `osh-node-oscar-1.0.zip`. + 2. Right-click `osh-node-oscar-.zip` (where `` is the current release version, e.g. `3.0.0`). 3. Select **Extract All..** 4. Choose your destination, (or leave the default) and extract. -1. Launch the OSH node: - Run the launch script, "launch.sh" for linux/mac and "launch.bat" for windows. +1. Launch the OSH node and PostGIS Database: + The database management system is handled through Docker. The default launch scripts automatically build and run a PostGIS container using the `Dockerfile` located in `dist/release/postgis`, and then start the OSH node. + Run the launch script, `launch-all.sh` (or `launch.sh` within the `osh-node-oscar` folder directly if the database is already running) for linux, `launch-all-arm.sh` (or `launch-arm.sh` if it exists) for mac, and `launch-all.bat` (or `launch.bat`) for windows. 2. Access the OSH Node - Remote: **[ip-address]:8282/sensorhub/admin** - Locally: **http://localhost:8282/sensorhub/admin** -The default credentials to access the OSH Node are admin:admin. This can be changed in the Security section of the admin page. +### First-Time Setup +On first boot, OSCAR enters an **Uninitialized State** and requires configuration via a Setup Wizard. +1. Navigate to `http://localhost:8282/` or `http://localhost:8282/sensorhub/admin`. +2. You will be automatically redirected to the **Setup Wizard**. +3. **Create an Admin Password**: Set a strong password for the `admin` account. +4. **Configure TOTP**: + - Scan the displayed QR code with an authenticator app (Google Authenticator, Authy, etc.). + - **Important**: Save the secret key shown in the wizard! + - Use the **Test Code** form to verify your setup before proceeding. +5. Once complete, you will be redirected to the Admin UI login. + +### Logging In +After initialization, use the following credentials: +- **Username**: `admin` +- **Password**: The password you set during the Setup Wizard. +- **Two-Factor Authentication**: + - If your browser or client supports it, enter your password as usual and provide the 6-digit TOTP code when prompted. + - If you are prompted for a single login by the browser and can't provide a TOTP code separately, enter your password followed by a colon and the code (e.g., `mypassword:123456`). + +**Language Selection** +The user can select different languages for the Admin UI by using the language drop-down menu located in the top right corner of the Admin UI toolbar. Selecting a new language will instantly switch the UI localization. + +**Two-Factor Authentication (2FA)** +2FA is mandatory for the administrator account and can be configured for other users to add an extra layer of security. To set this up for additional users: +1. Log in to the Admin UI as `admin`. +2. Navigate to the **Security** section. +3. Edit a user profile and set up Two-Factor Authentication. A popup window will appear with a QR code generated locally on the server. +4. Scan the QR code with an authenticator app (like Google Authenticator or Authy) to complete the setup. + +**Importing/Exporting Lane Configurations via CSV** +Configurations for Lane Systems can be bulk managed via spreadsheet (CSV). +1. Log in to the Admin UI. +2. Navigate to **Services -> OSCAR Service**. +3. Within the configuration form for the OSCAR service, locate the property for spreadsheet configuration (`spreadsheetConfigPath`). +4. To export, click the download button to retrieve the current configurations as a CSV file. +5. To import, upload your modified CSV file through the provided upload mechanism in the service configuration to apply new or updated lane setups. For documentation on configuring a Lane System on the OSH Admin panel, please refer to the OSCAR Documentation provided in the Google Drive documentation folder. + +## Progressive Web App (PWA) +The OSCAR Viewer can now be installed as a Progressive Web App (PWA) on compatible devices (mobile, tablet, and desktop) for offline-capable or app-like experiences. +To install it: +1. Navigate to the OSCAR Viewer in a supported browser (e.g., Chrome, Safari). +2. Look for the "Install App" or "Add to Home Screen" option in the browser menu. + +## WebID Analysis and Spectroscopic QR Scanning +The OSCAR Viewer now features integrated Spectroscopic QR Code scanning for WebID analysis in the Adjudication workflows. +- During an adjudication, users can open the **QR Scanner** to scan spectroscopic QR codes via their device camera. +- Scanned items can be configured with a Detector Response Function (DRF) or used to synthesize background data. +- The system parses the scanned QR code to perform WebID Analysis, displaying results in the **WebID Analysis Results Log** within the adjudication panel. +- All WebID UI elements are localized and adapt to the user's selected language. + ## Deploy the Client After configuring the Lanes on the OSH Admin Panel, you can navigate to the Clients endpoint: - Remote: **[ip-address]:8282** @@ -73,6 +126,12 @@ After configuring the Lanes on the OSH Admin Panel, you can navigate to the Clie For documentation on configuring a server on the OSCAR Client refer to the OSCAR Documentation provided in the Google Drive documentation folder. +## Security and Federation +- [Security Architecture](SECURITY_ARCHITECTURE.md) +- [System Architecture](SYSTEM_ARCHITECTURE.md) +- [Federation Provisioning (API Keys)](docs/FEDERATION_PROVISIONING.md) +- [Tailscale Security and Configuration](docs/TAILSCALE_CONFIGURATION.md) + # Release Checklist - Version in `build.gradle` - Version in `dist/config/standard/config.json` diff --git a/SECURITY_ARCHITECTURE.md b/SECURITY_ARCHITECTURE.md new file mode 100644 index 0000000..6e170ae --- /dev/null +++ b/SECURITY_ARCHITECTURE.md @@ -0,0 +1,52 @@ +# OSCAR Security Hardening Architecture + +**Critical Domain Context:** +This project is an Open Source Central Alarm Station (OSCAR) monitoring radiation portal monitors. The application runs cross-platform on Windows, macOS, and Linux. The primary critical threat is the unauthorized suppression, modification, or spoofing of alarms. Note this specific nomenclature: +* **G Alarm:** Gamma Alarm. +* **N Alarm:** Neutron Alarm. +* **G-N:** Gamma Neutron Alarm. + +**OpenSensorHub (OSH) Ecosystem Constraint:** +OSCAR is built on the OpenSensorHub framework. **Under no circumstances may any code modifications break compatibility with the larger OSH ecosystem.** * Standard OGC SWE, SOS, and SPS API endpoints must remain fully compliant. +* Sensor drivers (e.g., video processing, hardware interfaces mapped in `config.csv`) must not be prevented from initializing or communicating. +* Machine-to-machine API routes cannot rely on human-interactive authentication (like 302 redirects to a TOTP login). + +**Global Build Constraint:** +Whenever generating or modifying Dockerfiles for this project, you MUST ensure the font package is explicitly set to `fonts-freefont-ttf`. This is strictly required to prevent downstream rendering failures in the application's graphical reporting components. + +## Database Security Implementation + +### SCRAM-SHA-256 Authentication +PostgreSQL is configured to enforce `scram-sha-256` authentication for all database users. This is initialized during the PostGIS container setup via `POSTGRES_INITDB_ARGS`. + +### Docker Secrets for Database Credentials +The system uses Docker Secrets (via bind mounts) to manage database passwords. +- **Injected Secret Path**: `/run/secrets/db_password` within the container. +- **Environment Variable**: `POSTGRES_PASSWORD_FILE` points to this secret path. +- **Backend Priority**: The OSH Java backend is architected to prioritize the `POSTGRES_PASSWORD_FILE` environment variable during initialization, overriding any plaintext credentials in `config.json`. + +### Configurable Networking and TLS +- **DB Host**: The database host is configurable via the `DB_HOST` environment variable (default: `localhost`), enabling secure deployment on separate LAN machines. +- **TLS Enforcement**: All connections from the OSH backend to PostGIS are secured over TLS. This is enforced by using `sslmode=require` in the JDBC connection string in the `ConnectionManager`. + +## Application-Level Security Hardening + +### Persistent Local CA and TLS Certificates +On first boot, the system generates a persistent Root CA and a leaf TLS certificate. +- **Root CA Private Key**: Securely stored within the PKCS12 keystore (`osh-keystore.p12`) under the alias `root-ca`. This allows for automated, silent renewal of leaf certificates. +- **Lifespan**: The Root CA is generated with a 20-year lifespan, while the Leaf certificate has a 1-year lifespan. +- **Automated Renewal**: Upon each startup, the system checks the expiration of the active Leaf certificate. If it expires within 30 days, a new Leaf certificate is automatically generated and signed by the persistent Root CA. +- **Leaf Certificate**: Stored in the same PKCS12 keystore (`osh-keystore.p12`) under the alias `jetty`. +- **Key Storage Security**: The keystore password is automatically generated and stored in a hidden `.app_secrets` file. Access to this file and the keystore is restricted to the executing user using POSIX permissions (Linux/macOS) or ACLs (Windows). The system implements a "fail-secure" startup policy: if `.app_secrets` is missing, the application will halt with a critical error rather than falling back to default passwords. +- **Public CA Download**: The public Root CA certificate is available for download at `/sensorhub/admin/ca-cert` to allow clients to establish trust. + +### Setup Wizard and Credential Management +The system does not ship with default administrative credentials. +- **Uninitialized State**: If the system detects that it has not been configured (no admin password set), it enters an uninitialized state. +- **Mandatory Redirection**: In the uninitialized state, all requests to the root URL or Admin UI are redirected to a Setup Wizard. +- **Initialization**: The Setup Wizard forces the creation of a strong admin password (hashed using PBKDF2) and initializes the TOTP 2FA seed. +- **Bifurcated Authentication (TOTP + API Keys)**: OSCAR implements two distinct authentication flows to ensure secure human access and robust machine-to-machine (M2M) communication. + - **Human UI Routes (/, /admin, /VAADIN, /setup)**: Require a valid session. If unauthenticated, the user is redirected (302) to `/sensorhub/login`. Login requires username, password, and TOTP. Validated 2FA sessions are bridged across contexts using `BridgedAuthenticator`. + - **Machine/API Routes (SOS, SPS, WebSockets)**: Secured via long-lived API Keys. Keys are provided via `Authorization: Bearer ` or `X-API-Key` headers. These routes return standard `401/403` status codes instead of redirects if authentication fails. +- **API Key Management**: Administrators can generate, view (once), and revoke API keys via the Admin UI. Keys are hashed (PBKDF2) before storage. +- **Secure Provisioning**: Automated utilities (`provision-node.sh` and `provision-node.bat`) are provided for secure key distribution via Tailscale. See [Federation Provisioning](docs/FEDERATION_PROVISIONING.md) and [Tailscale Configuration](docs/TAILSCALE_CONFIGURATION.md) for detailed instructions and security requirements. diff --git a/SYSTEM_ARCHITECTURE.md b/SYSTEM_ARCHITECTURE.md new file mode 100644 index 0000000..be9bab0 --- /dev/null +++ b/SYSTEM_ARCHITECTURE.md @@ -0,0 +1,54 @@ +# OSCAR System Architecture + +## Overview +OSCAR (Open Source Central Alarm Station) is a monitoring system for radiation portal monitors based on the OpenSensorHub (OSH) framework. + +## Data Flow Diagram +![OSCAR System Data Flow](docs/system_data_flow.svg) + +## Component Network Flow and Ports + +### Components: +- **OSH Backend**: Java-based core application. +- **PostGIS Database**: PostgreSQL with PostGIS extensions for persistent storage. +- **Client Web UI**: React/Frontend viewer. + +### Default Port Configuration: +- **OSH Backend API (HTTP)**: `8282` +- **OSH Backend Admin UI**: `8282` +- **PostGIS Database**: `5432` +- **MQTT Server (HiveMQ)**: WebSockets on `/mqtt` (via proxy on port `8282`) + +### Network Flows: +- **Client to OSH**: Clients interact with OSH through its REST API and Web UI on port `8282`. The client is now progressive web app (PWA) compatible and can be installed locally via a modern web browser. +- **Client Features**: The progressive web application contains specialized functionality such as offline caching, client-side WebID analysis, and camera integration for Spectroscopic QR Code scanning during Adjudication workflows. +- **OSH to PostGIS**: The OSH backend connects to the PostGIS database over the network (local or LAN) on port `5432`. This connection is secured via TLS and authenticated with SCRAM-SHA-256. +- **Certificate Management**: OSH manages its own internal PKI. On first boot, a 20-year Root CA and a 1-year Leaf certificate are generated and stored in `osh-keystore.p12`. The system automatically renews the Leaf certificate if it is within 30 days of expiration during the boot sequence. + +## Deployment and Lifecycle Commands + +### Main Launch Scripts: +Located in `dist/release/`: +- `launch-all.sh`: Starts the PostGIS container and the OSH backend (Linux/macOS). +- `launch-all-arm.sh`: Starts the PostGIS container and the OSH backend (ARM64, e.g., Mac M1/M2/M3). +- `launch-all.bat`: Starts the PostGIS container and the OSH backend (Windows). + +### Automated Provisioning Utilities: +Located in the repository root: +- `provision-node.sh`: Securely pushes an API key to a remote node via Tailscale (Unix/Linux/macOS). +- `provision-node.bat`: Securely pushes an API key to a remote node via Tailscale (Windows). + +See [Federation Provisioning](docs/FEDERATION_PROVISIONING.md) and [Tailscale Configuration](docs/TAILSCALE_CONFIGURATION.md) for detailed setup and usage instructions. + +### Standalone Database Scripts: +Located in `dist/release/postgis/`: +- `run-postgis.sh`: Starts the PostGIS container independently (Linux/macOS). +- `run-postgis-arm.sh`: Starts the PostGIS container independently (ARM64). +- `run-postgis.bat`: Starts the PostGIS container independently (Windows). + +## Database Utilities +Cross-platform scripts are provided in the repository root for maintenance: +- `backup.sh/bat`: Safely creates a database dump. +- `restore.sh/bat`: Restores the database from a dump. + +These utilities respect the `DB_HOST` and `POSTGRES_PASSWORD_FILE` environment variables. diff --git a/backup.bat b/backup.bat new file mode 100644 index 0000000..8c62a77 --- /dev/null +++ b/backup.bat @@ -0,0 +1,32 @@ +@echo off +setlocal enabledelayedexpansion + +if "%DB_HOST%"=="" (set DB_HOST=localhost) +set DB_NAME=gis +set DB_USER=postgres + +if "%POSTGRES_PASSWORD_FILE%"=="" ( + echo Error: POSTGRES_PASSWORD_FILE environment variable is not set. + exit /b 1 +) + +if not exist "%POSTGRES_PASSWORD_FILE%" ( + echo Error: Password file %POSTGRES_PASSWORD_FILE% does not exist. + exit /b 1 +) + +set /p PGPASSWORD=<"%POSTGRES_PASSWORD_FILE%" + +set TIMESTAMP=%date:~10,4%%date:~4,2%%date:~7,2%_%time:~0,2%%time:~3,2%%time:~6,2% +set TIMESTAMP=%TIMESTAMP: =0% +set BACKUP_FILE=backup_%TIMESTAMP%.dump + +echo Backing up database %DB_NAME% from %DB_HOST%... +pg_dump -h %DB_HOST% -U %DB_USER% -d %DB_NAME% -F c -f "%BACKUP_FILE%" + +if %errorlevel% equ 0 ( + echo Backup completed successfully: %BACKUP_FILE% +) else ( + echo Backup failed. + exit /b 1 +) diff --git a/backup.sh b/backup.sh new file mode 100755 index 0000000..cf8f55e --- /dev/null +++ b/backup.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +DB_HOST="${DB_HOST:-localhost}" +DB_NAME="gis" +DB_USER="postgres" + +if [ -z "$POSTGRES_PASSWORD_FILE" ]; then + echo "Error: POSTGRES_PASSWORD_FILE environment variable is not set." + exit 1 +fi + +if [ ! -f "$POSTGRES_PASSWORD_FILE" ]; then + echo "Error: Password file $POSTGRES_PASSWORD_FILE does not exist." + exit 1 +fi + +export PGPASSWORD=$(cat "$POSTGRES_PASSWORD_FILE") + +echo "Backing up database $DB_NAME from $DB_HOST..." +pg_dump -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -F c -f "backup_$(date +%Y%m%d_%H%M%S).dump" + +if [ $? -eq 0 ]; then + echo "Backup completed successfully." +else + echo "Backup failed." + exit 1 +fi diff --git a/build.gradle b/build.gradle index 3f4a802..20aad5e 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ apply from: gradle.oshCoreDir + '/common.gradle' description = '' allprojects { - version = "3.0.0-rc.5" + version = "3.0.0" } subprojects { diff --git a/changelog.md b/changelog.md index 4564fab..10dfc4c 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,22 @@ # OSCAR Build Node Change Log All notable changes to this project will be documented in this file. +## [Unreleased] +### Added +- Added Progressive Web App (PWA) capabilities, allowing the client to be installed as a local application with offline support. +- Integrated Spectroscopic QR Code scanning for Adjudication workflows. +- Added WebID analysis and result logging to the Adjudication Detail view. + +## 3.0.0 2026-02-04 +This is the official first release of 3.0.0 +### Changes +- Data from database is purged regularly with "daily files" exported at midnight +- Added internationalization (i18n) to the frontend +- Sorted lanes by alphanumeric order in the frontend dashboard +- Use server-side filters in frontend tables +### Fixed +- Fixed issue where database is queried everytime Admin UI is loaded + ## 3.0.0-rc.5 2025-12-11 ### Changes - Improved pagination speed on large datasets diff --git a/dist/config/standard/config.json b/dist/config/standard/config.json index a88eea6..18b41a6 100644 --- a/dist/config/standard/config.json +++ b/dist/config/standard/config.json @@ -27,9 +27,7 @@ "roles": [ "admin" ], - "allow": [ - "fileserver[af72442c-1ce6-4baa-a126-ed41dda26910]" - ], + "allow": [], "deny": [] }, { @@ -94,7 +92,7 @@ "uiClass": "com.botts.ui.oscar.forms.OSCARServiceForm" } ], - "deploymentName": "OSCAR 3.0.0-rc.5", + "deploymentName": "OSCAR 3.0.0", "enableLandingPage": false, "id": "5cb05c9c-9123-4fa1-8731-ffaa51489678", "autoStart": true, @@ -158,7 +156,8 @@ "initialBuckets": [ "sitemap", "reports", - "videos" + "videos", + "adjudication" ], "fileStoreRootDir": "files", "endPoint": "/buckets", @@ -174,7 +173,7 @@ "url": "localhost:5432", "dbName": "gis", "login": "postgres", - "password": "postgres", + "password": "", "idProviderType": "SEQUENTIAL", "autoCommitPeriod": 10, "useBatch": false, diff --git a/dist/release/launch-all-arm.sh b/dist/release/launch-all-arm.sh index 96c1cc5..a14b151 100755 --- a/dist/release/launch-all-arm.sh +++ b/dist/release/launch-all-arm.sh @@ -1,6 +1,6 @@ #!/bin/bash -HOST=localhost +HOST="${DB_HOST:-localhost}" DB_NAME=gis DB_USER=postgres RETRY_MAX=20 @@ -8,6 +8,16 @@ RETRY_INTERVAL=5 PROJECT_DIR="$(pwd)" # Store the original directory CONTAINER_NAME=oscar-postgis-container +# Set up DB password secret +if [ -z "$POSTGRES_PASSWORD_FILE" ]; then + export POSTGRES_PASSWORD_FILE="${PROJECT_DIR}/.db_password" +fi + +if [ ! -f "$POSTGRES_PASSWORD_FILE" ]; then + echo "Generating new database password..." + openssl rand -base64 32 > "$POSTGRES_PASSWORD_FILE" +fi + #sudo docker rm -f "$CONTAINER_NAME" 2>/dev/null || true # Create pgdata directory if needed @@ -48,10 +58,11 @@ else --name $CONTAINER_NAME \ -e POSTGRES_DB=$DB_NAME \ -e POSTGRES_USER=$DB_USER \ - -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_PASS=$(cat "$POSTGRES_PASSWORD_FILE") \ -e DATADIR=/var/lib/postgresql/data \ -p 5432:5432 \ -v "$(pwd)/pgdata:/var/lib/postgresql/data" \ + -v "$POSTGRES_PASSWORD_FILE:/run/secrets/db_password" \ -d \ oscar-postgis-arm || { echo "Failed to start PostGIS container"; exit 1; } fi @@ -60,16 +71,24 @@ fi echo "Waiting for PostGIS ARM64 (PostgreSQL) to be ready..." RETRY_COUNT=0 -export PGPASSWORD=postgres # Needed for pg_isready with password - -until docker exec "$CONTAINER_NAME" pg_isready -U "$DB_USER" -d "$DB_NAME" > /dev/null 2>&1; do +until docker exec -u "$DB_USER" "$CONTAINER_NAME" pg_isready -d "$DB_NAME" > /dev/null 2>&1; do echo "PostGIS not ready yet, retrying..." sleep "${RETRY_INTERVAL}" done echo "PostGIS (PostgreSQL) is ready! Please wait for OpenSensorHub to start..." -sleep 10 +sleep 30 + +# Final check +until docker exec -u "$DB_USER" "$CONTAINER_NAME" pg_isready -d "$DB_NAME" > /dev/null 2>&1; do + echo "PostGIS still restarting, waiting..." + sleep 5 +done + +# Export for OSH backend +export DB_HOST="$HOST" +export POSTGRES_PASSWORD_FILE="$POSTGRES_PASSWORD_FILE" # Launch osh-node-oscar cd "$PROJECT_DIR/osh-node-oscar" || { echo "Error: osh-node-oscar not found"; exit 1; } diff --git a/dist/release/launch-all.bat b/dist/release/launch-all.bat index 5c93081..1daae2e 100755 --- a/dist/release/launch-all.bat +++ b/dist/release/launch-all.bat @@ -2,7 +2,7 @@ setlocal enabledelayedexpansion REM ==== CONFIG ==== -set HOST=localhost +if "%DB_HOST%"=="" (set HOST=localhost) else (set HOST=%DB_HOST%) set PORT=5432 set DB_NAME=gis set USER=postgres @@ -14,6 +14,16 @@ set IMAGE_NAME=oscar-postgis echo PROJECT_DIR is: %PROJECT_DIR% +REM Set up DB password secret +if "%POSTGRES_PASSWORD_FILE%"=="" (set "POSTGRES_PASSWORD_FILE=%PROJECT_DIR%\.db_password") + +if not exist "%POSTGRES_PASSWORD_FILE%" ( + echo Generating new database password... + powershell -Command "$p = New-Object byte[] 32; (New-Object System.Security.Cryptography.RNGCryptoServiceProvider).GetBytes($p); $pwd = [Convert]::ToBase64String($p); [System.IO.File]::WriteAllText(\"%POSTGRES_PASSWORD_FILE:\=\\%\", $pwd)" +) + +set /p DB_PASSWORD=<"%POSTGRES_PASSWORD_FILE%" + where docker >nul 2>&1 if %errorlevel% neq 0 ( echo ERROR: Docker is not installed or not in PATH. @@ -61,9 +71,10 @@ if defined CONTAINER_EXISTS ( --name %CONTAINER_NAME% ^ -e POSTGRES_DB=%DB_NAME% ^ -e POSTGRES_USER=%USER% ^ - -e POSTGRES_PASSWORD=postgres ^ + -e POSTGRES_PASSWORD_FILE=/run/secrets/db_password ^ -p %PORT%:5432 ^ -v "%PROJECT_DIR%\pgdata:/var/lib/postgresql/data" ^ + -v "%POSTGRES_PASSWORD_FILE%:/run/secrets/db_password" ^ -d ^ %IMAGE_NAME% @@ -78,7 +89,7 @@ echo Waiting for PostGIS database to become ready... set RETRY_COUNT=0 :wait_loop -docker exec %CONTAINER_NAME% pg_isready -U %USER% -d %DB_NAME% >nul 2>&1 +docker exec -u %USER% %CONTAINER_NAME% pg_isready -d %DB_NAME% >nul 2>&1 if %errorlevel% equ 0 ( echo Received OK from PostGIS. Please wait for initialization... goto after_wait @@ -97,10 +108,25 @@ goto wait_loop :after_wait -timeout /t 10 >nul +timeout /t 30 >nul + +:final_wait_loop +docker exec -u %USER% %CONTAINER_NAME% pg_isready -d %DB_NAME% >nul 2>&1 +if %errorlevel% equ 0 ( + goto after_final_wait +) +echo PostGIS still restarting, waiting... +timeout /t 5 >nul +goto final_wait_loop + +:after_final_wait echo PostGIS database is ready! +REM Export for OSH backend +set DB_HOST=%HOST% +set POSTGRES_PASSWORD_FILE=%POSTGRES_PASSWORD_FILE% + cd "%PROJECT_DIR%\osh-node-oscar" if %errorlevel% neq 0 ( echo ERROR: osh-node-oscar directory not found. diff --git a/dist/release/launch-all.sh b/dist/release/launch-all.sh index 5716c7c..9ca8e4f 100755 --- a/dist/release/launch-all.sh +++ b/dist/release/launch-all.sh @@ -1,6 +1,6 @@ #!/bin/bash -HOST="localhost" +HOST="${DB_HOST:-localhost}" PORT="5432" DB_NAME="gis" DB_USER="postgres" @@ -9,6 +9,16 @@ RETRY_INTERVAL=5 PROJECT_DIR="$(pwd)" # Store the original directory CONTAINER_NAME="oscar-postgis-container" +# Set up DB password secret +if [ -z "$POSTGRES_PASSWORD_FILE" ]; then + export POSTGRES_PASSWORD_FILE="${PROJECT_DIR}/.db_password" +fi + +if [ ! -f "$POSTGRES_PASSWORD_FILE" ]; then + echo "Generating new database password..." + openssl rand -base64 32 > "$POSTGRES_PASSWORD_FILE" +fi + #docker rm -f "$CONTAINER_NAME" 2>/dev/null || true # Create pgdata directory if needed @@ -51,9 +61,10 @@ else --name "$CONTAINER_NAME" \ -e POSTGRES_DB="$DB_NAME" \ -e POSTGRES_USER="$DB_USER" \ - -e POSTGRES_PASSWORD="postgres" \ + -e POSTGRES_PASSWORD_FILE="/run/secrets/db_password" \ -p $PORT:5432 \ -v "${PROJECT_DIR}/pgdata:/var/lib/postgresql/data" \ + -v "$POSTGRES_PASSWORD_FILE:/run/secrets/db_password" \ -d \ oscar-postgis || { echo "Failed to start PostGIS container"; exit 1; } fi @@ -62,16 +73,24 @@ fi echo "Waiting for PostGIS (PostgreSQL) to be ready..." RETRY_COUNT=0 -export PGPASSWORD=postgres # Needed for pg_isready with password - -until docker exec "$CONTAINER_NAME" pg_isready -U "$DB_USER" -d "$DB_NAME" > /dev/null 2>&1; do +until docker exec -u "$DB_USER" "$CONTAINER_NAME" pg_isready -d "$DB_NAME" > /dev/null 2>&1; do echo "PostGIS not ready yet, retrying..." sleep "${RETRY_INTERVAL}" done echo "PostGIS (PostgreSQL) is ready! Please wait for OpenSensorHub to start..." -sleep 10 +sleep 30 + +# Final check +until docker exec -u "$DB_USER" "$CONTAINER_NAME" pg_isready -d "$DB_NAME" > /dev/null 2>&1; do + echo "PostGIS still restarting, waiting..." + sleep 5 +done + +# Export for OSH backend +export DB_HOST="$HOST" +export POSTGRES_PASSWORD_FILE="$POSTGRES_PASSWORD_FILE" # Launch osh-node-oscar cd "$PROJECT_DIR/osh-node-oscar" || { echo "Error: osh-node-oscar not found"; exit 1; } diff --git a/dist/release/postgis/Dockerfile b/dist/release/postgis/Dockerfile index ce645c7..090489a 100644 --- a/dist/release/postgis/Dockerfile +++ b/dist/release/postgis/Dockerfile @@ -1,5 +1,14 @@ FROM postgis/postgis:16-3.4 +# Install fonts as required by AI_CONTRIBUTING_RULES.md +RUN apt-get update && apt-get install -y fonts-freefont-ttf && rm -rf /var/lib/apt/lists/* + COPY init-extensions.sql /docker-entrypoint-initdb.d/init-extensions.sql -ENV POSTGRES_INITDB_ARGS="-c max_parallel_workers_per_gather=0 -c max_parallel_workers=0" \ No newline at end of file +# Generate self-signed certificate for SSL support +RUN openssl req -new -x509 -days 365 -nodes -text -out /var/lib/postgresql/server.crt \ + -keyout /var/lib/postgresql/server.key -subj "/CN=oscar-postgis" \ + && chmod 600 /var/lib/postgresql/server.key \ + && chown postgres:postgres /var/lib/postgresql/server.key /var/lib/postgresql/server.crt + +ENV POSTGRES_INITDB_ARGS="--auth-local=trust --auth-host=scram-sha-256 -c max_parallel_workers_per_gather=0 -c max_parallel_workers=0 -c ssl=on -c ssl_cert_file=/var/lib/postgresql/server.crt -c ssl_key_file=/var/lib/postgresql/server.key" \ No newline at end of file diff --git a/dist/release/postgis/Dockerfile-arm64 b/dist/release/postgis/Dockerfile-arm64 index becfa20..4efddd7 100644 --- a/dist/release/postgis/Dockerfile-arm64 +++ b/dist/release/postgis/Dockerfile-arm64 @@ -1,7 +1,14 @@ FROM kartoza/postgis:16-3.4 -RUN echo "host all all all md5" >> /etc/postgresql/16/main/pg_hba.conf +# Install fonts as required by AI_CONTRIBUTING_RULES.md +RUN apt-get update && apt-get install -y fonts-freefont-ttf && rm -rf /var/lib/apt/lists/* COPY init-extensions.sql /docker-entrypoint-initdb.d/init-extensions.sql -ENV POSTGRES_INITDB_ARGS="-c max_parallel_workers_per_gather=0 -c max_parallel_workers=0" \ No newline at end of file +# Generate self-signed certificate for SSL support +RUN openssl req -new -x509 -days 365 -nodes -text -out /var/lib/postgresql/server.crt \ + -keyout /var/lib/postgresql/server.key -subj "/CN=oscar-postgis" \ + && chmod 600 /var/lib/postgresql/server.key \ + && chown postgres:postgres /var/lib/postgresql/server.key /var/lib/postgresql/server.crt + +ENV POSTGRES_INITDB_ARGS="--auth-local=trust --auth-host=scram-sha-256 -c max_parallel_workers_per_gather=0 -c max_parallel_workers=0 -c ssl=on -c ssl_cert_file=/var/lib/postgresql/server.crt -c ssl_key_file=/var/lib/postgresql/server.key" \ No newline at end of file diff --git a/dist/release/postgis/init-extensions.sql b/dist/release/postgis/init-extensions.sql index 796b363..50f33ee 100644 --- a/dist/release/postgis/init-extensions.sql +++ b/dist/release/postgis/init-extensions.sql @@ -1,4 +1,8 @@ ALTER SYSTEM SET max_connections = 1024; +ALTER SYSTEM SET ssl = 'on'; +ALTER SYSTEM SET ssl_cert_file = '/var/lib/postgresql/server.crt'; +ALTER SYSTEM SET ssl_key_file = '/var/lib/postgresql/server.key'; + \connect gis; CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE EXTENSION IF NOT EXISTS btree_gist; diff --git a/dist/release/postgis/run-postgis-arm.sh b/dist/release/postgis/run-postgis-arm.sh index 62ff5b6..987aa40 100755 --- a/dist/release/postgis/run-postgis-arm.sh +++ b/dist/release/postgis/run-postgis-arm.sh @@ -5,13 +5,26 @@ if [ ! -d "$(pwd)/pgdata" ]; then mkdir -p "$(pwd)/pgdata" fi +# Set up DB password secret +PROJECT_DIR="$(pwd)" +if [ -z "$POSTGRES_PASSWORD_FILE" ]; then + export POSTGRES_PASSWORD_FILE="${PROJECT_DIR}/.db_password" +fi + +if [ ! -f "$POSTGRES_PASSWORD_FILE" ]; then + echo "Generating new database password..." + openssl rand -base64 32 > "$POSTGRES_PASSWORD_FILE" +fi + docker build . --file=Dockerfile-arm64 --tag=oscar-postgis-arm docker run \ -e PG_MAX_CONNECTIONS=500 \ -e POSTGRES_DB=gis \ -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_PASS=$(cat "$POSTGRES_PASSWORD_FILE") \ -e DATADIR=/var/lib/postgresql/data \ -p 5432:5432 \ -v "$(pwd)/pgdata:/var/lib/postgresql/data" \ + -v "$POSTGRES_PASSWORD_FILE:/run/secrets/db_password" \ + -d \ oscar-postgis-arm \ No newline at end of file diff --git a/dist/release/postgis/run-postgis.bat b/dist/release/postgis/run-postgis.bat index f7ae6ad..d09ddb6 100644 --- a/dist/release/postgis/run-postgis.bat +++ b/dist/release/postgis/run-postgis.bat @@ -5,6 +5,14 @@ if not exist "%cd%\pgdata" ( mkdir "%cd%\pgdata" ) +# Set up DB password secret +if "%POSTGRES_PASSWORD_FILE%"=="" (set POSTGRES_PASSWORD_FILE=%cd%\.db_password) + +if not exist "%POSTGRES_PASSWORD_FILE%" ( + echo Generating new database password... + powershell -Command "$p = New-Object byte[] 32; (New-Object System.Security.Cryptography.RNGCryptoServiceProvider).GetBytes($p); $pwd = [Convert]::ToBase64String($p); [System.IO.File]::WriteAllText('%POSTGRES_PASSWORD_FILE%', $pwd)" +) + docker build . --tag=oscar-postgis docker run ^ @@ -13,7 +21,9 @@ docker run ^ -e PG_MAX_CONNECTIONS=500 ^ -e POSTGRES_DB=gis ^ -e POSTGRES_USER=postgres ^ - -e POSTGRES_PASSWORD=postgres ^ + -e POSTGRES_PASSWORD_FILE=/run/secrets/db_password ^ -p 5432:5432 ^ -v "%cd%\pgdata:/var/lib/postgresql/data" ^ + -v "%POSTGRES_PASSWORD_FILE%:/run/secrets/db_password" ^ + -d ^ oscar-postgis \ No newline at end of file diff --git a/dist/release/postgis/run-postgis.sh b/dist/release/postgis/run-postgis.sh index 4b47008..4ff1726 100755 --- a/dist/release/postgis/run-postgis.sh +++ b/dist/release/postgis/run-postgis.sh @@ -5,12 +5,25 @@ if [ ! -d "$(pwd)/pgdata" ]; then mkdir -p "$(pwd)/pgdata" fi +# Set up DB password secret +PROJECT_DIR="$(pwd)" +if [ -z "$POSTGRES_PASSWORD_FILE" ]; then + export POSTGRES_PASSWORD_FILE="${PROJECT_DIR}/.db_password" +fi + +if [ ! -f "$POSTGRES_PASSWORD_FILE" ]; then + echo "Generating new database password..." + openssl rand -base64 32 > "$POSTGRES_PASSWORD_FILE" +fi + docker build . --file=Dockerfile --tag=oscar-postgis docker run \ -e PG_MAX_CONNECTIONS=1024 \ -e POSTGRES_DB=gis \ -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_PASSWORD_FILE="/run/secrets/db_password" \ -p 5432:5432 \ -v "$(pwd)/pgdata:/var/lib/postgresql/data" \ + -v "$POSTGRES_PASSWORD_FILE:/run/secrets/db_password" \ + -d \ oscar-postgis diff --git a/dist/scripts/standard/launch.bat b/dist/scripts/standard/launch.bat index 52eb997..b672242 100755 --- a/dist/scripts/standard/launch.bat +++ b/dist/scripts/standard/launch.bat @@ -2,38 +2,61 @@ setlocal enabledelayedexpansion +REM Persistent CA Check & Generation +REM If keystore doesn't exist, this will generate it and create .app_secrets. +REM If it does exist, it will check for auto-renewal of the leaf certificate. +java -cp "lib/*" com.botts.impl.security.LocalCAUtility + +if exist ".app_secrets" ( + set /p KEYSTORE_PASSWORD=<.app_secrets + REM Use the same auto-generated secret for the truststore if not provided + if "%TRUSTSTORE_PASSWORD%"=="" ( + set "TRUSTSTORE_PASSWORD=%KEYSTORE_PASSWORD%" + ) +) else ( + echo CRITICAL ERROR: .app_secrets not found. Cannot load keystore password. Halting startup. + exit /b 1 +) + REM Make sure all the necessary certificates are trusted by the system. CALL %~dp0load_trusted_certs.bat set KEYSTORE=.\osh-keystore.p12 set KEYSTORE_TYPE=PKCS12 -set KEYSTORE_PASSWORD=atakatak - set TRUSTSTORE=.\truststore.jks set TRUSTSTORE_TYPE=JKS -set TRUSTSTORE_PASSWORD=changeit - -set INITIAL_ADMIN_PASSWORD_FILE=.\.s +if exist ".\.initial_admin_password" ( + set INITIAL_ADMIN_PASSWORD_FILE=.\.initial_admin_password +) -REM Check if INITIAL_ADMIN_PASSWORD_FILE and INITIAL_ADMIN_PASSWORD are empty -REM Set default password if neither is provided -if "%INITIAL_ADMIN_PASSWORD_FILE%"=="" if "%INITIAL_ADMIN_PASSWORD%"=="" ( - set INITIAL_ADMIN_PASSWORD=admin +REM Database configuration +if "%DB_HOST%"=="" (set DB_HOST=localhost) +if "%POSTGRES_PASSWORD_FILE%"=="" ( + if exist "..\.db_password" ( + for %%i in ("..\.db_password") do set POSTGRES_PASSWORD_FILE=%%~fi + ) else ( + if exist ".\.db_password" ( + for %%i in (".\.db_password") do set POSTGRES_PASSWORD_FILE=%%~fi + ) + ) ) -REM Call the next batch script to handle setting the initial admin password -CALL "%SCRIPT_DIR%set-initial-admin-password.bat" +REM Check if INITIAL_ADMIN_PASSWORD_FILE or INITIAL_ADMIN_PASSWORD are provided +REM If so, call the next batch script to handle setting the initial admin password +if not "%INITIAL_ADMIN_PASSWORD_FILE%"=="" ( + CALL "%SCRIPT_DIR%set-initial-admin-password.bat" +) else ( + if not "%INITIAL_ADMIN_PASSWORD%"=="" ( + CALL "%SCRIPT_DIR%set-initial-admin-password.bat" + ) +) REM Start the node java -Xms6g -Xmx6g -Xss256k -XX:ReservedCodeCacheSize=512m -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError ^ -Dlogback.configurationFile=./logback.xml ^ -cp "lib/*" ^ -Djava.system.class.loader="org.sensorhub.utils.NativeClassLoader" ^ - -Djavax.net.ssl.keyStore="./osh-keystore.p12" ^ - -Djavax.net.ssl.keyStorePassword="atakatak" ^ - -Djavax.net.ssl.trustStore="%~dp0trustStore.jks" ^ - -Djavax.net.ssl.trustStorePassword="changeit" ^ com.botts.impl.security.SensorHubWrapper config.json db diff --git a/dist/scripts/standard/launch.sh b/dist/scripts/standard/launch.sh index 856b549..5f2aeb7 100755 --- a/dist/scripts/standard/launch.sh +++ b/dist/scripts/standard/launch.sh @@ -1,26 +1,52 @@ #!/bin/bash -# Make sure all the necessary certificates are trusted by the system. SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) -"$SCRIPT_DIR/load_trusted_certs.sh" - export KEYSTORE="./osh-keystore.p12" - export KEYSTORE_TYPE=PKCS12 - export KEYSTORE_PASSWORD="atakatak" +# Persistent CA Check & Generation +# If keystore doesn't exist, this will generate it and create .app_secrets. +# If it does exist, it will check for auto-renewal of the leaf certificate. +java -cp "lib/*" com.botts.impl.security.LocalCAUtility + +if [ -f ".app_secrets" ]; then + export KEYSTORE_PASSWORD=$(head -n 1 .app_secrets) + # Use the same auto-generated secret for the truststore if not provided + if [ -z "$TRUSTSTORE_PASSWORD" ]; then + export TRUSTSTORE_PASSWORD="$KEYSTORE_PASSWORD" + fi +else + echo "CRITICAL ERROR: .app_secrets not found. Cannot load keystore password. Halting startup." + exit 1 +fi - export TRUSTSTORE="./truststore.jks" - export TRUSTSTORE_TYPE=JKS - export TRUSTSTORE_PASSWORD="changeit" - export INITIAL_ADMIN_PASSWORD_FILE="./.s" +# Make sure all the necessary certificates are trusted by the system. +"$SCRIPT_DIR/load_trusted_certs.sh" +export KEYSTORE="./osh-keystore.p12" +export KEYSTORE_TYPE=PKCS12 +export TRUSTSTORE="./truststore.jks" +export TRUSTSTORE_TYPE=JKS + + if [ -f "./.initial_admin_password" ]; then + export INITIAL_ADMIN_PASSWORD_FILE="./.initial_admin_password" + fi + +# Database configuration +export DB_HOST="${DB_HOST:-localhost}" +if [ -z "$POSTGRES_PASSWORD_FILE" ]; then + # Check for password file in parent directory (standard for release) or current + if [ -f "../.db_password" ]; then + export POSTGRES_PASSWORD_FILE="$(cd .. && pwd)/.db_password" + elif [ -f "./.db_password" ]; then + export POSTGRES_PASSWORD_FILE="$(pwd)/.db_password" + fi +fi # After copying the default configuration file, also look to see if they # specified what they want the initial admin user's password to be, either # as a secret file or by providing it as an environment variable. -if [ -z "$INITIAL_ADMIN_PASSWORD_FILE" ] && [ -z "$INITIAL_ADMIN_PASSWORD" ]; then - export INITIAL_ADMIN_PASSWORD=admin +if [ ! -z "$INITIAL_ADMIN_PASSWORD_FILE" ] || [ ! -z "$INITIAL_ADMIN_PASSWORD" ]; then + "$SCRIPT_DIR/set-initial-admin-password.sh" fi -"$SCRIPT_DIR/set-initial-admin-password.sh" @@ -29,8 +55,4 @@ java -Xms6g -Xmx6g -Xss256k -XX:ReservedCodeCacheSize=512m -XX:+UseG1GC -XX:+Hea -Dlogback.configurationFile=./logback.xml \ -cp "lib/*" \ -Djava.system.class.loader="org.sensorhub.utils.NativeClassLoader" \ - -Djavax.net.ssl.keyStore="./osh-keystore.p12" \ - -Djavax.net.ssl.keyStorePassword="atakatak" \ - -Djavax.net.ssl.trustStore="$SCRIPT_DIR/trustStore.jks" \ - -Djavax.net.ssl.trustStorePassword="changeit" \ com.botts.impl.security.SensorHubWrapper ./config.json ./db diff --git a/dist/scripts/standard/load_trusted_certs.bat b/dist/scripts/standard/load_trusted_certs.bat index 5357164..f30bb8b 100755 --- a/dist/scripts/standard/load_trusted_certs.bat +++ b/dist/scripts/standard/load_trusted_certs.bat @@ -3,9 +3,11 @@ setlocal echo Building Java trust store... -REM Default password for the sytem trust store is "changeit". Edit this next -REM line if it's something different in your Java installation. -set "STOREPASS=changeit" +if "%TRUSTSTORE_PASSWORD%"=="" ( + echo CRITICAL ERROR: TRUSTSTORE_PASSWORD not set. Cannot load truststore password. Halting startup. + exit /b 1 +) +set "STOREPASS=%TRUSTSTORE_PASSWORD%" REM Get the path of this script. set "SCRIPTDIR=%~dp0" diff --git a/dist/scripts/standard/load_trusted_certs.sh b/dist/scripts/standard/load_trusted_certs.sh index eeb05bd..585a6b1 100755 --- a/dist/scripts/standard/load_trusted_certs.sh +++ b/dist/scripts/standard/load_trusted_certs.sh @@ -2,9 +2,11 @@ echo "Building Java trust store..." -# Default password for the system trust store is "changeit". Edit this next -# line if it's something different in your Java installation. -STOREPASS="changeit" +if [ -z "$TRUSTSTORE_PASSWORD" ]; then + echo "CRITICAL ERROR: TRUSTSTORE_PASSWORD not set. Cannot load truststore password. Halting startup." + exit 1 +fi +STOREPASS="$TRUSTSTORE_PASSWORD" # Get the path of this script. SCRIPTDIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" diff --git a/docs/ADDING_NEW_RADIATION_PORTAL_MONITOR.md b/docs/ADDING_NEW_RADIATION_PORTAL_MONITOR.md new file mode 100644 index 0000000..1dea20e --- /dev/null +++ b/docs/ADDING_NEW_RADIATION_PORTAL_MONITOR.md @@ -0,0 +1,45 @@ +# Adding a New Radiation Portal Monitor System + +This document provides the correct process for adding a new radiation portal monitor system using the Lane Systems sensor. + +## Step 1: Add a New Module +1. Navigate to the admin panel at [localhost:8282/sensorhub/admin](http://localhost:8282/sensorhub/admin). +2. Right-click in the **Sensors** area. +3. Click **Add New Module**. +4. Select the **Lane Systems** module. + +## Step 2: Configure Location and Manufacturer +1. Enter the required **Location Information**. +2. Go to the tab that allows you to enter the radiation portal monitor manufacturer (Rapiscan or Aspect). +3. If you select **Rapiscan**, enter the **IP address** and the **communication port**. + +## Step 3: Add Camera Systems +Add the camera system of your choice by selecting the appropriate option: **Sony**, **Axis**, or **Generic**. + +### Sony or Axis Cameras +- If a Sony or Axis camera is selected, you will be presented with options to enter the **username**, **password**, and **IP address** for the camera. +- **Sony** cameras will additionally have the ability to choose between the **MJPG** and **H.264** video streams. + +### Generic Cameras +- If a Generic camera is selected, you will have the option to enter the **username**, **password**, **IP address**, **port number**, and the **stream URL** (this consists of the information that follows the initial IP address of the camera). + +*Note: More than one camera may be added by selecting to add additional cameras.* + +## Step 4: Save Changes +**Important: The save buttons must be clicked in the specific order described below.** + +1. **Session Save:** After all information has been entered for the radiation portal monitor system, save your changes by clicking the appropriate button on the **right side** of the upper corner of the screen. + *(Note: Saving only via the button on the right side saves the changes **only during the current session** that the node is running in.)* +2. **Persistent Save:** Next, save the changes by clicking the save icon on the **left side** of the screen. + *(Note: Clicking the save icon on the upper left-hand portion of the screen actually saves the configuration into the configuration file, ensuring they will persist after the node is restarted.)* + +## Step 5: Start the Module +1. Right-click on the newly created module in the Sensors area and select **Start**. +2. The module may go through an initial initialization phase where communication is established between the node and each of the sensors. +3. After initialization, the module will start. + *(Note: Modules can also be configured to auto-start when the node is started.)* + +## Step 6: Verify in OSCAR Viewer +Once the module has been successfully added and started: +1. Switch to the Oscar Viewer Dashboard at [localhost:8282](http://localhost:8282). +2. You will often need to **refresh this page** to make the newly added sensors appear. diff --git a/docs/FEDERATION_PROVISIONING.md b/docs/FEDERATION_PROVISIONING.md new file mode 100644 index 0000000..29bc932 --- /dev/null +++ b/docs/FEDERATION_PROVISIONING.md @@ -0,0 +1,42 @@ +# Federation Provisioning with API Keys + +This document describes how to set up federation between OSCAR nodes using the bifurcated authentication system (API Keys). + +## 1. Provisioning an API Key for a Remote Node +To allow a remote OSCAR node (or any OGC/OSH client) to poll data from your node without interactive 2FA: + +1. **Access Admin UI**: Log in to your local OSCAR Admin UI (`/sensorhub/admin`). +2. **Generate Key**: + * Navigate to the **Security** tab. + * Select the user account that the remote node will "act as" (e.g., `admin` or a dedicated service account). + * In the **API Keys** section, click **Generate Key**. + * Give it a name like `Remote-Node-Alpha`. + * **Copy the raw key immediately.** +3. **Distribute Key**: + * Use the `provision-node.sh` or `provision-node.bat` script in the repository root to securely push the key to the remote node over Tailscale: + ```bash + ./provision-node.sh + ``` + * **Note**: Requires Tailscale SSH and Taildrop to be configured. See [Tailscale Security and Configuration](TAILSCALE_CONFIGURATION.md) for details. + +## 2. Configuring your Node to Federate with another Node +If you have been given an API key from another OSCAR node and want to pull data from it: + +1. **Add a Client Module**: + * In your Admin UI, navigate to the **Clients** tab (or **Services** depending on the protocol). + * Add a new client (e.g., **ConSys API Client** or **SOS Client**). +2. **Configure Connection**: + * Set the **Remote Host** or **Endpoint URL** to the target node's address. + * **Username**: Enter the username provided by the remote administrator. + * **Password**: Paste the **API Key** provided by the remote administrator into the password field. +3. **Authentication Mode**: + * Since OSCAR supports bifurcated authentication, using the API Key in the password field will automatically bypass the TOTP requirement for that connection. + * Ensure **Enable TLS** is checked if the remote node uses HTTPS (standard). + +## 3. Advanced: Using API Keys in Headers +Automated tools that do not support Basic Authentication can use the API key directly in HTTP headers: + +* **Header**: `Authorization: Bearer ` +* **OR Custom Header**: `X-API-Key: ` + +This is the preferred method for machine-to-machine polling of SOS/SPS endpoints. diff --git a/docs/TAILSCALE_CONFIGURATION.md b/docs/TAILSCALE_CONFIGURATION.md new file mode 100644 index 0000000..0980f32 --- /dev/null +++ b/docs/TAILSCALE_CONFIGURATION.md @@ -0,0 +1,64 @@ +# Tailscale Security and Configuration for OSCAR Federation + +This document explains the requirements and security considerations for using Tailscale to provision API keys between OSCAR nodes. + +## 1. Tailscale Requirements + +To use the automated provisioning scripts (`provision-node.sh` and `provision-node.bat`), the following Tailscale features must be configured on both the **Central Station** (Source) and the **Federated Node** (Target). + +### A. Taildrop (File Sharing) +The scripts use `tailscale file cp` to transfer the API key. +* **Action**: Ensure Taildrop is enabled in your Tailscale network (Tailnet) settings. +* **Target Node**: Must be online and capable of receiving files. + +### B. Tailscale SSH +The scripts use `tailscale ssh` to move the key into the final configuration directory and set appropriate permissions. +* **Target Node**: Must have Tailscale SSH enabled. + * On Linux: `tailscale up --ssh` + * On Windows: Enabled via the Tailscale UI or CLI. +* **Access Controls (ACLs)**: Your Tailnet ACLs must allow the administrator (Source) to SSH into the Target node. + +### C. Tailnet ACL Configuration +You should restrict access so that only authorized administrators can push keys. Example ACL snippet: +```json +{ + "ssh": [ + { + "action": "accept", + "src": ["group:admin"], + "dst": ["tag:oscar-node"], + "users": ["root", "oscar-user"] + } + ] +} +``` + +## 2. Administrator Responsibilities + +### Within Tailscale +1. **Tagging**: Tag OSCAR nodes (e.g., `tag:oscar-node`) to apply specific security policies. +2. **Key Expiry**: Disable key expiry for long-lived federated nodes or ensure a process is in place to renew node keys. +3. **SSH Policies**: Audit who has SSH access to the nodes via Tailscale. + +### Within OSCAR +1. **API Key ownership**: Assign API keys to service accounts with the **minimum necessary permissions** (Least Privilege). Do not use the primary `admin` account for machine-to-machine federation if possible. +2. **Key Revocation**: If a node is decommissioned or a Tailnet key is compromised, immediately **Revoke** the API key in the OSCAR Admin UI. +3. **Audit Logs**: Monitor OSCAR logs for unusual API activity associated with specific API keys. + +## 3. How the Provisioning Process Works (Technical Flow) + +1. **Local Generation**: The admin generates a random 32-byte API key in the OSCAR Admin UI. +2. **Hash Storage**: OSCAR stores only the PBKDF2 hash of this key. +3. **Secure Transfer**: + * The `provision-node` script writes the raw key to a temporary local file. + * `tailscale file cp` encrypts and transfers the file directly to the target node over the Tailnet (WireGuard). +4. **Remote Placement**: + * `tailscale ssh` executes a command on the target to move the file from the Tailscale "received" folder to `/opt/sensorhub/secrets/api_key` (Linux) or `C:\ProgramData\SensorHub\secrets\api_key` (Windows). + * Permissions are set to `600` (read/write by owner only) to prevent local exposure. +5. **Cleanup**: The temporary local file is deleted immediately. + +## 4. Troubleshooting + +* **"Permission Denied" (SSH)**: Check your Tailscale ACLs and ensure the source user has permission to SSH into the target as the specified user. +* **"File not found"**: Ensure Taildrop is enabled. On some systems, you may need to manually accept the file if Tailscale is not configured to auto-receive. +* **Connection Timeout**: Verify both nodes are logged into the same Tailnet and are visible to each other (`tailscale status`). diff --git a/docs/system_data_flow.svg b/docs/system_data_flow.svg new file mode 100644 index 0000000..8804dbc --- /dev/null +++ b/docs/system_data_flow.svg @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + Radiation Portal Monitors + (RPMs: G/N Alarms) + Protocol: TCP/UDP/CSV + + + IP Cameras + (Video/MJPEG/RTSP) + Protocol: HTTP/RTSP + + + + OSH Backend (Java) + + + Sensor Drivers + (Aspect, Rapiscan, etc.) + + + Processing Modules + (FFmpeg, Lane Logic) + + + OGC Services Layer + - SOS (Observations) + - SPS (Control) + - WebSockets (Live) + + + Auth (Bifurcated) + + + + PostGIS Database + Persistent Storage + G/N Alarms, Events + TLS + SCRAM-SHA-256 + + + + Admin UI (Vaadin) + Config, User Mgmt + Auth: TOTP/Session + + + OSCAR Viewer (React) + Live Maps, Alarms + Auth: TOTP/Session + + + MQTT Proxy + HiveMQ Extension + Auth: API Key + + + + Federated Nodes + Remote Polling + Auth: API Key / Tailscale + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/include/osh-addons b/include/osh-addons index ce66c7f..68fbc88 160000 --- a/include/osh-addons +++ b/include/osh-addons @@ -1 +1 @@ -Subproject commit ce66c7f8aabddbff05ed07c9bbe07fd0c436d24e +Subproject commit 68fbc8878b781d47790ae1b7e8bc48ca9ae7ad27 diff --git a/include/osh-core b/include/osh-core index 7af3a11..7e366fa 160000 --- a/include/osh-core +++ b/include/osh-core @@ -1 +1 @@ -Subproject commit 7af3a119dde5241e19fd94ae459aac192780fc3d +Subproject commit 7e366fa059767348f48c20de623a61101c7ef463 diff --git a/include/osh-oakridge-modules b/include/osh-oakridge-modules index e3b15fc..92c2cb3 160000 --- a/include/osh-oakridge-modules +++ b/include/osh-oakridge-modules @@ -1 +1 @@ -Subproject commit e3b15fce02cd9eeb1d3a4a365ae6451bf03bcf17 +Subproject commit 92c2cb3e7596db912d01f76ec44ab50a38e4be83 diff --git a/oscar_initialization_lifecycle.md b/oscar_initialization_lifecycle.md new file mode 100644 index 0000000..3fcf67b --- /dev/null +++ b/oscar_initialization_lifecycle.md @@ -0,0 +1,92 @@ +# OSCAR System: Initialization and Restart Lifecycle + +This document provides a comprehensive step-by-step detail of the initialization and restart lifecycle of the current working OSCAR hybrid system. It breaks down the exact logic, execution order, and specific mechanisms that orchestrate the PostGIS database, OSH Java backend, and certificate generation, which are critical when migrating to a fully containerized Docker Compose stack. + +## 1. Startup Timing and Service Delays + +### Boot Process and Execution Order +The current startup logic is driven by shell/batch scripts (e.g., `dist/release/launch-all.sh`, `launch-all.bat`), which manage the sequential initialization of components. The execution order is strictly enforced: +1. **Pre-flight Checks & Credentials:** Generates the `.db_password` file if it doesn't exist. +2. **PostGIS Initialization:** Builds the `oscar-postgis` Docker image and launches the database container. +3. **Wait-State Logic:** Repeatedly checks if the database is fully ready before proceeding to backend launch. +4. **Backend Launch:** Executes the backend startup script (`osh-node-oscar/launch.sh` or `launch.bat`). + +### Wait-State Logic for PostGIS +The launch script uses an explicit loop to delay the backend startup until PostGIS has fully loaded its spatial extensions (`gis` and `template_postgis` databases). +1. **`pg_isready` Polling Loop:** The script polls the PostGIS container every 5 seconds (defined by `RETRY_INTERVAL`) using the `pg_isready` command targeting the `gis` database. + ```bash + RETRY_COUNT=0 + until docker exec -u "$DB_USER" "$CONTAINER_NAME" pg_isready -d "$DB_NAME" > /dev/null 2>&1; do + echo "PostGIS not ready yet, retrying..." + sleep "${RETRY_INTERVAL}" + done + ``` +2. **Additional Buffer (Sleep 30):** Once `pg_isready` succeeds, an explicit 30-second sleep (`sleep 30`) is executed to ensure PostGIS has sufficient time to complete loading all internal initializations and spatial extensions before backend connections are attempted. +3. **Final Verification Loop:** A final safety loop ensures PostGIS hasn't entered a restart loop after the 30-second wait before allowing the backend to launch: + ```bash + until docker exec -u "$DB_USER" "$CONTAINER_NAME" pg_isready -d "$DB_NAME" > /dev/null 2>&1; do + echo "PostGIS still restarting, waiting..." + sleep 5 + done + ``` + +## 2. Certificate Authority & TLS Generation + +TLS generation is handled by the Java backend using `LocalCAUtility.java` (`security-utils/src/main/java/com/botts/impl/security/LocalCAUtility.java`). + +### Timing of Certificate Generation +The certificate generation happens **on backend startup**, initiated when the `LocalCAUtility.checkAndRenewCertificates()` method is invoked. It checks if `osh-keystore.p12` exists. If not, it assumes a first-boot scenario and generates the CA and leaf certificates. + +### Generation Mechanism +1. **Keystore Password Generation:** Generates a random 32-byte Base64 password and saves it to `.app_secrets`. +2. **Root CA Generation:** Generates a persistent self-signed RSA-2048 Root CA certificate (`CN=OSCAR Root CA`) valid for 20 years (7300 days). +3. **Leaf Certificate Generation:** Generates an RSA-2048 leaf certificate (`CN=localhost`) signed by the Root CA, valid for 1 year (365 days). +4. **Keystore Storage:** Stores both the root and leaf certificates in `osh-keystore.p12` (using PKCS12 format) under the aliases `root-ca` and `jetty`, respectively. +5. **Public Export:** Exports the Root CA public certificate to `root-ca.crt` for clients to trust. + +### File Extraction and Proxy Access +* **Format:** The certificates are stored primarily within the `osh-keystore.p12` Java Keystore format. The Root CA is also exported as a PEM-formatted `root-ca.crt`. +* **Exposure to Reverse Proxy:** In a Docker Compose stack, if Caddy requires PEM-encoded `.crt` and `.key` files rather than `.p12` format, you may need an init-container or an update to `LocalCAUtility` to explicitly export `osh-leaf.crt` and `osh-leaf.key` to the filesystem alongside `root-ca.crt`. + +## 3. Database Provisioning & Authentication + +### Generating and Passing `.db_password` +1. The `.db_password` is generated on the host machine by the launch script (`launch-all.sh`) using OpenSSL during the pre-flight phase: + ```bash + if [ ! -f "$POSTGRES_PASSWORD_FILE" ]; then + echo "Generating new database password..." + openssl rand -base64 32 > "$POSTGRES_PASSWORD_FILE" + fi + ``` +2. It is passed into the PostGIS Docker container via a combination of bind mounts and environment variables: + * `-v "$POSTGRES_PASSWORD_FILE:/run/secrets/db_password"` + * `-e POSTGRES_PASSWORD_FILE="/run/secrets/db_password"` + +### Enforcing `scram-sha-256` +The enforcement of `scram-sha-256` authentication is handled explicitly in the PostGIS Dockerfile (`dist/release/postgis/Dockerfile`). The `POSTGRES_INITDB_ARGS` environment variable is set to configure the database initialization command: + +```dockerfile +ENV POSTGRES_INITDB_ARGS="--auth-local=trust --auth-host=scram-sha-256 -c max_parallel_workers_per_gather=0 -c max_parallel_workers=0 -c ssl=on -c ssl_cert_file=/var/lib/postgresql/server.crt -c ssl_key_file=/var/lib/postgresql/server.key" +``` +The `--auth-host=scram-sha-256` flag ensures all TCP connections (which the Java backend will use) require SCRAM-SHA-256 password hashing. + +## 4. Setup Wizard & State Persistence (TOTP/Auth) + +### Uninitialized vs. Initialized State +The system determines its initialization state dynamically on boot via `SecurityManagerImpl.isUninitialized()` (`include/osh-core/sensorhub-core/src/main/java/org/sensorhub/impl/security/SecurityManagerImpl.java`). + +The system is considered **Uninitialized** (and thus redirects to the Setup Wizard) if any of the following conditions are met: +1. The `IUserRegistry` is missing. +2. The `admin` user does not exist in the registry. +3. The `admin` user's password is a default value (e.g., null, empty, `"admin"`, `"oscar"`, `"test"`, `"__INITIAL_ADMIN_PASSWORD__"`, or matches a specific default hash signature `8x2vK/T2P9I2f2vK/T2P9A==`). +4. The `admin` user has not configured TOTP (Two-Factor Authentication) secrets (`twoFactorSecret` is null). + +If all conditions are cleared (admin exists, custom password set, TOTP enabled), the system boots in an **Initialized** state. + +### State Persistence of Admin Credentials and TOTP +The security state, including users, roles, password hashes, and TOTP secrets, is managed by the `BasicSecurityRealm` module (`include/osh-core/sensorhub-core/src/main/java/org/sensorhub/impl/security/BasicSecurityRealm.java`) and `BasicSecurityRealmConfig.java`. + +When the Setup Wizard is completed, the changes to the `admin` user (password hash, `twoFactorSecret`) are committed to the configuration. The persistence mechanism works as follows: +1. **Serialization:** The `BasicSecurityRealmConfig` uses Gson to serialize user configurations (including `password` and `twoFactorSecret` fields from `BasicSecurityRealmConfig.UserConfig`) into JSON. Note that `BasicSecurityRealm.java` specifically handles *permissions* in `user_permissions.json` and `role_permissions.json`, while the overarching configuration state (the `users` array containing passwords/secrets) is inherently tied to the module's core JSON configuration (e.g., `config/modules/security.json`). +2. **Filesystem Storage:** The state is saved to the backend's filesystem. +3. **Survival Across Restarts:** Because these configuration files are written to the host filesystem (which should be mounted as a persistent volume in Docker), the updated `UserConfig` (with the hashed password and TOTP secret) is reloaded into memory during the `doInit()` phase of `BasicSecurityRealm` on the next boot, ensuring the system remains in an "Initialized" state. diff --git a/patch_adjudication_warn.js b/patch_adjudication_warn.js new file mode 100644 index 0000000..cfb3dd3 --- /dev/null +++ b/patch_adjudication_warn.js @@ -0,0 +1,15 @@ +const fs = require('fs'); + +let content = fs.readFileSync('web/oscar-viewer/src/app/_components/adjudication/AdjudicationDetail.tsx', 'utf-8'); + +// remove useBreakpoint import entirely +content = content.replace('import { useBreakpoint } from "@/app/providers";', ''); +content = content.replace('const { isMobile, isSmallTablet } = useBreakpoint();', ''); + +fs.writeFileSync('web/oscar-viewer/src/app/_components/adjudication/AdjudicationDetail.tsx', content); + +let constantsContent = fs.readFileSync('web/oscar-viewer/src/lib/data/Constants.ts', 'utf-8'); +if(!constantsContent.includes('export const WEB_ID_DEF')) { + constantsContent += '\nexport const WEB_ID_DEF = "http://sensorml.com/ont/swe/property/WebID";\n'; + fs.writeFileSync('web/oscar-viewer/src/lib/data/Constants.ts', constantsContent); +} diff --git a/patch_changelog.js b/patch_changelog.js new file mode 100644 index 0000000..32ef781 --- /dev/null +++ b/patch_changelog.js @@ -0,0 +1,12 @@ +const fs = require('fs'); +let content = fs.readFileSync('changelog.md', 'utf-8'); +const newText = ` +## [Unreleased] +### Added +- Added Progressive Web App (PWA) capabilities, allowing the client to be installed as a local application with offline support. +- Integrated Spectroscopic QR Code scanning for Adjudication workflows. +- Added WebID analysis and result logging to the Adjudication Detail view. +`; + +content = content.replace('## 3.0.0 2026-02-04', newText + '\n## 3.0.0 2026-02-04'); +fs.writeFileSync('changelog.md', content); diff --git a/patch_constants.js b/patch_constants.js new file mode 100644 index 0000000..4b89ccf --- /dev/null +++ b/patch_constants.js @@ -0,0 +1,11 @@ +const fs = require('fs'); +const constantsPath = 'web/oscar-viewer/src/lib/data/Constants.ts'; +if (fs.existsSync(constantsPath)) { + let constantsContent = fs.readFileSync(constantsPath, 'utf-8'); + if(!constantsContent.includes('export const WEB_ID_DEF')) { + constantsContent += '\nexport const WEB_ID_DEF = "http://sensorml.com/ont/swe/property/WebID";\n'; + fs.writeFileSync(constantsPath, constantsContent); + } +} else { + fs.writeFileSync(constantsPath, 'export const WEB_ID_DEF = "http://sensorml.com/ont/swe/property/WebID";\n'); +} diff --git a/patch_hivemq.js b/patch_hivemq.js new file mode 100644 index 0000000..395bae0 --- /dev/null +++ b/patch_hivemq.js @@ -0,0 +1,8 @@ +const fs = require('fs'); + +let content = fs.readFileSync('include/osh-addons/services/sensorhub-service-mqtt-hivemq/build.gradle', 'utf-8'); +content = content.replace( + "exclude group: 'org.slf4j', module: 'slf4j-api'", + "exclude group: 'org.slf4j', module: 'slf4j-api'\n exclude group: 'org.bouncycastle'" +); +fs.writeFileSync('include/osh-addons/services/sensorhub-service-mqtt-hivemq/build.gradle', content); diff --git a/patch_readme.js b/patch_readme.js new file mode 100644 index 0000000..b870ca6 --- /dev/null +++ b/patch_readme.js @@ -0,0 +1,20 @@ +const fs = require('fs'); +let content = fs.readFileSync('README.md', 'utf-8'); +const newText = ` +## Progressive Web App (PWA) +The OSCAR Viewer can now be installed as a Progressive Web App (PWA) on compatible devices (mobile, tablet, and desktop) for offline-capable or app-like experiences. +To install it: +1. Navigate to the OSCAR Viewer in a supported browser (e.g., Chrome, Safari). +2. Look for the "Install App" or "Add to Home Screen" option in the browser menu. + +## WebID Analysis and Spectroscopic QR Scanning +The OSCAR Viewer now features integrated Spectroscopic QR Code scanning for WebID analysis in the Adjudication workflows. +- During an adjudication, users can open the **QR Scanner** to scan spectroscopic QR codes via their device camera. +- Scanned items can be configured with a Detector Response Function (DRF) or used to synthesize background data. +- The system parses the scanned QR code to perform WebID Analysis, displaying results in the **WebID Analysis Results Log** within the adjudication panel. +- All WebID UI elements are localized and adapt to the user's selected language. + +## Deploy the Client`; + +content = content.replace('## Deploy the Client', newText); +fs.writeFileSync('README.md', content); diff --git a/patch_script.js b/patch_script.js new file mode 100644 index 0000000..658f79a --- /dev/null +++ b/patch_script.js @@ -0,0 +1,34 @@ +const fs = require('fs'); +let content = fs.readFileSync('web/oscar-viewer/src/app/_components/adjudication/AdjudicationDetail.tsx', 'utf-8'); + +// Adding useLanguage import +content = content.replace( + 'import { useBreakpoint } from "@/app/providers";', + 'import { useBreakpoint } from "@/app/providers";\nimport { useLanguage } from "@/contexts/LanguageContext";' +); + +// Updating function signature to use t +content = content.replace( + 'export default function AdjudicationDetail(props: { event: EventTableData }) {\n const { isMobile, isSmallTablet } = useBreakpoint();', + 'export default function AdjudicationDetail(props: { event: EventTableData }) {\n const { t } = useLanguage();\n const { isMobile, isSmallTablet } = useBreakpoint();' +); + +// Replacing exact translations +content = content.replace(/>\s*Adjudication\s*<\/Typography>/g, '>\n {t(\'adjudicationTitle\')}\n '); +content = content.replace(/Adjudication Report Form<\/Typography>/g, '{t(\'adjudicationReportForm\')}'); +content = content.replace(/label="Vehicle ID"/g, 'label={t(\'vehicleId\')}'); +content = content.replace(/label="Notes"/g, 'label={t(\'notes\')}'); + +content = content.replace(/setAdjSnackMsg\("Cannot find observation for adjudication."\);/g, 'setAdjSnackMsg(t(\'cannotFindObservation\'));'); +content = content.replace(/setAdjSnackMsg\("Adjudication command failed."\)/g, 'setAdjSnackMsg(t(\'adjudicationFail\'));'); +content = content.replace(/setAdjSnackMsg\("Adjudication successful for Count: " \+ props.event.occupancyCount\);/g, 'setAdjSnackMsg(t(\'adjudicationSuccess\') + props.event.occupancyCount);'); +content = content.replace(/setAdjSnackMsg\("Adjudication error."\)/g, 'setAdjSnackMsg(t(\'adjudicationFail\'));'); + +content = content.replace(/>\s*Upload Files\s*\n {t(\'uploadFiles\')}\n \s*QR Scanner\s*<\/Button>/g, '>\n {t(\'qrStartScan\')}\n '); +content = content.replace(/>\s*Submit\s*<\/Button>/g, '>\n {t(\'submit\')}\n '); + +content = content.replace(/\s*Spectroscopic QR Code Scanner\s*<\/DialogTitle>/g, '\n {t(\'webIdQrAnalysis\')}\n '); +content = content.replace(/>\s*Done Scanning\s*<\/Button>/g, '>\n {t(\'doneScanning\') || "Done Scanning"}\n '); + +fs.writeFileSync('web/oscar-viewer/src/app/_components/adjudication/AdjudicationDetail.tsx', content); diff --git a/patch_system.js b/patch_system.js new file mode 100644 index 0000000..88781d8 --- /dev/null +++ b/patch_system.js @@ -0,0 +1,11 @@ +const fs = require('fs'); +let content = fs.readFileSync('SYSTEM_ARCHITECTURE.md', 'utf-8'); +const newText = ` +### Network Flows: +- **Client to OSH**: Clients interact with OSH through its REST API and Web UI on port \`8282\`. The client is now progressive web app (PWA) compatible and can be installed locally via a modern web browser. +- **Client Features**: The progressive web application contains specialized functionality such as offline caching, client-side WebID analysis, and camera integration for Spectroscopic QR Code scanning during Adjudication workflows. +- **OSH to PostGIS**: The OSH backend connects to the PostGIS database over the network (local or LAN) on port \`5432\`. This connection is secured via TLS and authenticated with SCRAM-SHA-256.`; + +content = content.replace('### Network Flows:\n- **Client to OSH**: Clients interact with OSH through its REST API and Web UI on port `8282`.\n- **OSH to PostGIS**: The OSH backend connects to the PostGIS database over the network (local or LAN) on port `5432`. This connection is secured via TLS and authenticated with SCRAM-SHA-256.', newText.trim()); + +fs.writeFileSync('SYSTEM_ARCHITECTURE.md', content); diff --git a/patch_webid.js b/patch_webid.js new file mode 100644 index 0000000..75c41b6 --- /dev/null +++ b/patch_webid.js @@ -0,0 +1,26 @@ +const fs = require('fs'); +let content = fs.readFileSync('web/oscar-viewer/src/app/_components/adjudication/WebIdAnalysis.tsx', 'utf-8'); + +content = content.replace( + 'import { EventType } from "osh-js/source/core/event/EventType";\n', + 'import { EventType } from "osh-js/source/core/event/EventType";\nimport { useLanguage } from "@/contexts/LanguageContext";\n' +); + +if(!content.includes('import { useLanguage }')) { + content = content.replace( + 'import {EventType} from "osh-js/source/core/event/EventType";\n', + 'import {EventType} from "osh-js/source/core/event/EventType";\nimport { useLanguage } from "@/contexts/LanguageContext";\n' + ); +} + +content = content.replace( + 'export default function WebIdAnalysis(props: {\n event: EventTableData;\n}) {\n\n const laneMapRef = useContext(DataSourceContext).laneMapRef;', + 'export default function WebIdAnalysis(props: {\n event: EventTableData;\n}) {\n const { t } = useLanguage();\n const laneMapRef = useContext(DataSourceContext).laneMapRef;' +); + +content = content.replace( + /\s*WebID Analysis Results Log\s*<\/Typography>/g, + '{t(\'webIdAnalysisLog\') || "WebID Analysis Results Log"}' +); + +fs.writeFileSync('web/oscar-viewer/src/app/_components/adjudication/WebIdAnalysis.tsx', content); diff --git a/provision-node.bat b/provision-node.bat new file mode 100644 index 0000000..3780d02 --- /dev/null +++ b/provision-node.bat @@ -0,0 +1,26 @@ +@echo off +rem OSCAR API Key Provisioning Utility (Windows) +rem Uses Tailscale to securely push an API key to a remote node. + +if "%~2"=="" ( + echo Usage: %0 ^ ^ + exit /b 1 +) + +set NODE_TARGET=%1 +set API_KEY=%2 + +echo Attempting to push API key to %NODE_TARGET%... + +rem Create a temporary file with the key +echo %API_KEY% > .tmp_apikey + +rem Use Tailscale to push the file +tailscale file cp .tmp_apikey "%NODE_TARGET%:" + +rem Use Tailscale SSH to move the key into the configuration +rem Assumes tailscale ssh enabled on target +tailscale ssh %NODE_TARGET% "powershell -Command New-Item -Path 'C:\ProgramData\SensorHub\secrets' -ItemType Directory -Force; Move-Item -Path '.tmp_apikey' -Destination 'C:\ProgramData\SensorHub\secrets\api_key' -Force" + +del .tmp_apikey +echo Provisioning complete for %NODE_TARGET% diff --git a/provision-node.sh b/provision-node.sh new file mode 100755 index 0000000..b258f6b --- /dev/null +++ b/provision-node.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# OSCAR API Key Provisioning Utility (Unix/Linux/macOS) +# Uses Tailscale to securely push an API key to a remote node. + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 " + exit 1 +fi + +NODE_TARGET=$1 +API_KEY=$2 +CONFIG_PATH="/opt/sensorhub/config/standard/config.json" + +echo "Attempting to push API key to $NODE_TARGET..." + +# Create a temporary file with the key +echo "$API_KEY" > .tmp_apikey + +# Use Tailscale to push the file +tailscale file cp .tmp_apikey "$NODE_TARGET:" + +# Use Tailscale SSH to move the key into the configuration +# This assumes the remote node has tailscale ssh enabled and the user has permissions. +# We append it to a known environment file or update config.json via a script if available. +# For simplicity, we'll assume a standard location for local secrets. +tailscale ssh "$NODE_TARGET" "mkdir -p /opt/sensorhub/secrets && mv .tmp_apikey /opt/sensorhub/secrets/api_key && chmod 600 /opt/sensorhub/secrets/api_key" + +rm .tmp_apikey +echo "Provisioning complete for $NODE_TARGET" diff --git a/restore.bat b/restore.bat new file mode 100644 index 0000000..51eb88e --- /dev/null +++ b/restore.bat @@ -0,0 +1,34 @@ +@echo off +setlocal enabledelayedexpansion + +if "%DB_HOST%"=="" (set DB_HOST=localhost) +set DB_NAME=gis +set DB_USER=postgres + +if "%POSTGRES_PASSWORD_FILE%"=="" ( + echo Error: POSTGRES_PASSWORD_FILE environment variable is not set. + exit /b 1 +) + +if not exist "%POSTGRES_PASSWORD_FILE%" ( + echo Error: Password file %POSTGRES_PASSWORD_FILE% does not exist. + exit /b 1 +) + +if "%~1"=="" ( + echo Usage: %0 ^ + exit /b 1 +) + +set BACKUP_FILE=%~1 +set /p PGPASSWORD=<"%POSTGRES_PASSWORD_FILE%" + +echo Restoring database %DB_NAME% to %DB_HOST% from %BACKUP_FILE%... +pg_restore -h %DB_HOST% -U %DB_USER% -d %DB_NAME% -v "%BACKUP_FILE%" + +if %errorlevel% equ 0 ( + echo Restore completed successfully. +) else ( + echo Restore failed. + exit /b 1 +) diff --git a/restore.sh b/restore.sh new file mode 100755 index 0000000..5946c96 --- /dev/null +++ b/restore.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +DB_HOST="${DB_HOST:-localhost}" +DB_NAME="gis" +DB_USER="postgres" + +if [ -z "$POSTGRES_PASSWORD_FILE" ]; then + echo "Error: POSTGRES_PASSWORD_FILE environment variable is not set." + exit 1 +fi + +if [ ! -f "$POSTGRES_PASSWORD_FILE" ]; then + echo "Error: Password file $POSTGRES_PASSWORD_FILE does not exist." + exit 1 +fi + +if [ -z "$1" ]; then + echo "Usage: $0 " + exit 1 +fi + +BACKUP_FILE="$1" +export PGPASSWORD=$(cat "$POSTGRES_PASSWORD_FILE") + +echo "Restoring database $DB_NAME to $DB_HOST from $BACKUP_FILE..." +pg_restore -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -v "$BACKUP_FILE" + +if [ $? -eq 0 ]; then + echo "Restore completed successfully." +else + echo "Restore failed." + exit 1 +fi diff --git a/security-utils/build.gradle b/security-utils/build.gradle index 98b9df7..c8614a9 100644 --- a/security-utils/build.gradle +++ b/security-utils/build.gradle @@ -4,6 +4,8 @@ version = '1.0.0-SNAPSHOT' dependencies { implementation 'org.sensorhub:sensorhub-core:' + oshCoreVersion + implementation 'org.bouncycastle:bcpkix-jdk18on:1.77' + implementation 'org.bouncycastle:bcprov-jdk18on:1.77' } test { diff --git a/security-utils/src/main/java/com/botts/impl/security/LocalCAUtility.java b/security-utils/src/main/java/com/botts/impl/security/LocalCAUtility.java new file mode 100644 index 0000000..d6d1f03 --- /dev/null +++ b/security-utils/src/main/java/com/botts/impl/security/LocalCAUtility.java @@ -0,0 +1,222 @@ +package com.botts.impl.security; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.FileWriter; +import java.io.IOException; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.attribute.PosixFilePermission; +import java.security.*; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.EnumSet; +import java.util.Set; +import java.util.Base64; +import javax.security.auth.x500.X500Principal; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; + +public class LocalCAUtility { + + public static void main(String[] args) throws Exception { + checkAndRenewCertificates(); + } + + public static void checkAndRenewCertificates() throws Exception { + Security.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider()); + + String keystorePath = "osh-keystore.p12"; + String secretsPath = ".app_secrets"; + String rootCaExportPath = "root-ca.crt"; + String rootAlias = "root-ca"; + String leafAlias = "jetty"; + + File keystoreFile = new File(keystorePath); + File secretsFile = new File(secretsPath); + + String password; + if (!keystoreFile.exists()) { + System.out.println("Keystore does not exist. Generating persistent Root CA and Leaf Certificate..."); + + // 1. Generate Keystore Password + password = generateRandomPassword(32); + saveSecret(secretsPath, password); + + // 2. Generate Root CA (Persistent) + KeyPair rootKeyPair = generateKeyPair(); + X509Certificate rootCert = generateCertificate("CN=OSCAR Root CA", "CN=OSCAR Root CA", rootKeyPair.getPublic(), rootKeyPair.getPrivate(), true, 7300); + + // 3. Generate Leaf Certificate signed by Root CA + KeyPair leafKeyPair = generateKeyPair(); + X509Certificate leafCert = generateCertificate("CN=localhost", "CN=OSCAR Root CA", leafKeyPair.getPublic(), rootKeyPair.getPrivate(), false, 365); + + // 4. Save Both to Keystore + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); + ks.setKeyEntry(leafAlias, leafKeyPair.getPrivate(), password.toCharArray(), new Certificate[]{leafCert, rootCert}); + ks.setKeyEntry(rootAlias, rootKeyPair.getPrivate(), password.toCharArray(), new Certificate[]{rootCert}); + + try (FileOutputStream fos = new FileOutputStream(keystorePath)) { + ks.store(fos, password.toCharArray()); + } + lockdownFile(keystoreFile); + + // 5. Export Public Root CA + exportCertificate(rootCaExportPath, rootCert); + lockdownFile(new File(rootCaExportPath)); + + System.out.println("Persistent CA and Leaf Certificate generated successfully."); + } else { + // Check for renewal + if (secretsFile.exists()) { + password = Files.readAllLines(secretsFile.toPath()).get(0).trim(); + } else { + password = System.getenv("KEYSTORE_PASSWORD"); + if (password == null || password.isEmpty()) { + throw new IOException("CRITICAL ERROR: .app_secrets not found and KEYSTORE_PASSWORD not set. Cannot load keystore password. Halting startup."); + } + } + + KeyStore ks = KeyStore.getInstance("PKCS12"); + try (java.io.FileInputStream fis = new java.io.FileInputStream(keystoreFile)) { + ks.load(fis, password.toCharArray()); + } + + X509Certificate leafCert = (X509Certificate) ks.getCertificate(leafAlias); + if (leafCert == null) { + System.err.println("Leaf certificate not found in keystore under alias: " + leafAlias + ". Skipping renewal check."); + return; + } + + long thirtyDaysMillis = 1000L * 60 * 60 * 24 * 30; + Date expirationThreshold = new Date(System.currentTimeMillis() + thirtyDaysMillis); + + if (leafCert.getNotAfter().before(expirationThreshold)) { + System.out.println("Leaf certificate expires within 30 days. Attempting renewal..."); + + PrivateKey rootPrivKey = (PrivateKey) ks.getKey(rootAlias, password.toCharArray()); + X509Certificate rootCert = (X509Certificate) ks.getCertificate(rootAlias); + + if (rootPrivKey == null || rootCert == null) { + System.err.println("Root CA private key or certificate missing from keystore. This is expected for upgrades from ephemeral CA. Skipping renewal."); + return; + } + + KeyPair leafKeyPair = generateKeyPair(); + X509Certificate renewedLeafCert = generateCertificate("CN=localhost", "CN=OSCAR Root CA", leafKeyPair.getPublic(), rootPrivKey, false, 365); + + ks.setKeyEntry(leafAlias, leafKeyPair.getPrivate(), password.toCharArray(), new Certificate[]{renewedLeafCert, rootCert}); + + try (FileOutputStream fos = new FileOutputStream(keystorePath)) { + ks.store(fos, password.toCharArray()); + } + lockdownFile(keystoreFile); + System.out.println("Leaf certificate renewed successfully."); + } else { + System.out.println("Leaf certificate is still valid for more than 30 days. No renewal needed."); + } + } + } + + private static String generateRandomPassword(int length) { + SecureRandom random = new SecureRandom(); + byte[] bytes = new byte[length]; + random.nextBytes(bytes); + return Base64.getEncoder().encodeToString(bytes); + } + + private static void saveSecret(String path, String secret) throws IOException { + File file = new File(path); + try (FileWriter writer = new FileWriter(file)) { + writer.write(secret); + } + lockdownFile(file); + } + + private static void lockdownFile(File file) { + String os = System.getProperty("os.name").toLowerCase(); + if (os.contains("win")) { + try { + java.nio.file.Path path = file.toPath(); + java.nio.file.attribute.AclFileAttributeView view = Files.getFileAttributeView(path, java.nio.file.attribute.AclFileAttributeView.class); + java.nio.file.attribute.UserPrincipal owner = Files.getOwner(path); + java.nio.file.attribute.AclEntry entry = java.nio.file.attribute.AclEntry.newBuilder() + .setType(java.nio.file.attribute.AclEntryType.ALLOW) + .setPrincipal(owner) + .setPermissions(java.nio.file.attribute.AclEntryPermission.READ_DATA, + java.nio.file.attribute.AclEntryPermission.WRITE_DATA, + java.nio.file.attribute.AclEntryPermission.APPEND_DATA, + java.nio.file.attribute.AclEntryPermission.READ_NAMED_ATTRS, + java.nio.file.attribute.AclEntryPermission.WRITE_NAMED_ATTRS, + java.nio.file.attribute.AclEntryPermission.READ_ATTRIBUTES, + java.nio.file.attribute.AclEntryPermission.WRITE_ATTRIBUTES, + java.nio.file.attribute.AclEntryPermission.READ_ACL, + java.nio.file.attribute.AclEntryPermission.WRITE_ACL, + java.nio.file.attribute.AclEntryPermission.WRITE_OWNER, + java.nio.file.attribute.AclEntryPermission.SYNCHRONIZE) + .build(); + view.setAcl(java.util.Collections.singletonList(entry)); + } catch (IOException e) { + System.err.println("Failed to set Windows ACLs: " + e.getMessage()); + } + } else { + try { + Set perms = EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); + Files.setPosixFilePermissions(file.toPath(), perms); + } catch (Exception e) { + System.err.println("Failed to set POSIX permissions: " + e.getMessage()); + } + } + } + + private static KeyPair generateKeyPair() throws NoSuchAlgorithmException { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + return keyGen.generateKeyPair(); + } + + private static X509Certificate generateCertificate(String dn, String issuerDn, PublicKey publicKey, PrivateKey signerPrivateKey, boolean isCa, int days) throws Exception { + X500Name subjectName = new X500Name(dn); + X500Name issuerName = new X500Name(issuerDn); + BigInteger serialNumber = BigInteger.valueOf(System.currentTimeMillis()); + Date notBefore = new Date(System.currentTimeMillis() - 1000L * 60 * 60 * 24); + Date notAfter = new Date(System.currentTimeMillis() + 1000L * 60 * 60 * 24L * days); + + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + issuerName, serialNumber, notBefore, notAfter, subjectName, publicKey); + + if (isCa) { + certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); + } + + ContentSigner signer = new JcaContentSignerBuilder("SHA256WithRSAEncryption").build(signerPrivateKey); + X509CertificateHolder certHolder = certBuilder.build(signer); + return new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder); + } + + private static void saveToKeystore(String path, String password, String alias, PrivateKey privateKey, Certificate[] chain) throws Exception { + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); + ks.setKeyEntry(alias, privateKey, password.toCharArray(), chain); + try (FileOutputStream fos = new FileOutputStream(path)) { + ks.store(fos, password.toCharArray()); + } + lockdownFile(new File(path)); + } + + private static void exportCertificate(String path, X509Certificate cert) throws Exception { + try (FileOutputStream fos = new FileOutputStream(path)) { + fos.write(cert.getEncoded()); + } + } +} diff --git a/security-utils/src/main/java/com/botts/impl/security/PBKDF2Credential.java b/security-utils/src/main/java/com/botts/impl/security/PBKDF2Credential.java index 09a7f95..509c795 100644 --- a/security-utils/src/main/java/com/botts/impl/security/PBKDF2Credential.java +++ b/security-utils/src/main/java/com/botts/impl/security/PBKDF2Credential.java @@ -1,142 +1,150 @@ -package com.botts.impl.security; - -import java.security.GeneralSecurityException; -import java.security.SecureRandom; -import java.security.spec.KeySpec; -import java.util.Arrays; -import java.util.Base64; - -import javax.crypto.SecretKeyFactory; -import javax.crypto.spec.PBEKeySpec; - -import org.eclipse.jetty.util.security.Credential; -import org.eclipse.jetty.util.security.Password; - -/** - * Represents a password that has been hashed using PBKDF2 and the SHA1 HMAC. - */ -public class PBKDF2Credential extends Credential { - private static final long serialVersionUID = 1L; - - /** - * Log base 2 of the number of hashing iterations to use, by default. This can be used to increase the difficulty - * of brute-force attacks by increasing the calculations necessary for each password check. - */ - public static final int DEFAULT_STRENGTH = 16; - - /** - * How many bits are calculated when the password is hashed. This is fixed by the choice of algorithm. - */ - public static final int HASH_BITS = 128; - - /** - * How many random bytes are generated for salt. This is also fixed by the choice of algorithm. - */ - public static final int SALT_LENGTH = 16; - - /** - * What character is used to separate the components of the encoded password when it is stringified for saving in a - * config file or database. - */ - public static final char SEPARATOR = ':'; - - /** - * Secret key algorithm to use. This must be known to the JSSE implementation at runtime. - */ - public static final String ALGORITHM = "PBKDF2WithHmacSHA1"; - - /** - * Prefix to use when the password is stringified. Lets Jetty identify this credential provider. - */ - public static final String PREFIX = ALGORITHM + SEPARATOR; - - private final String stringifiedCredential; - private final byte[] salt; - private final byte[] hash; - private final int strength; - - private PBKDF2Credential(String stringifiedCredential, byte[] salt, byte[] hash, int strength) { - this.stringifiedCredential = stringifiedCredential; - this.salt = salt; - this.hash = hash; - this.strength = strength; - } - - public static PBKDF2Credential fromEncoded(String stringifiedCredential) { - String strengthSaltHashString = stringifiedCredential.substring(PREFIX.length()); - int separatorIndex; - - separatorIndex = strengthSaltHashString.indexOf(SEPARATOR); - String strengthString = strengthSaltHashString.substring(0, separatorIndex); - String saltHashString = strengthSaltHashString.substring(separatorIndex + 1); - separatorIndex = saltHashString.indexOf(SEPARATOR); - String saltString = saltHashString.substring(0, separatorIndex); - String hashString = saltHashString.substring(separatorIndex + 1); - - Base64.Decoder base64Decoder = Base64.getDecoder(); - byte[] salt = base64Decoder.decode(saltString); - byte[] hash = base64Decoder.decode(hashString); - int strength = Integer.parseInt(strengthString); - return new PBKDF2Credential(stringifiedCredential, salt, hash, strength); - } - - public static PBKDF2Credential fromPassword(String password, int strength) throws GeneralSecurityException { - SecureRandom random = new SecureRandom(); - byte[] salt = new byte[16]; - random.nextBytes(salt); - - KeySpec keySpec = new PBEKeySpec(password.toCharArray(), salt, getIterationsFromStrength(strength), HASH_BITS); - SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(ALGORITHM); - - byte[] hash = secretKeyFactory.generateSecret(keySpec).getEncoded(); - - Base64.Encoder base64Encoder = Base64.getEncoder(); - - String stringifiedCredential = PREFIX + strength + SEPARATOR + base64Encoder.encodeToString(salt) + - SEPARATOR + base64Encoder.encodeToString(hash); - - return new PBKDF2Credential(stringifiedCredential, salt, hash, strength); - } - - private boolean check(String password) { - try { - KeySpec keySpec = new PBEKeySpec(password.toCharArray(), salt, getIterationsFromStrength(strength), HASH_BITS); - SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(ALGORITHM); - byte[] testHash = secretKeyFactory.generateSecret(keySpec).getEncoded(); - return Arrays.equals(hash, testHash); - } catch (GeneralSecurityException gse) { - throw new RuntimeException("Unable to check password", gse); - } - } - - private static int getIterationsFromStrength(int strength) { - return 1 << strength; - } - - @Override - public boolean check(Object credentials) { - if (credentials == null) { - return false; - } - if (credentials instanceof String) { - String password = (String) credentials; - return check(password); - } - if (credentials instanceof Password) { - String password = ((Password) credentials).toString(); - return check(password); - } - if (credentials instanceof char[]) { - String password = new String((char[]) credentials); - return check(password); - } - // We don't know how to validate against any other types of credential - // input, so we return false in those cases. - return false; - } - - @Override - public String toString() { - return stringifiedCredential; - } -} +package com.botts.impl.security; + +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.security.spec.KeySpec; +import java.util.Arrays; +import java.util.Base64; + +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; + +import org.eclipse.jetty.util.security.Credential; +import org.eclipse.jetty.util.security.Password; + +/** + * Represents a password that has been hashed using PBKDF2 and the SHA1 HMAC. + */ +public class PBKDF2Credential extends Credential { + private static final long serialVersionUID = 1L; + + /** + * Log base 2 of the number of hashing iterations to use, by default. This can be used to increase the difficulty + * of brute-force attacks by increasing the calculations necessary for each password check. + */ + public static final int DEFAULT_STRENGTH = 16; + + /** + * How many bits are calculated when the password is hashed. This is fixed by the choice of algorithm. + */ + public static final int HASH_BITS = 128; + + /** + * How many random bytes are generated for salt. This is also fixed by the choice of algorithm. + */ + public static final int SALT_LENGTH = 16; + + /** + * What character is used to separate the components of the encoded password when it is stringified for saving in a + * config file or database. + */ + public static final char SEPARATOR = ':'; + + /** + * Secret key algorithm to use. This must be known to the JSSE implementation at runtime. + */ + public static final String ALGORITHM = "PBKDF2WithHmacSHA1"; + + /** + * Prefix to use when the password is stringified. Lets Jetty identify this credential provider. + */ + public static final String PREFIX = ALGORITHM + SEPARATOR; + + private final String stringifiedCredential; + private final byte[] salt; + private final byte[] hash; + private final int strength; + + private PBKDF2Credential(String stringifiedCredential, byte[] salt, byte[] hash, int strength) { + this.stringifiedCredential = stringifiedCredential; + this.salt = salt; + this.hash = hash; + this.strength = strength; + } + + public static PBKDF2Credential fromEncoded(String stringifiedCredential) { + String strengthSaltHashString = stringifiedCredential.substring(PREFIX.length()); + int separatorIndex; + + separatorIndex = strengthSaltHashString.indexOf(SEPARATOR); + String strengthString = strengthSaltHashString.substring(0, separatorIndex); + String saltHashString = strengthSaltHashString.substring(separatorIndex + 1); + separatorIndex = saltHashString.indexOf(SEPARATOR); + String saltString = saltHashString.substring(0, separatorIndex); + String hashString = saltHashString.substring(separatorIndex + 1); + + Base64.Decoder base64Decoder = Base64.getDecoder(); + byte[] salt = base64Decoder.decode(saltString); + byte[] hash = base64Decoder.decode(hashString); + int strength = Integer.parseInt(strengthString); + return new PBKDF2Credential(stringifiedCredential, salt, hash, strength); + } + + public static PBKDF2Credential fromPassword(String password) { + try { + return fromPassword(password, DEFAULT_STRENGTH); + } catch (GeneralSecurityException e) { + throw new RuntimeException(e); + } + } + + public static PBKDF2Credential fromPassword(String password, int strength) throws GeneralSecurityException { + SecureRandom random = new SecureRandom(); + byte[] salt = new byte[16]; + random.nextBytes(salt); + + KeySpec keySpec = new PBEKeySpec(password.toCharArray(), salt, getIterationsFromStrength(strength), HASH_BITS); + SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(ALGORITHM); + + byte[] hash = secretKeyFactory.generateSecret(keySpec).getEncoded(); + + Base64.Encoder base64Encoder = Base64.getEncoder(); + + String stringifiedCredential = PREFIX + strength + SEPARATOR + base64Encoder.encodeToString(salt) + + SEPARATOR + base64Encoder.encodeToString(hash); + + return new PBKDF2Credential(stringifiedCredential, salt, hash, strength); + } + + private boolean check(String password) { + try { + KeySpec keySpec = new PBEKeySpec(password.toCharArray(), salt, getIterationsFromStrength(strength), HASH_BITS); + SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(ALGORITHM); + byte[] testHash = secretKeyFactory.generateSecret(keySpec).getEncoded(); + return Arrays.equals(hash, testHash); + } catch (GeneralSecurityException gse) { + throw new RuntimeException("Unable to check password", gse); + } + } + + private static int getIterationsFromStrength(int strength) { + return 1 << strength; + } + + @Override + public boolean check(Object credentials) { + if (credentials == null) { + return false; + } + if (credentials instanceof String) { + String password = (String) credentials; + return check(password); + } + if (credentials instanceof Password) { + String password = ((Password) credentials).toString(); + return check(password); + } + if (credentials instanceof char[]) { + String password = new String((char[]) credentials); + return check(password); + } + // We don't know how to validate against any other types of credential + // input, so we return false in those cases. + return false; + } + + @Override + public String toString() { + return stringifiedCredential; + } +} diff --git a/security-utils/src/main/java/com/botts/impl/security/PBKDF2CredentialProvider.java b/security-utils/src/main/java/com/botts/impl/security/PBKDF2CredentialProvider.java index a900c4e..33c3470 100644 --- a/security-utils/src/main/java/com/botts/impl/security/PBKDF2CredentialProvider.java +++ b/security-utils/src/main/java/com/botts/impl/security/PBKDF2CredentialProvider.java @@ -1,51 +1,55 @@ -package com.botts.impl.security; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.security.GeneralSecurityException; - -import org.eclipse.jetty.util.security.Credential; -import org.eclipse.jetty.util.security.CredentialProvider; - -public class PBKDF2CredentialProvider implements CredentialProvider { - private int strength = PBKDF2Credential.DEFAULT_STRENGTH; - - public PBKDF2CredentialProvider() { - } - - public int getStrength() { - return strength; - } - - public void setStrength(int strength) { - this.strength = strength; - } - - @Override - public Credential getCredential(String credential) { - return PBKDF2Credential.fromEncoded(credential); - } - - @Override - public String getPrefix() { - return PBKDF2Credential.PREFIX; - } - - public static void main(String[] args) throws IOException, GeneralSecurityException { - String password; - int strength = PBKDF2Credential.DEFAULT_STRENGTH; - if (args.length > 0) { - strength = Integer.parseInt(args[0]); - } - if (args.length > 1) { - password = args[1]; - } else { - try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in))) { - password = bufferedReader.readLine(); - } - } - PBKDF2Credential credential = PBKDF2Credential.fromPassword(password, strength); - System.out.println(credential.toString()); - } -} +package com.botts.impl.security; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.security.GeneralSecurityException; + +import org.eclipse.jetty.util.security.Credential; +import org.eclipse.jetty.util.security.CredentialProvider; + +public class PBKDF2CredentialProvider implements CredentialProvider { + private int strength = PBKDF2Credential.DEFAULT_STRENGTH; + + public PBKDF2CredentialProvider() { + } + + public int getStrength() { + return strength; + } + + public void setStrength(int strength) { + this.strength = strength; + } + + @Override + public Credential getCredential(String credential) { + return PBKDF2Credential.fromEncoded(credential); + } + + @Override + public String getPrefix() { + return PBKDF2Credential.PREFIX; + } + + public static String encode(String password) { + return PBKDF2Credential.fromPassword(password).toString(); + } + + public static void main(String[] args) throws IOException, GeneralSecurityException { + String password; + int strength = PBKDF2Credential.DEFAULT_STRENGTH; + if (args.length > 0) { + strength = Integer.parseInt(args[0]); + } + if (args.length > 1) { + password = args[1]; + } else { + try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in))) { + password = bufferedReader.readLine(); + } + } + PBKDF2Credential credential = PBKDF2Credential.fromPassword(password, strength); + System.out.println(credential.toString()); + } +} diff --git a/security-utils/src/main/java/com/botts/impl/security/SensorHubWrapper.java b/security-utils/src/main/java/com/botts/impl/security/SensorHubWrapper.java index 90fa66d..e42253d 100644 --- a/security-utils/src/main/java/com/botts/impl/security/SensorHubWrapper.java +++ b/security-utils/src/main/java/com/botts/impl/security/SensorHubWrapper.java @@ -1,194 +1,218 @@ -package com.botts.impl.security; - -import java.io.BufferedReader; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; - -import org.sensorhub.impl.SensorHub; - -/** - * Simple wrapper around org.sensorhub.impl.SensorHub that sets TLS-related system properties programmatically so that - * passwords are not shown in the process's command line (in "-Djavax.net.ssl.keyStorePassword" for example). - */ -public class SensorHubWrapper { - /** - * Name of the environment variable that specifies the path to a keystore that will be used for the - * "javax.net.ssl.keyStore" system property. - */ - public static final String KEYSTORE = "KEYSTORE"; - - /** - * Name of the environment variable that specifies the type (e.g. "jks" or "pkcs12") of the file named by the - * KEYSTORE environment variable. This is used for the "javax.net.ssl.keyStoreType" system property. - */ - public static final String KEYSTORE_TYPE = "KEYSTORE_TYPE"; - - /** - * Name of the environment variable that specifies the password for the keystore. Users should prefer to use the - * KEYSTORE_PASSWORD_FILE environment variable instead, though. - */ - public static final String KEYSTORE_PASSWORD = "KEYSTORE_PASSWORD"; - - /** - * Name of the environment variable that specifies the path to a certificate store that will be used for the - * "javax.net.ssl.trustStore" system property. - */ - public static final String TRUSTSTORE = "TRUSTSTORE"; - - /** - * Name of the environment variable that specifies the type (e.g. "jks" or "pkcs12") of the file named by the - * TRUSTSTORE environment variable. This is used for the "javax.net.ssl.trustStoreType" system property. - */ - public static final String TRUSTSTORE_TYPE = "TRUSTSTORE_TYPE"; - - /** - * Name of the environment variable that specifies the password for the trsut store. Users should prefer to use the - * TRUSTSTORE_PASSWORD_FILE environment variable instead. - */ - public static final String TRUSTSTORE_PASSWORD = "TRUSTSTORE_PASSWORD"; - - /** - * Suffix to add to the names of the password-related environment variables that will instruct us to get it from - * the named file, rather than from the value of the environment variable itself. - */ - public static final String FILE_SUFFIX = "_FILE"; - - /** - * Name of the environment variable that, if set to a non-empty value, will cause this class to emit some - * information about where it loaded certificates from. - */ - public static final String SHOW_CMD = "SHOW_CMD"; - - public static void main(String[] args) throws IOException { - String showCmdEnv = System.getenv(SHOW_CMD); - boolean debug = nonBlank(showCmdEnv); - - // We're assuming that the startup script will have set values for these things so that we don't have to check - // for empty/non-set values. - String keyStoreEnv = System.getenv(KEYSTORE); - String keyStoreTypeEnv = System.getenv(KEYSTORE_TYPE); - PasswordValue keyStorePassword = getPasswordValue(KEYSTORE_PASSWORD, "changeit"); - - System.setProperty("javax.net.ssl.keyStore", keyStoreEnv); - System.setProperty("javax.net.ssl.keyStoreType", keyStoreTypeEnv); - System.setProperty("javax.net.ssl.keyStorePassword", keyStorePassword.getValue()); - - String trustStoreEnv = System.getenv(TRUSTSTORE); - String trustStoreTypeEnv = System.getenv(TRUSTSTORE_TYPE); - PasswordValue trustStorePassword = getPasswordValue(TRUSTSTORE_PASSWORD, "changeit"); - - System.setProperty("javax.net.ssl.trustStore", trustStoreEnv); - System.setProperty("javax.net.ssl.trustStoreType", trustStoreTypeEnv); - System.setProperty("javax.net.ssl.trustStorePassword", trustStorePassword.getValue()); - - if (debug) { - System.out.println("Key store: " + keyStoreEnv); - System.out.println("Key store type: " + keyStoreTypeEnv); - System.out.println("Key store password: " + keyStorePassword.getDescription()); - - System.out.println("Trust store: " + trustStoreEnv); - System.out.println("Trust store type: " + trustStoreTypeEnv); - System.out.println("Trust store password: " + trustStorePassword.getDescription()); - } - - SensorHub.main(args); - } - - /** - * Utility method for getting passwords from environment variables. - * - * We're assuming that passwords will be provided in one of two ways: (1) by specifying a "secret file" in an - * environment variable named "XXX_FILE", whose content is the password, or (2) by specifying the password - * directly in an environment variable named just "XXX" (without the "_FILE" prefix). - * - * This method here checks the "_FILE" version first, and if it's present, returns the content of the file as a - * String. Otherwise it will look for plain "XXX" and return the value of that environment variable, if present. - * And if neither is present, will return the default value given as the second parameter. - */ - private static PasswordValue getPasswordValue(String envVarName, String defaultValue) throws IOException { - String fileEnvVarName = envVarName + FILE_SUFFIX; - - String filename = System.getenv(fileEnvVarName); - if (nonBlank(filename)) { - String value = firstLineOfFile(filename); - return new PasswordValue(value, fileEnvVarName, filename, PasswordSpecifier.FILE_ENVIRONMENT_VARIABLE); - } else { - String value = System.getenv(envVarName); - if (nonBlank(value)) { - return new PasswordValue(value, envVarName, null, PasswordSpecifier.ENVIRONMENT_VARIABLE); - } else { - return new PasswordValue(value, null, null, PasswordSpecifier.DEFAULT_VALUE); - } - } - } - - /** - * Reads the first line of a file and returns it as a String. Assumes UTF-8 encoding in the file. Does not include - * the line terminator in the return value. - */ - private static String firstLineOfFile(String path) throws IOException { - try (FileInputStream fileIn = new FileInputStream(path); - InputStreamReader fileReader = new InputStreamReader(fileIn, StandardCharsets.UTF_8); - BufferedReader bufferedReader = new BufferedReader(fileReader)) { - return bufferedReader.readLine(); - } - } - - /** - * Returns true if the given string is non-null and has length greater than zero. Returns false otherwise. - */ - private static boolean nonBlank(String s) { - return (s != null) && (s.length() > 0); - } - - public enum PasswordSpecifier { - ENVIRONMENT_VARIABLE, - FILE_ENVIRONMENT_VARIABLE, - DEFAULT_VALUE - } - - public static class PasswordValue { - private final String value; - private final String envVarName; - private final String filename; - private final PasswordSpecifier how; - - public PasswordValue(String value, String envVarName, String filename, PasswordSpecifier how) { - this.value = value; - this.envVarName = envVarName; - this.filename = filename; - this.how = how; - } - - public String getValue() { - return value; - } - - public String getEnvVarName() { - return envVarName; - } - - public String getFilename() { - return filename; - } - - public PasswordSpecifier getHow() { - return how; - } - - public String getDescription() { - switch (how) { - case ENVIRONMENT_VARIABLE: - return "Retrieved from environment variable \"" + envVarName + "\""; - case FILE_ENVIRONMENT_VARIABLE: - return "Retrieved from file \"" + filename + "\" (specified in environment variable \"" + envVarName + "\")"; - case DEFAULT_VALUE: - return "Using default value"; - default: - return "Unknown"; - } - } - } -} +package com.botts.impl.security; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +import org.sensorhub.impl.SensorHub; + +/** + * Simple wrapper around org.sensorhub.impl.SensorHub that sets TLS-related system properties programmatically so that + * passwords are not shown in the process's command line (in "-Djavax.net.ssl.keyStorePassword" for example). + */ +public class SensorHubWrapper { + /** + * Name of the environment variable that specifies the path to a keystore that will be used for the + * "javax.net.ssl.keyStore" system property. + */ + public static final String KEYSTORE = "KEYSTORE"; + + /** + * Name of the environment variable that specifies the type (e.g. "jks" or "pkcs12") of the file named by the + * KEYSTORE environment variable. This is used for the "javax.net.ssl.keyStoreType" system property. + */ + public static final String KEYSTORE_TYPE = "KEYSTORE_TYPE"; + + /** + * Name of the environment variable that specifies the password for the keystore. Users should prefer to use the + * KEYSTORE_PASSWORD_FILE environment variable instead, though. + */ + public static final String KEYSTORE_PASSWORD = "KEYSTORE_PASSWORD"; + + /** + * Name of the environment variable that specifies the path to a certificate store that will be used for the + * "javax.net.ssl.trustStore" system property. + */ + public static final String TRUSTSTORE = "TRUSTSTORE"; + + /** + * Name of the environment variable that specifies the type (e.g. "jks" or "pkcs12") of the file named by the + * TRUSTSTORE environment variable. This is used for the "javax.net.ssl.trustStoreType" system property. + */ + public static final String TRUSTSTORE_TYPE = "TRUSTSTORE_TYPE"; + + /** + * Name of the environment variable that specifies the password for the trsut store. Users should prefer to use the + * TRUSTSTORE_PASSWORD_FILE environment variable instead. + */ + public static final String TRUSTSTORE_PASSWORD = "TRUSTSTORE_PASSWORD"; + + /** + * Suffix to add to the names of the password-related environment variables that will instruct us to get it from + * the named file, rather than from the value of the environment variable itself. + */ + public static final String FILE_SUFFIX = "_FILE"; + + /** + * Name of the environment variable that, if set to a non-empty value, will cause this class to emit some + * information about where it loaded certificates from. + */ + public static final String SHOW_CMD = "SHOW_CMD"; + + public static void main(String[] args) throws Exception { + // Run CA check and renewal + LocalCAUtility.checkAndRenewCertificates(); + + String showCmdEnv = System.getenv(SHOW_CMD); + boolean debug = nonBlank(showCmdEnv); + + // We're assuming that the startup script will have set values for these things so that we don't have to check + // for empty/non-set values. + String keyStoreEnv = System.getenv(KEYSTORE); + String keyStoreTypeEnv = System.getenv(KEYSTORE_TYPE); + + PasswordValue keyStorePassword; + File appSecrets = new File(".app_secrets"); + if (appSecrets.exists()) { + String val = firstLineOfFile(appSecrets.getAbsolutePath()); + keyStorePassword = new PasswordValue(val, "KEYSTORE_PASSWORD_FILE", appSecrets.getAbsolutePath(), PasswordSpecifier.FILE_ENVIRONMENT_VARIABLE); + } else { + keyStorePassword = getPasswordValue(KEYSTORE_PASSWORD, null); + if (keyStorePassword.getValue() == null) { + throw new IOException("CRITICAL ERROR: .app_secrets not found and KEYSTORE_PASSWORD not set. Cannot load keystore password. Halting startup."); + } + } + + System.setProperty("javax.net.ssl.keyStore", keyStoreEnv); + System.setProperty("javax.net.ssl.keyStoreType", keyStoreTypeEnv); + System.setProperty("javax.net.ssl.keyStorePassword", keyStorePassword.getValue()); + + String trustStoreEnv = System.getenv(TRUSTSTORE); + String trustStoreTypeEnv = System.getenv(TRUSTSTORE_TYPE); + PasswordValue trustStorePassword; + if (appSecrets.exists()) { + String val = firstLineOfFile(appSecrets.getAbsolutePath()); + trustStorePassword = new PasswordValue(val, "TRUSTSTORE_PASSWORD_FILE", appSecrets.getAbsolutePath(), PasswordSpecifier.FILE_ENVIRONMENT_VARIABLE); + } else { + trustStorePassword = getPasswordValue(TRUSTSTORE_PASSWORD, null); + if (trustStorePassword.getValue() == null) { + throw new IOException("CRITICAL ERROR: TRUSTSTORE_PASSWORD not set. Cannot load truststore password. Halting startup."); + } + } + + System.setProperty("javax.net.ssl.trustStore", trustStoreEnv); + System.setProperty("javax.net.ssl.trustStoreType", trustStoreTypeEnv); + System.setProperty("javax.net.ssl.trustStorePassword", trustStorePassword.getValue()); + + if (debug) { + System.out.println("Key store: " + keyStoreEnv); + System.out.println("Key store type: " + keyStoreTypeEnv); + System.out.println("Key store password: " + keyStorePassword.getDescription()); + + System.out.println("Trust store: " + trustStoreEnv); + System.out.println("Trust store type: " + trustStoreTypeEnv); + System.out.println("Trust store password: " + trustStorePassword.getDescription()); + } + + SensorHub.main(args); + } + + /** + * Utility method for getting passwords from environment variables. + * + * We're assuming that passwords will be provided in one of two ways: (1) by specifying a "secret file" in an + * environment variable named "XXX_FILE", whose content is the password, or (2) by specifying the password + * directly in an environment variable named just "XXX" (without the "_FILE" prefix). + * + * This method here checks the "_FILE" version first, and if it's present, returns the content of the file as a + * String. Otherwise it will look for plain "XXX" and return the value of that environment variable, if present. + * And if neither is present, will return the default value given as the second parameter. + */ + private static PasswordValue getPasswordValue(String envVarName, String defaultValue) throws IOException { + String fileEnvVarName = envVarName + FILE_SUFFIX; + + String filename = System.getenv(fileEnvVarName); + if (nonBlank(filename)) { + String value = firstLineOfFile(filename); + return new PasswordValue(value, fileEnvVarName, filename, PasswordSpecifier.FILE_ENVIRONMENT_VARIABLE); + } else { + String value = System.getenv(envVarName); + if (nonBlank(value)) { + return new PasswordValue(value, envVarName, null, PasswordSpecifier.ENVIRONMENT_VARIABLE); + } else { + return new PasswordValue(value, null, null, PasswordSpecifier.DEFAULT_VALUE); + } + } + } + + /** + * Reads the first line of a file and returns it as a String. Assumes UTF-8 encoding in the file. Does not include + * the line terminator in the return value. + */ + private static String firstLineOfFile(String path) throws IOException { + try (FileInputStream fileIn = new FileInputStream(path); + InputStreamReader fileReader = new InputStreamReader(fileIn, StandardCharsets.UTF_8); + BufferedReader bufferedReader = new BufferedReader(fileReader)) { + return bufferedReader.readLine(); + } + } + + /** + * Returns true if the given string is non-null and has length greater than zero. Returns false otherwise. + */ + private static boolean nonBlank(String s) { + return (s != null) && (s.length() > 0); + } + + public enum PasswordSpecifier { + ENVIRONMENT_VARIABLE, + FILE_ENVIRONMENT_VARIABLE, + DEFAULT_VALUE + } + + public static class PasswordValue { + private final String value; + private final String envVarName; + private final String filename; + private final PasswordSpecifier how; + + public PasswordValue(String value, String envVarName, String filename, PasswordSpecifier how) { + this.value = value; + this.envVarName = envVarName; + this.filename = filename; + this.how = how; + } + + public String getValue() { + return value; + } + + public String getEnvVarName() { + return envVarName; + } + + public String getFilename() { + return filename; + } + + public PasswordSpecifier getHow() { + return how; + } + + public String getDescription() { + switch (how) { + case ENVIRONMENT_VARIABLE: + return "Retrieved from environment variable \"" + envVarName + "\""; + case FILE_ENVIRONMENT_VARIABLE: + return "Retrieved from file \"" + filename + "\" (specified in environment variable \"" + envVarName + "\")"; + case DEFAULT_VALUE: + return "Using default value"; + default: + return "Unknown"; + } + } + } +} diff --git a/security-utils/src/test/java/com/botts/impl/security/LocalCAUtilityTest.java b/security-utils/src/test/java/com/botts/impl/security/LocalCAUtilityTest.java new file mode 100644 index 0000000..e82e03c --- /dev/null +++ b/security-utils/src/test/java/com/botts/impl/security/LocalCAUtilityTest.java @@ -0,0 +1,55 @@ +package com.botts.impl.security; + +import org.junit.Assert; +import org.junit.Test; +import java.io.File; +import java.nio.file.Files; +import java.security.KeyStore; +import java.security.cert.X509Certificate; +import java.util.Date; + +public class LocalCAUtilityTest { + + @Test + public void testInitialGeneration() throws Exception { + String keystorePath = "osh-keystore.p12"; + String secretsPath = ".app_secrets"; + String rootCaPath = "root-ca.crt"; + + // Clean up + new File(keystorePath).delete(); + new File(secretsPath).delete(); + new File(rootCaPath).delete(); + + LocalCAUtility.checkAndRenewCertificates(); + + Assert.assertTrue(new File(keystorePath).exists()); + Assert.assertTrue(new File(secretsPath).exists()); + Assert.assertTrue(new File(rootCaPath).exists()); + + String password = Files.readAllLines(new File(secretsPath).toPath()).get(0).trim(); + KeyStore ks = KeyStore.getInstance("PKCS12"); + try (java.io.FileInputStream fis = new java.io.FileInputStream(keystorePath)) { + ks.load(fis, password.toCharArray()); + } + + Assert.assertTrue(ks.containsAlias("root-ca")); + Assert.assertTrue(ks.containsAlias("jetty")); + + X509Certificate rootCert = (X509Certificate) ks.getCertificate("root-ca"); + X509Certificate leafCert = (X509Certificate) ks.getCertificate("jetty"); + + // Root CA should be ~20 years + long rootLifespan = rootCert.getNotAfter().getTime() - rootCert.getNotBefore().getTime(); + Assert.assertTrue(rootLifespan > 1000L * 60 * 60 * 24 * 365 * 19); + + // Leaf should be ~1 year + long leafLifespan = leafCert.getNotAfter().getTime() - leafCert.getNotBefore().getTime(); + Assert.assertTrue(leafLifespan > 1000L * 60 * 60 * 24 * 364); + + // Clean up + new File(keystorePath).delete(); + new File(secretsPath).delete(); + new File(rootCaPath).delete(); + } +} diff --git a/tools/sensorhub-test/src/main/resources/config.json b/tools/sensorhub-test/src/main/resources/config.json index a6fc3df..ebb0b22 100644 --- a/tools/sensorhub-test/src/main/resources/config.json +++ b/tools/sensorhub-test/src/main/resources/config.json @@ -19,19 +19,6 @@ { "objClass": "org.sensorhub.impl.security.BasicSecurityRealmConfig", "users": [ - { - "objClass": "org.sensorhub.impl.security.BasicSecurityRealmConfig$UserConfig", - "id": "admin", - "name": "Administrator", - "password": "oscar", - "roles": [ - "admin" - ], - "allow": [ - "fileserver[af72442c-1ce6-4baa-a126-ed41dda26910]" - ], - "deny": [] - }, { "objClass": "org.sensorhub.impl.security.BasicSecurityRealmConfig$UserConfig", "id": "anonymous", @@ -170,7 +157,7 @@ "url": "localhost:5432", "dbName": "gis", "login": "postgres", - "password": "postgres", + "password": "", "idProviderType": "SEQUENTIAL", "autoCommitPeriod": 10, "useBatch": false, diff --git a/web/oscar-viewer b/web/oscar-viewer index 5cbddb7..6bcfcb7 160000 --- a/web/oscar-viewer +++ b/web/oscar-viewer @@ -1 +1 @@ -Subproject commit 5cbddb7dec77fbea96cfb3c6e8e9a0fe14df2310 +Subproject commit 6bcfcb784b27ea2230c26fd2792a3c47314a2eeb