From 7a323631ef1d3fd8d8a8222d66e330d4e130265b Mon Sep 17 00:00:00 2001 From: Alex Almanza <115671044+earocorn@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:39:39 -0600 Subject: [PATCH 1/2] Fix release for official 3.0.0 (#124) --- build.gradle | 2 +- changelog.md | 9 +++++++++ dist/config/standard/config.json | 5 +++-- include/osh-addons | 2 +- include/osh-oakridge-modules | 2 +- web/oscar-viewer | 2 +- 6 files changed, 16 insertions(+), 6 deletions(-) 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..8696d47 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # OSCAR Build Node Change Log All notable changes to this project will be documented in this file. +## 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 diff --git a/dist/config/standard/config.json b/dist/config/standard/config.json index a88eea6..9572a55 100644 --- a/dist/config/standard/config.json +++ b/dist/config/standard/config.json @@ -94,7 +94,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 +158,8 @@ "initialBuckets": [ "sitemap", "reports", - "videos" + "videos", + "adjudication" ], "fileStoreRootDir": "files", "endPoint": "/buckets", diff --git a/include/osh-addons b/include/osh-addons index ce66c7f..c26e214 160000 --- a/include/osh-addons +++ b/include/osh-addons @@ -1 +1 @@ -Subproject commit ce66c7f8aabddbff05ed07c9bbe07fd0c436d24e +Subproject commit c26e21482a7e1e8bab666d3e857ce54464048896 diff --git a/include/osh-oakridge-modules b/include/osh-oakridge-modules index e3b15fc..907d282 160000 --- a/include/osh-oakridge-modules +++ b/include/osh-oakridge-modules @@ -1 +1 @@ -Subproject commit e3b15fce02cd9eeb1d3a4a365ae6451bf03bcf17 +Subproject commit 907d28284125d805141f58a857d9ec7cc6c9142d diff --git a/web/oscar-viewer b/web/oscar-viewer index 5cbddb7..ef10564 160000 --- a/web/oscar-viewer +++ b/web/oscar-viewer @@ -1 +1 @@ -Subproject commit 5cbddb7dec77fbea96cfb3c6e8e9a0fe14df2310 +Subproject commit ef10564a474850c001f4d6fff5e8665af61764dd From a67b5bd6d998991ee5ba8cbcf53b016f7729cf5a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Mar 2026 10:51:29 +0000 Subject: [PATCH 2/2] Update submodule pointers: Discovery & Planning: Full Stack Containerization and TLS Reverse Proxy --- AI_CONTRIBUTING_RULES.md | 13 + CONTAINERIZATION_PLAN.md | 274 ++++++++++++ MAPPING.md | 15 + README.md | 61 ++- SECURITY_ARCHITECTURE.md | 52 +++ SYSTEM_ARCHITECTURE.md | 53 +++ backup.bat | 32 ++ backup.sh | 27 ++ dist/config/standard/config.json | 6 +- dist/release/launch-all-arm.sh | 31 +- dist/release/launch-all.bat | 34 +- dist/release/launch-all.sh | 31 +- dist/release/postgis/Dockerfile | 11 +- dist/release/postgis/Dockerfile-arm64 | 11 +- dist/release/postgis/init-extensions.sql | 4 + dist/release/postgis/run-postgis-arm.sh | 15 +- dist/release/postgis/run-postgis.bat | 12 +- dist/release/postgis/run-postgis.sh | 15 +- dist/scripts/standard/launch.bat | 53 ++- dist/scripts/standard/launch.sh | 54 ++- dist/scripts/standard/load_trusted_certs.bat | 8 +- dist/scripts/standard/load_trusted_certs.sh | 8 +- docs/ADDING_NEW_RADIATION_PORTAL_MONITOR.md | 45 ++ docs/FEDERATION_PROVISIONING.md | 42 ++ docs/TAILSCALE_CONFIGURATION.md | 64 +++ docs/system_data_flow.svg | 106 +++++ include/osh-addons | 2 +- include/osh-core | 2 +- include/osh-oakridge-modules | 2 +- provision-node.bat | 26 ++ provision-node.sh | 29 ++ restore.bat | 34 ++ restore.sh | 33 ++ security-utils/build.gradle | 2 + .../botts/impl/security/LocalCAUtility.java | 222 ++++++++++ .../botts/impl/security/PBKDF2Credential.java | 292 +++++++------ .../security/PBKDF2CredentialProvider.java | 106 ++--- .../botts/impl/security/SensorHubWrapper.java | 412 +++++++++--------- .../impl/security/LocalCAUtilityTest.java | 55 +++ .../src/main/resources/config.json | 15 +- web/oscar-viewer | 2 +- 41 files changed, 1835 insertions(+), 476 deletions(-) create mode 100644 AI_CONTRIBUTING_RULES.md create mode 100644 CONTAINERIZATION_PLAN.md create mode 100644 MAPPING.md create mode 100644 SECURITY_ARCHITECTURE.md create mode 100644 SYSTEM_ARCHITECTURE.md create mode 100644 backup.bat create mode 100755 backup.sh create mode 100644 docs/ADDING_NEW_RADIATION_PORTAL_MONITOR.md create mode 100644 docs/FEDERATION_PROVISIONING.md create mode 100644 docs/TAILSCALE_CONFIGURATION.md create mode 100644 docs/system_data_flow.svg create mode 100644 provision-node.bat create mode 100755 provision-node.sh create mode 100644 restore.bat create mode 100755 restore.sh create mode 100644 security-utils/src/main/java/com/botts/impl/security/LocalCAUtility.java create mode 100644 security-utils/src/test/java/com/botts/impl/security/LocalCAUtilityTest.java 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..9dbc811 --- /dev/null +++ b/CONTAINERIZATION_PLAN.md @@ -0,0 +1,274 @@ +# 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.1 Service Definitions + +```yaml +services: + osh-postgis: + 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=/run/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 + secrets: + - db_password + 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=/run/secrets/db_password + - KEYSTORE=./osh-keystore.p12 + - KEYSTORE_TYPE=PKCS12 + - TRUSTSTORE=./truststore.jks + - TRUSTSTORE_TYPE=JKS + - SHOW_CMD=true + # JVM Tuning from .env + - JAVA_OPTS=-Xmx${BACKEND_MEM_LIMIT:-2G} -Xms${BACKEND_MEM_LIMIT:-2G} + volumes: + - ./osh-node-oscar/config:/app/config + - ./osh-node-oscar/db:/app/db + - ./osh-node-oscar/files:/app/files + - ./osh-node-oscar/osh-keystore.p12:/app/osh-keystore.p12 + - ./osh-node-oscar/.app_secrets:/app/.app_secrets + - ./osh-node-oscar/truststore.jks:/app/truststore.jks + secrets: + - db_password + 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" + + osh-proxy: + image: caddy:2-alpine + 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 + - ./osh-node-oscar/osh-leaf.crt:/etc/caddy/certs/osh-leaf.crt:ro + - ./osh-node-oscar/osh-leaf.key:/etc/caddy/certs/osh-leaf.key:ro + networks: + - osh-internal + restart: unless-stopped + +networks: + osh-internal: + driver: bridge + +secrets: + db_password: + file: .db_password + +volumes: + caddy_data: + caddy_config: +``` + +### 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 +{ + # Global options +} + +# 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. + +### 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 font-freefont +# 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 font-freefont openssl bash && \ + 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 +# JAVA_OPTS is used to pass memory limits from the .env file +ENTRYPOINT ["/bin/bash", "-c", "java -cp 'lib/*' com.botts.impl.security.LocalCAUtility && if [ ! -f .app_secrets ]; then echo 'CRITICAL ERROR: .app_secrets not found. Halting startup.'; exit 1; fi && export KEYSTORE_PASSWORD=$(head -n 1 .app_secrets) && java $JAVA_OPTS -Djavax.net.ssl.keyStorePassword=$KEYSTORE_PASSWORD -Djavax.net.ssl.trustStorePassword=$KEYSTORE_PASSWORD -cp 'lib/*' com.botts.impl.security.SensorHubWrapper ./config/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 `font-freefont`. +- **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..61f9891 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,25 +45,63 @@ 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. @@ -73,6 +112,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..e1cf59e --- /dev/null +++ b/SYSTEM_ARCHITECTURE.md @@ -0,0 +1,53 @@ +# 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`. +- **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/dist/config/standard/config.json b/dist/config/standard/config.json index 9572a55..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": [] }, { @@ -175,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 c26e214..6c5f05f 160000 --- a/include/osh-addons +++ b/include/osh-addons @@ -1 +1 @@ -Subproject commit c26e21482a7e1e8bab666d3e857ce54464048896 +Subproject commit 6c5f05f05cef8ae26c846081d27f3ec4987aef76 diff --git a/include/osh-core b/include/osh-core index 7af3a11..9893256 160000 --- a/include/osh-core +++ b/include/osh-core @@ -1 +1 @@ -Subproject commit 7af3a119dde5241e19fd94ae459aac192780fc3d +Subproject commit 989325659e88c840258e390acaa651b6dc64917d diff --git a/include/osh-oakridge-modules b/include/osh-oakridge-modules index 907d282..0d67b01 160000 --- a/include/osh-oakridge-modules +++ b/include/osh-oakridge-modules @@ -1 +1 @@ -Subproject commit 907d28284125d805141f58a857d9ec7cc6c9142d +Subproject commit 0d67b01fc3c070609da864ce4a188849363206b5 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 ef10564..de46592 160000 --- a/web/oscar-viewer +++ b/web/oscar-viewer @@ -1 +1 @@ -Subproject commit ef10564a474850c001f4d6fff5e8665af61764dd +Subproject commit de465925325315191370ec10c15229e68c418c21