diff --git a/.gitignore b/.gitignore index add0add..145ab41 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,8 @@ node_modules .DS_Store release +docker/.env +jdbc/lib/classes +jdbc/lib/ojdbc11.jar +jdbc/lib/json.jar +jdbc/lib/kerberos-jdbc-bridge.jar diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..f2e3689 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,20 @@ +# Copy to .env and adjust before: docker compose --env-file .env up -d +# +# Oracle Database Free (container-registry.oracle.com/database/free:latest) +ORACLE_PWD=change_me_oracle_system_password + +# MIT Kerberos realm (must stay uppercase in krb5.conf) +KRB5_REALM=HACKOLADE.LOCAL +KRB5_DOMAIN=hackolade.local + +# Hostnames used in service principals (must match /etc/hosts on Mac/Azure client) +ORACLE_FQDN=oracle-db.hackolade.local +KDC_FQDN=kdc.hackolade.local + +# Kerberos end-user for Hackolade plugin testing (auth method: Kerberos) +KRB_USER=hackolade_krb +KRB_USER_PASSWORD=change_me_krb_user_password + +# KDC master password (kdb5_util) and kadmin/admin principal password +KDC_MASTER_PASSWORD=change_me_kdc_master +KADMIN_PASSWORD=change_me_kadmin diff --git a/docker/KERBEROS-PLATFORMS.md b/docker/KERBEROS-PLATFORMS.md new file mode 100644 index 0000000..6a4d30d --- /dev/null +++ b/docker/KERBEROS-PLATFORMS.md @@ -0,0 +1,28 @@ +# Kerberos platform notes + +## Hackolade plugin + +Kerberos uses **JDBC Thin + Java 11–21**, not Oracle Instant Client or thick mode. + +Works on **macOS, Linux, and Windows** with: + +- MIT Kerberos (`brew install krb5` on Mac) +- Valid ticket (`docker/scripts/mac-kinit.sh` for the local lab) +- Java 21 recommended (`brew install openjdk@21`) + +Test from the plugin repo: + +```bash +npm install +node docker/scripts/test-jdbc-kerberos.js +``` + +See [../docs/KERBEROS-JDBC.md](../docs/KERBEROS-JDBC.md). + +## macOS + Instant Client (password / OS auth only) + +Instant Client on macOS does **not** support database Kerberos (`ORA-12638`). Use JDBC Kerberos in Hackolade, or use **username/password + thick** for non-Kerberos work on Mac. + +## Docker lab + +The KDC and Oracle server in `docker/` are platform-agnostic. Only the Hackolade host needs `kinit` + Java for Kerberos connections. diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..9c25044 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,182 @@ +# Oracle Free + Kerberos local lab + +> **Hackolade Kerberos:** JDBC Thin + Java 11–21 (no Instant Client). See [KERBEROS-PLATFORMS.md](./KERBEROS-PLATFORMS.md) and [../docs/KERBEROS-JDBC.md](../docs/KERBEROS-JDBC.md). + +Docker lab for testing the Hackolade Oracle plugin with **Kerberos (JDBC)** and optional **thick password** auth, using the official image you already pulled: + +`container-registry.oracle.com/database/free:latest` + +The same `docker-compose.yml` runs on **macOS (M4)** and can be copied to an **Azure Linux VM** with minimal changes. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Mac / Azure VM (Hackolade host) │ +│ • Java 11–21 + npm install (JDBC Kerberos artifacts) │ +│ • krb5.conf + kinit → TGT (MIT krb5) │ +└───────────────────────────┬─────────────────────────────────┘ + │ :1521 / :88 +┌───────────────────────────▼─────────────────────────────────┐ +│ Docker network (kerbnet) │ +│ ┌──────────────┐ ┌────────────────────────────────────┐ │ +│ │ MIT KDC │ │ Oracle Database Free │ │ +│ │ kdc.hackolade│ │ oracle-db.hackolade.local │ │ +│ │ .local │ │ SPN: oracle/oracle-db...@REALM │ │ +│ └──────────────┘ │ keytab + sqlnet.ora (server only) │ │ +│ └────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Important:** Hackolade does not run inside Docker. Only the database and KDC do. Kerberos tickets and Java live on the Hackolade host. + +## Prerequisites + +- Docker Desktop (Mac M4) or Docker Engine (Azure VM) +- Logged in to Oracle Container Registry: + `docker login container-registry.oracle.com` +- Image already pulled: `database/free:latest` +- For Kerberos: **Java 21** (`brew install openjdk@21 krb5`) — no Instant Client +- For password + thick on Mac: [Instant Client for macOS ARM64](https://www.oracle.com/database/technologies/instant-client/macos-arm64-downloads.html) (optional) + +### About `database/adb-free` + +`adb-free` is Autonomous Database Free (wallet/TLS, different ports). This lab targets **`database/free`** (listener on 1521, `FREEPDB1`). Use adb-free only if you extend the compose file yourself. + +## Quick start + +```bash +cd docker +cp .env.example .env +chmod +x scripts/*.sh kdc/entrypoint.sh kerberos-setup/setup.sh oracle/scripts/startup/*.sh + +./scripts/bootstrap.sh +``` + +First Oracle startup often takes **10–15 minutes**. Bootstrap waits for Oracle health, then runs `./scripts/fix-kerberos-principals.sh` (uses `kadmin.local` inside the KDC — reliable for `ktadd`). + +If Oracle exits with code **137**, increase Docker Desktop memory (4–6 GB minimum). + +### Host name resolution + +Add to `/etc/hosts` on the machine running Hackolade: + +``` +127.0.0.1 oracle-db.hackolade.local kdc.hackolade.local +``` + +See `scripts/hosts-snippet.txt`. + +### macOS client (Kerberos via JDBC — recommended) + +1. **Java 21** (JDK 25 breaks Oracle Kerberos in JDBC): + ```bash + brew install openjdk@21 krb5 + ``` +2. Build the JDBC bridge and obtain a ticket: + ```bash + cd docker && ./scripts/mac-kinit.sh + cd .. && npm run build:jdbc + ./docker/scripts/ensure-kerberos-db-user.sh # if DB existed before Kerberos setup + node docker/scripts/test-jdbc-kerberos.js # expect: SUCCESS: [ [ 1 ] ] + ``` +3. Package the plugin and point Hackolade at it: + ```bash + npm ci && npm run package + # copy release to ~/.hackolade/plugins/Oracle (or your pluginPath) + ``` +4. Hackolade connection: + + | Field | Value | + |--------|--------| + | Auth | Kerberos | + | Java path | `/opt/homebrew/opt/openjdk@21/bin/java` | + | Host | `oracle-db.hackolade.local` | + | Port | `1521` | + | Service | `FREEPDB1` | + | User | `hackolade_krb` (or empty for ticket-only mapping) | + | Ticket cache | default `~/.hackolade/krb5cc_hackolade` | + +No Instant Client is required for Kerberos when using JDBC. + +### Smoke tests + +```bash +# Password (no Kerberos) — from host +./scripts/smoke-password.sh + +# Kerberos JDBC (cross-platform, no Instant Client) +cd .. # Oracle plugin repo root +npm run build:jdbc +node docker/scripts/test-jdbc-kerberos.js +``` + +Instant Client **Basic Light** does not include SQL*Plus. To add it, download the separate **SQL*Plus** package for macOS ARM64 from Oracle and unzip into the same `instantclient_*` folder. + +## Manual steps (if you prefer) + +```bash +docker compose up -d +./scripts/fix-kerberos-principals.sh +``` + +The `kerberos-setup` compose profile uses remote `kadmin` and may fail on `ktadd`; prefer `fix-kerberos-principals.sh`. + +## Reset lab + +```bash +docker compose down -v # removes DB + KDC + Kerberos volume data +``` + +## Port to Azure VM + +1. Create an Ubuntu 22.04+ VM (or RHEL). **4 GB+ RAM** recommended for Oracle Free. +2. Install Docker and compose plugin; login to `container-registry.oracle.com`. +3. Copy the entire `docker/` directory to the VM. +4. Open NSG / firewall: **1521** (Oracle), **88** tcp/udp (Kerberos, if clients connect from outside the VM). +5. In `.env`, you can keep the same `ORACLE_FQDN` / `KDC_FQDN` if clients use VM public IP in `/etc/hosts`: + + ``` + oracle-db.hackolade.local kdc.hackolade.local + ``` + +6. Run `./scripts/bootstrap.sh` on the VM. +7. Run Hackolade on your Mac or a jump box with `/etc/hosts` pointing at the VM IP, **Java 21**, and `kinit` (port 88 to the KDC if remote). + +## Troubleshooting + +| Symptom | Check | +|--------|--------| +| Oracle container unhealthy / OOM | Increase Docker memory (4 GB+); try `free:latest-lite` if memory protection key errors (see [oracle/docker-images#2972](https://github.com/oracle/docker-images/issues/2972)) | +| `kinit: CLIENT_NOT_FOUND` | Run `./scripts/fix-kerberos-principals.sh` | +| `kinit: Password incorrect` | Run `./scripts/reset-krb-password.sh` then use exact `KRB_USER_PASSWORD` from `.env` (watch `!` in zsh) | +| `ktadd` / `change-password` privilege / missing `v5srvtab` | Do **not** use `kerberos-setup` profile — run `./scripts/fix-kerberos-principals.sh` | +| Oracle exit **137** during bootstrap | Docker OOM — allocate more RAM to Docker Desktop | +| `ORA-01017` (Kerberos JDBC) | Run `./scripts/ensure-kerberos-db-user.sh` — external user may be missing in FREEPDB1 | +| `ORA-12655` | Server: run `./scripts/fix-kerberos-principals.sh`; client: `klist` and `./scripts/mac-kinit.sh` | +| `ORA-28040` / auth failure | `klist` — re-`kinit`; ticket expired | +| SPN / hostname mismatch | Host in connect string must match `oracle/${ORACLE_FQDN}@REALM` — use FQDN, not `localhost` | +| Thick `DPI-1047` on M4 | Use **ARM64** Instant Client, not x86 | +| Kerberos not applied in DB | Re-run setup + restart: `docker compose --profile setup run --rm kerberos-setup && docker compose restart oracle` | +| User missing in PDB | `./scripts/ensure-kerberos-db-user.sh` | + +## Files + +| Path | Role | +|------|------| +| `docker-compose.yml` | KDC + Oracle Free + optional setup job | +| `kdc/` | MIT KDC image | +| `kerberos-setup/` | Principals + keytab + server `sqlnet.ora` | +| `oracle/scripts/setup/` | `EXTERNALLY` Oracle user (first DB init) | +| `oracle/scripts/startup/` | Apply Kerberos config each start | +| `client/macos/` | Host `krb5.conf` template | + +## Default principals + +| Principal | Purpose | +|-----------|---------| +| `oracle/oracle-db.hackolade.local@HACKOLADE.LOCAL` | Database service (keytab) | +| `hackolade_krb@HACKOLADE.LOCAL` | Hackolade end user (`kinit`) | +| `admin/admin@HACKOLADE.LOCAL` | kadmin (setup only) | + +Oracle DB user: `hackolade_krb` identified as `hackolade_krb@HACKOLADE.LOCAL` in `FREEPDB1`. diff --git a/docker/client/hackolade-connection.example.json b/docker/client/hackolade-connection.example.json new file mode 100644 index 0000000..302c3fe --- /dev/null +++ b/docker/client/hackolade-connection.example.json @@ -0,0 +1,12 @@ +{ + "comment": "Example Hackolade Oracle connection for Kerberos JDBC (local Docker lab)", + "name": "Oracle Free Kerberos (local Docker)", + "connectionMethod": "Basic", + "host": "oracle-db.hackolade.local", + "port": 1521, + "identifierType": "serviceName", + "serviceName": "FREEPDB1", + "authMethod": "Kerberos", + "javaPath": "/opt/homebrew/opt/openjdk@21/bin/java", + "userName": "hackolade_krb" +} diff --git a/docker/client/macos/krb5.conf b/docker/client/macos/krb5.conf new file mode 100644 index 0000000..2e094a3 --- /dev/null +++ b/docker/client/macos/krb5.conf @@ -0,0 +1,22 @@ +# Install as /etc/krb5.conf or set KRB5_CONFIG to this file before kinit. +# Points at the KDC exposed on localhost (Docker port mapping). + +[libdefaults] + default_realm = HACKOLADE.LOCAL + dns_lookup_realm = false + dns_lookup_kdc = false + rdns = false + ticket_lifetime = 24h + renew_lifetime = 7d + forwardable = true + +[realms] + HACKOLADE.LOCAL = { + kdc = kdc.hackolade.local:88 + admin_server = kdc.hackolade.local:749 + default_domain = hackolade.local + } + +[domain_realm] + .hackolade.local = HACKOLADE.LOCAL + hackolade.local = HACKOLADE.LOCAL diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..6b88d15 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,107 @@ +# Local Oracle Free + MIT Kerberos lab for Hackolade plugin testing (Kerberos JDBC). +# Same layout can run on an Azure VM (Linux) with the same images and .env. +# +# Usage: +# cp .env.example .env +# docker compose up -d +# docker compose --profile setup run --rm kerberos-setup +# See README.md for Mac client (/etc/hosts, kinit, Instant Client). + +services: + kdc: + build: + context: ./kdc + dockerfile: Dockerfile + container_name: hackolade-kdc + hostname: kdc.hackolade.local + environment: + KRB5_REALM: ${KRB5_REALM:-HACKOLADE.LOCAL} + KDC_MASTER_PASSWORD: ${KDC_MASTER_PASSWORD} + KADMIN_PASSWORD: ${KADMIN_PASSWORD} + volumes: + - kdc-data:/var/kerberos/krb5kdc + - ./krb5/krb5.conf:/etc/krb5.conf:ro + - ./krb5/kdc.conf:/etc/krb5kdc/kdc.conf:ro + - ./krb5/kadm5.acl:/etc/krb5kdc/kadm5.acl:ro + ports: + - "88:88/tcp" + - "88:88/udp" + - "749:749/tcp" + networks: + kerbnet: + aliases: + - kdc.hackolade.local + healthcheck: + test: ["CMD-SHELL", "kadmin.local -q 'list_principals' 2>/dev/null | grep -q admin"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 15s + + oracle: + image: container-registry.oracle.com/database/free:latest + container_name: hackolade-oracle-free + hostname: oracle-db.hackolade.local + depends_on: + kdc: + condition: service_healthy + environment: + ORACLE_PWD: ${ORACLE_PWD} + shm_size: "2gb" + volumes: + - oracle-data:/opt/oracle/oradata + - kerberos-config:/opt/oracle/kerberos + - ./krb5/krb5.conf:/etc/krb5.conf:ro + - ./oracle/scripts/setup:/opt/oracle/scripts/setup:ro + - ./oracle/scripts/startup:/opt/oracle/scripts/startup:ro + ports: + - "1521:1521" + networks: + kerbnet: + aliases: + - oracle-db.hackolade.local + healthcheck: + test: + [ + "CMD-SHELL", + "echo \"SELECT 1 FROM DUAL;\" | sqlplus -s -L system/\"$$ORACLE_PWD\"@//localhost:1521/FREEPDB1 | grep -q 1", + ] + interval: 30s + timeout: 15s + retries: 20 + start_period: 600s + + # One-shot: create Kerberos principals + keytab, then signal Oracle startup scripts. + # Run after oracle is healthy: docker compose --profile setup run --rm kerberos-setup + kerberos-setup: + profiles: ["setup"] + build: + context: ./kerberos-setup + dockerfile: Dockerfile + container_name: hackolade-kerberos-setup + environment: + KRB5_REALM: ${KRB5_REALM:-HACKOLADE.LOCAL} + ORACLE_FQDN: ${ORACLE_FQDN:-oracle-db.hackolade.local} + KRB_USER: ${KRB_USER:-hackolade_krb} + KRB_USER_PASSWORD: ${KRB_USER_PASSWORD} + KADMIN_PASSWORD: ${KADMIN_PASSWORD} + volumes: + - kerberos-config:/kerberos + - ./krb5/krb5.conf:/etc/krb5.conf:ro + - ./oracle/config/sqlnet.ora.template:/templates/sqlnet.ora.template:ro + depends_on: + kdc: + condition: service_healthy + oracle: + condition: service_healthy + networks: + - kerbnet + +volumes: + kdc-data: + oracle-data: + kerberos-config: + +networks: + kerbnet: + driver: bridge diff --git a/docker/kdc/Dockerfile b/docker/kdc/Dockerfile new file mode 100644 index 0000000..473ac44 --- /dev/null +++ b/docker/kdc/Dockerfile @@ -0,0 +1,10 @@ +FROM rockylinux:9 + +RUN dnf install -y krb5-server krb5-workstation && dnf clean all + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 88/tcp 88/udp 749/tcp + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/kdc/entrypoint.sh b/docker/kdc/entrypoint.sh new file mode 100755 index 0000000..57eb182 --- /dev/null +++ b/docker/kdc/entrypoint.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +REALM="${KRB5_REALM:-HACKOLADE.LOCAL}" +MASTER_PW="${KDC_MASTER_PASSWORD:?KDC_MASTER_PASSWORD is required}" +KADMIN_PW="${KADMIN_PASSWORD:?KADMIN_PASSWORD is required}" + +PRINCIPAL_DB="/var/kerberos/krb5kdc/principal" + +if [[ ! -f "${PRINCIPAL_DB}" ]]; then + echo "Creating Kerberos realm ${REALM}..." + kdb5_util create -s -P "${MASTER_PW}" + kadmin.local -q "addprinc -pw ${KADMIN_PW} admin/admin@${REALM}" + echo "Realm ${REALM} created." +fi + +echo "Starting krb5kdc and kadmind..." +krb5kdc +exec kadmind -nofork diff --git a/docker/kerberos-setup/Dockerfile b/docker/kerberos-setup/Dockerfile new file mode 100644 index 0000000..9e150c6 --- /dev/null +++ b/docker/kerberos-setup/Dockerfile @@ -0,0 +1,8 @@ +FROM rockylinux:9 + +RUN dnf install -y krb5-workstation && dnf clean all + +COPY setup.sh /setup.sh +RUN chmod +x /setup.sh + +ENTRYPOINT ["/setup.sh"] diff --git a/docker/kerberos-setup/setup.sh b/docker/kerberos-setup/setup.sh new file mode 100755 index 0000000..8dca0e4 --- /dev/null +++ b/docker/kerberos-setup/setup.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +REALM="${KRB5_REALM:-HACKOLADE.LOCAL}" +ORACLE_FQDN="${ORACLE_FQDN:-oracle-db.hackolade.local}" +KRB_USER="${KRB_USER:-hackolade_krb}" +KRB_USER_PASSWORD="${KRB_USER_PASSWORD:?KRB_USER_PASSWORD is required}" +KADMIN_PW="${KADMIN_PASSWORD:?KADMIN_PASSWORD is required}" + +SERVICE_PRINCIPAL="oracle/${ORACLE_FQDN}@${REALM}" +USER_PRINCIPAL="${KRB_USER}@${REALM}" +KERB_DIR="/kerberos" +KEYTAB="${KERB_DIR}/v5srvtab" +MARKER="${KERB_DIR}/.configured" + +echo "Waiting for KDC (kadmin)..." +for i in $(seq 1 60); do + if kadmin -p "admin/admin@${REALM}" -w "${KADMIN_PW}" -q "list_principals" 2>/dev/null | grep -q "admin/admin@${REALM}"; then + break + fi + sleep 2 +done + +KADMIN=(kadmin -p "admin/admin@${REALM}" -w "${KADMIN_PW}") + +run_kadmin() { + "${KADMIN[@]}" -q "$1" +} + +echo "Creating Kerberos principals..." +if ! run_kadmin "getprinc ${USER_PRINCIPAL}" 2>/dev/null; then + run_kadmin "addprinc -pw ${KRB_USER_PASSWORD} ${USER_PRINCIPAL}" +else + run_kadmin "change_password -pw ${KRB_USER_PASSWORD} ${USER_PRINCIPAL}" || true +fi + +if ! run_kadmin "getprinc ${SERVICE_PRINCIPAL}" 2>/dev/null; then + run_kadmin "addprinc -randkey ${SERVICE_PRINCIPAL}" +fi + +echo "Extracting service keytab..." +rm -f "${KEYTAB}" +run_kadmin "ktadd -k ${KEYTAB} ${SERVICE_PRINCIPAL}" +chmod 640 "${KEYTAB}" + +echo "Writing Oracle Kerberos network config..." +sed \ + -e "s|@ORACLE_FQDN@|${ORACLE_FQDN}|g" \ + -e "s|@KRB5_REALM@|${REALM}|g" \ + /templates/sqlnet.ora.template >"${KERB_DIR}/sqlnet.ora" + +cp /etc/krb5.conf "${KERB_DIR}/krb5.conf" 2>/dev/null || true + +cat >"${KERB_DIR}/kerberos.env" </dev/null || true +chmod 640 "${KERB_DIR}/v5srvtab" 2>/dev/null || true +cp "${KERB_DIR}/sqlnet.ora" "${NET_ADMIN}/sqlnet.ora" +chmod 644 "${NET_ADMIN}/sqlnet.ora" + +if [[ -f "${KERB_DIR}/krb5.conf" ]]; then + cp "${KERB_DIR}/krb5.conf" /etc/krb5.conf +fi + +echo "[kerberos] Setting OS_AUTHENT_PREFIX and reloading listener..." +sqlplus -s / as sysdba <<'EOSQL' +WHENEVER SQLERROR EXIT SQL.SQLCODE +ALTER SYSTEM SET OS_AUTHENT_PREFIX='' SCOPE=MEMORY; +ALTER SYSTEM SET OS_AUTHENT_PREFIX='' SCOPE=SPFILE; +EOSQL + +"${ORACLE_HOME}/bin/lsnrctl" reload 2>/dev/null || "${ORACLE_HOME}/bin/lsnrctl" status || true + +echo "[kerberos] Configuration applied." diff --git a/docker/scripts/bootstrap.sh b/docker/scripts/bootstrap.sh new file mode 100755 index 0000000..c131cd8 --- /dev/null +++ b/docker/scripts/bootstrap.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Full local lab bootstrap (run from docker/ directory). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "${ROOT}" + +if [[ ! -f .env ]]; then + echo "Creating .env from .env.example — review passwords before production use." + cp .env.example .env +fi + +echo "==> Starting KDC and Oracle Database Free..." +docker compose up -d kdc oracle + +echo "==> Waiting for Oracle health (first start can take 10–15 minutes)..." +ORACLE_CONTAINER="${ORACLE_CONTAINER:-hackolade-oracle-free}" +for _ in $(seq 1 80); do + state="$(docker inspect "${ORACLE_CONTAINER}" --format '{{.State.Status}}' 2>/dev/null || echo unknown)" + health="$(docker inspect "${ORACLE_CONTAINER}" --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' 2>/dev/null || echo none)" + + if [[ "${state}" == "exited" ]]; then + exit_code="$(docker inspect "${ORACLE_CONTAINER}" --format '{{.State.ExitCode}}' 2>/dev/null)" + echo "ERROR: Oracle container exited (code ${exit_code})." + if [[ "${exit_code}" == "137" ]]; then + echo " Exit 137 usually means OOM — give Docker Desktop at least 4–6 GB RAM." + fi + docker logs "${ORACLE_CONTAINER}" --tail 40 2>&1 || true + exit 1 + fi + + if [[ "${health}" == "healthy" ]]; then + break + fi + + sleep 15 + echo " state=${state} health=${health} — still starting..." +done + +if [[ "${health:-}" != "healthy" ]]; then + echo "ERROR: Oracle did not become healthy in time." + docker compose ps + exit 1 +fi + +echo "==> Configuring Kerberos (kadmin.local — not remote kadmin)..." +"${ROOT}/scripts/fix-kerberos-principals.sh" + +echo "" +echo "Bootstrap complete." +echo "" +echo "Host setup (Kerberos JDBC):" +echo " 1. Add to /etc/hosts (see scripts/hosts-snippet.txt)" +echo " 2. sudo cp client/macos/krb5.conf /etc/krb5.conf # or export KRB5_CONFIG=..." +echo " 3. ./scripts/mac-kinit.sh" +echo " 4. cd .. && npm run build:jdbc && node docker/scripts/test-jdbc-kerberos.js" +echo " 5. Hackolade: auth Kerberos + Java 21 — see README.md" +echo "" +echo "Password auth smoke test:" +echo " ./scripts/smoke-password.sh" diff --git a/docker/scripts/ensure-kerberos-db-user.sh b/docker/scripts/ensure-kerberos-db-user.sh new file mode 100755 index 0000000..f008a93 --- /dev/null +++ b/docker/scripts/ensure-kerberos-db-user.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Create hackolade_krb EXTERNALLY user in FREEPDB1 (idempotent). +# Needed when the DB was created before 01_create_kerberos_user.sql ran. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=/dev/null +source "${ROOT}/.env" + +ORACLE_CONTAINER="${ORACLE_CONTAINER:-hackolade-oracle-free}" +ORACLE_PWD="${ORACLE_PWD:?Set ORACLE_PWD in docker/.env}" + +SQL_FILE="$(mktemp)" +trap 'rm -f "${SQL_FILE}"' EXIT + +cat >"${SQL_FILE}" <<'SQL' +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET SERVEROUTPUT ON +DECLARE + user_exists EXCEPTION; + PRAGMA EXCEPTION_INIT(user_exists, -01920); +BEGIN + EXECUTE IMMEDIATE q'[ + CREATE USER hackolade_krb IDENTIFIED EXTERNALLY AS 'hackolade_krb@HACKOLADE.LOCAL' + ]'; + DBMS_OUTPUT.PUT_LINE('Created user HACKOLADE_KRB'); +EXCEPTION + WHEN user_exists THEN + DBMS_OUTPUT.PUT_LINE('User hackolade_krb already exists'); +END; +/ +GRANT CREATE SESSION TO hackolade_krb; +GRANT SELECT ANY DICTIONARY TO hackolade_krb; +GRANT SELECT_CATALOG_ROLE TO hackolade_krb; +SELECT username, external_name, authentication_type FROM dba_users WHERE username = 'HACKOLADE_KRB'; +EXIT +SQL + +docker exec "${ORACLE_CONTAINER}" bash -lc " +mkdir -p /tmp/admin_sqlnet +printf '%s\n' 'SQLNET.AUTHENTICATION_SERVICES=(NONE)' > /tmp/admin_sqlnet/sqlnet.ora +export TNS_ADMIN=/tmp/admin_sqlnet +sqlplus -s system/${ORACLE_PWD}@localhost:1521/FREEPDB1 +" <"${SQL_FILE}" + +echo "Kerberos DB user ensured in FREEPDB1." diff --git a/docker/scripts/fix-kerberos-principals.sh b/docker/scripts/fix-kerberos-principals.sh new file mode 100755 index 0000000..cbc5bfe --- /dev/null +++ b/docker/scripts/fix-kerberos-principals.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Create principals + keytab via kadmin.local (works reliably; remote kadmin ktadd often fails). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "${ROOT}" +# shellcheck source=/dev/null +source "${ROOT}/.env" + +REALM="${KRB5_REALM:-HACKOLADE.LOCAL}" +ORACLE_FQDN="${ORACLE_FQDN:-oracle-db.hackolade.local}" +SERVICE_PRINCIPAL="oracle/${ORACLE_FQDN}@${REALM}" +USER_PRINCIPAL="${KRB_USER}@${REALM}" +KDC_CONTAINER="${KDC_CONTAINER:-hackolade-kdc}" +ORACLE_CONTAINER="${ORACLE_CONTAINER:-hackolade-oracle-free}" +KERB_MOUNT="/opt/oracle/kerberos" + +kadmin_local() { + docker exec "${KDC_CONTAINER}" kadmin.local -q "$1" +} + +set_user_password() { + docker exec -e KRB_PW="${KRB_USER_PASSWORD}" "${KDC_CONTAINER}" bash -c \ + "kadmin.local -q \"change_password -pw \\\"\$KRB_PW\\\" ${USER_PRINCIPAL}\"" +} + +echo "==> Creating principals in KDC (kadmin.local)..." +if ! kadmin_local "getprinc ${USER_PRINCIPAL}" >/dev/null 2>&1; then + docker exec -e KRB_PW="${KRB_USER_PASSWORD}" "${KDC_CONTAINER}" bash -c \ + "kadmin.local -q \"addprinc -pw \\\"\$KRB_PW\\\" ${USER_PRINCIPAL}\"" +else + echo " ${USER_PRINCIPAL} exists — syncing password from .env" + set_user_password +fi + +if ! docker exec "${KDC_CONTAINER}" kadmin.local -q "getprinc ${SERVICE_PRINCIPAL}" >/dev/null 2>&1; then + docker exec "${KDC_CONTAINER}" kadmin.local -q "addprinc -randkey ${SERVICE_PRINCIPAL}" +else + echo " ${SERVICE_PRINCIPAL} already exists" +fi + +echo "==> Writing keytab and sqlnet.ora into Oracle volume..." +docker exec "${KDC_CONTAINER}" rm -f /tmp/v5srvtab +docker exec "${KDC_CONTAINER}" kadmin.local -q "ktadd -k /tmp/v5srvtab ${SERVICE_PRINCIPAL}" + +TMP_KEYTAB="$(mktemp)" +trap 'rm -f "${TMP_KEYTAB}" "${TMP_SQLNET}"' EXIT +docker cp "${KDC_CONTAINER}:/tmp/v5srvtab" "${TMP_KEYTAB}" + +TMP_SQLNET="$(mktemp)" +sed \ + -e "s|@ORACLE_FQDN@|${ORACLE_FQDN}|g" \ + -e "s|@KRB5_REALM@|${REALM}|g" \ + "${ROOT}/oracle/config/sqlnet.ora.template" >"${TMP_SQLNET}" + +docker exec "${ORACLE_CONTAINER}" mkdir -p "${KERB_MOUNT}" +docker cp "${TMP_KEYTAB}" "${ORACLE_CONTAINER}:${KERB_MOUNT}/v5srvtab" +docker cp "${TMP_SQLNET}" "${ORACLE_CONTAINER}:${KERB_MOUNT}/sqlnet.ora" +docker exec -u oracle "${ORACLE_CONTAINER}" chmod 640 "${KERB_MOUNT}/v5srvtab" 2>/dev/null || true +docker exec "${ORACLE_CONTAINER}" touch "${KERB_MOUNT}/.configured" + +echo "==> Restarting Oracle to apply Kerberos config..." +docker compose restart oracle + +echo "==> Ensuring EXTERNALLY user in FREEPDB1..." +"${ROOT}/scripts/ensure-kerberos-db-user.sh" + +echo "" +echo "Done. Principals:" +docker exec "${KDC_CONTAINER}" kadmin.local -q "list_principals ${KRB_USER}* oracle/*" 2>/dev/null || true +echo "" +echo "Test on Mac (JDBC — works on all platforms):" +echo " ./scripts/mac-kinit.sh" +echo " cd .. && npm run build:jdbc && node docker/scripts/test-jdbc-kerberos.js" diff --git a/docker/scripts/hosts-snippet.txt b/docker/scripts/hosts-snippet.txt new file mode 100644 index 0000000..2758c36 --- /dev/null +++ b/docker/scripts/hosts-snippet.txt @@ -0,0 +1,2 @@ +# Add these lines to /etc/hosts on your Mac (or Azure VM client) so Kerberos SPN hostnames resolve. +127.0.0.1 oracle-db.hackolade.local kdc.hackolade.local diff --git a/docker/scripts/mac-kinit.sh b/docker/scripts/mac-kinit.sh new file mode 100755 index 0000000..076f924 --- /dev/null +++ b/docker/scripts/mac-kinit.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Obtain a Kerberos ticket in ~/.hackolade/krb5cc_hackolade (used by Hackolade plugin). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=/dev/null +source "${ROOT}/.env" 2>/dev/null || true + +REALM="${KRB5_REALM:-HACKOLADE.LOCAL}" +USER_PRINCIPAL="${KRB_USER:-hackolade_krb}@${REALM}" +CC_FILE="${KRB5_CC_FILE:-${HOME}/.hackolade/krb5cc_hackolade}" + +mkdir -p "$(dirname "${CC_FILE}")" +export KRB5CCNAME="FILE:${CC_FILE}" +export KRB5_CONFIG="${KRB5_CONFIG:-/etc/krb5.conf}" + +if [[ ! -f "${KRB5_CONFIG}" ]]; then + echo "WARN: ${KRB5_CONFIG} not found." + echo " sudo cp ${ROOT}/client/macos/krb5.conf /etc/krb5.conf" + echo " or: export KRB5_CONFIG=${ROOT}/client/macos/krb5.conf" +fi + +echo "Principal: ${USER_PRINCIPAL}" +echo "Ticket file: ${CC_FILE}" +echo "" + +if [[ -x /opt/homebrew/opt/krb5/bin/kinit ]]; then + KINIT_BIN="/opt/homebrew/opt/krb5/bin/kinit" +elif [[ -x /usr/local/opt/krb5/bin/kinit ]]; then + KINIT_BIN="/usr/local/opt/krb5/bin/kinit" +else + echo "ERROR: MIT Kerberos is required (Apple Heimdal kinit does not work with Oracle Instant Client)." + echo "Install: brew install krb5" + echo "Then re-run this script." + exit 1 +fi + +echo "Using MIT kinit: ${KINIT_BIN}" +"$KINIT_BIN" -V 2>&1 | head -1 || true + +set +e +if "${KINIT_BIN}" -c "${CC_FILE}" "${USER_PRINCIPAL}"; then + : +elif KRB5CCNAME="FILE:${CC_FILE}" "${KINIT_BIN}" "${USER_PRINCIPAL}"; then + : +else + echo "ERROR: kinit failed (exit $?)" + exit 1 +fi +set -e + +if [[ ! -f "${CC_FILE}" ]]; then + echo "ERROR: No ticket file at ${CC_FILE}" + exit 1 +fi + +chmod 600 "${CC_FILE}" 2>/dev/null || true + +echo "" +echo "Ticket OK:" +klist -c "${CC_FILE}" 2>/dev/null || klist + +echo "" +echo "Hackolade plugin expects this cache path (set automatically when you rebuild the plugin)." diff --git a/docker/scripts/reset-krb-password.sh b/docker/scripts/reset-krb-password.sh new file mode 100755 index 0000000..9f2476d --- /dev/null +++ b/docker/scripts/reset-krb-password.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Reset hackolade_krb password to match KRB_USER_PASSWORD in .env +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=/dev/null +source "${ROOT}/.env" + +USER_PRINCIPAL="${KRB_USER}@${KRB5_REALM:-HACKOLADE.LOCAL}" +KDC_CONTAINER="${KDC_CONTAINER:-hackolade-kdc}" + +# Pass password via env inside the container to avoid shell mangling (!, $, etc.) +docker exec -e KRB_PW="${KRB_USER_PASSWORD}" "${KDC_CONTAINER}" bash -c \ + "kadmin.local -q \"change_password -pw \\\"\$KRB_PW\\\" ${USER_PRINCIPAL}\"" + +echo "Password reset for ${USER_PRINCIPAL}" +echo "Run: kinit ${USER_PRINCIPAL}" +echo "Password (from .env KRB_USER_PASSWORD): ${KRB_USER_PASSWORD}" diff --git a/docker/scripts/smoke-password.sh b/docker/scripts/smoke-password.sh new file mode 100755 index 0000000..81d3372 --- /dev/null +++ b/docker/scripts/smoke-password.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Password-based smoke test (thick mode not required). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=/dev/null +source "${ROOT}/.env" + +ORACLE_HOST="${ORACLE_FQDN:-oracle-db.hackolade.local}" + +docker exec hackolade-oracle-free bash -c \ + "echo \"SELECT 'Password auth OK' FROM DUAL;\" | sqlplus -s -L system/\"${ORACLE_PWD}\"@//localhost:1521/FREEPDB1" + +echo "OK: system@${ORACLE_HOST}:1521/FREEPDB1" diff --git a/docker/scripts/test-jdbc-kerberos.js b/docker/scripts/test-jdbc-kerberos.js new file mode 100644 index 0000000..8db384e --- /dev/null +++ b/docker/scripts/test-jdbc-kerberos.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node +/** + * Test Kerberos via JDBC bridge (same path as Hackolade plugin). + * Usage: node docker/scripts/test-jdbc-kerberos.js + */ +'use strict'; + +const path = require('path'); +const jdbcKerberosHelper = require('../../reverse_engineering/helpers/jdbcKerberosHelper'); + +const PLUGIN_PATH = path.resolve(__dirname, '../..'); +const CONNECT_STRING = + process.env.CONNECT_STRING || + '(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=oracle-db.hackolade.local)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=FREEPDB1)))'; + +const log = msg => console.log(JSON.stringify(msg, null, 2)); + +const main = async () => { + await jdbcKerberosHelper.connect({ + pluginPath: PLUGIN_PATH, + connectString: CONNECT_STRING, + userName: process.env.KRB_USER || 'hackolade_krb', + logger: log, + }); + + const rows = await jdbcKerberosHelper.execute('SELECT 1 AS ok FROM DUAL'); + console.log('SUCCESS:', rows); + + await jdbcKerberosHelper.disconnect(); +}; + +main().catch(err => { + console.error('FAILED:', err.message); + process.exit(1); +}); diff --git a/docs/KERBEROS-JDBC.md b/docs/KERBEROS-JDBC.md new file mode 100644 index 0000000..db479c6 --- /dev/null +++ b/docs/KERBEROS-JDBC.md @@ -0,0 +1,36 @@ +# Kerberos authentication (JDBC Thin) + +Hackolade Oracle plugin uses **JDBC Thin + Java** for Kerberos. Thick mode and Instant Client are **not** used for Kerberos sessions. + +## Requirements + +| Item | Notes | +|------|--------| +| Java | **11–21** only (JDK 25 breaks Oracle Kerberos in JDBC) | +| Ticket | MIT `kinit` — default cache `~/.hackolade/krb5cc_hackolade` | +| Build | `npm install` / `npm ci` downloads JARs and compiles the bridge into `jdbc/lib/` (or `npm run build:jdbc`) | +| Thick / IC | Optional for password/OS auth only; **not** required for Kerberos | + +## Connection modal + +| Field | Purpose | +|-------|---------| +| Authentication method | **Kerberos** | +| Java path | e.g. `/opt/homebrew/opt/openjdk@21/bin/java` | +| Kerberos ticket cache | Optional override of default cache path | +| User Name | DB user (`hackolade_krb`) or empty; proxy: `[username]` | +| Mode / Client | Ignored for Kerberos (JDBC path) | + +## Implementation + +- `reverse_engineering/helpers/jdbcKerberosHelper.js` — spawns Java bridge, JSON over stdio +- `jdbc/java/KerberosJdbcBridge.java` — JDBC Thin + Kerberos properties +- `reverse_engineering/helpers/oracleHelper.js` — routes `authMethod === 'Kerberos'` to JDBC before `initOracleClient` + +## Local Docker lab + +See `docker/README.md` and `docker/scripts/test-jdbc-kerberos.js`. + +## Reference + +[JDBC Client-Side Security — Kerberos](https://docs.oracle.com/en/database/oracle/oracle-database/26/jjdbc/client-side-security.html) diff --git a/esbuild.package.js b/esbuild.package.js index 53a3266..cf2ded0 100644 --- a/esbuild.package.js +++ b/esbuild.package.js @@ -53,6 +53,12 @@ esbuild to: [path.join('node_modules', 'oracledb')], }, }), + copy({ + assets: { + from: [path.join('jdbc', 'lib', '*.jar')], + to: [path.join('jdbc', 'lib')], + }, + }), copyFolderFiles({ fromPath: __dirname, targetFolderPath: RELEASE_FOLDER_PATH, diff --git a/jdbc/java/KerberosJdbcBridge.java b/jdbc/java/KerberosJdbcBridge.java new file mode 100644 index 0000000..6be7564 --- /dev/null +++ b/jdbc/java/KerberosJdbcBridge.java @@ -0,0 +1,148 @@ +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.Statement; +import java.util.Properties; + +import oracle.jdbc.OracleConnection; + +import org.json.JSONArray; +import org.json.JSONObject; + +/** + * Line-delimited JSON protocol over stdin/stdout. + * Commands: connect | execute | close | ping + */ +public class KerberosJdbcBridge { + + private static Connection connection; + + public static void main(String[] args) throws Exception { + BufferedReader in = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)); + PrintWriter out = new PrintWriter(System.out, true, StandardCharsets.UTF_8); + + out.println(new JSONObject().put("ok", true).put("message", "ready").toString()); + + String line; + while ((line = in.readLine()) != null) { + JSONObject req = new JSONObject(line); + JSONObject response; + try { + response = handle(req); + } catch (Exception e) { + response = error(e.getMessage()); + } + if (req.has("id")) { + response.put("id", req.getInt("id")); + } + out.println(response.toString()); + out.flush(); + } + } + + private static JSONObject handle(JSONObject req) throws Exception { + String cmd = req.optString("cmd", ""); + switch (cmd) { + case "ping": + return new JSONObject().put("ok", true).put("message", "pong"); + case "connect": + return connect(req); + case "execute": + return execute(req); + case "close": + return closeConnection(); + default: + return error("Unknown command: " + cmd); + } + } + + private static JSONObject connect(JSONObject req) throws Exception { + closeConnection(); + + String host = req.getString("host"); + int port = req.getInt("port"); + String serviceName = req.getString("serviceName"); + String krb5Conf = req.optString("krb5Conf", ""); + String krb5Cc = req.optString("krb5Cc", ""); + String user = req.optString("user", ""); + + if (!krb5Conf.isEmpty()) { + System.setProperty("java.security.krb5.conf", krb5Conf); + } + if (!krb5Cc.isEmpty()) { + System.setProperty("javax.security.auth.useSubjectCredsOnly", "false"); + System.setProperty("sun.security.krb5.ccache", krb5Cc); + System.setProperty("KRB5CCNAME", "FILE:" + krb5Cc); + } + + String url = "jdbc:oracle:thin:@//" + host + ":" + port + "/" + serviceName; + + Properties props = new Properties(); + props.setProperty(OracleConnection.CONNECTION_PROPERTY_THIN_NET_AUTHENTICATION_SERVICES, "(KERBEROS5)"); + props.setProperty(OracleConnection.CONNECTION_PROPERTY_THIN_NET_AUTHENTICATION_KRB5_MUTUAL, "true"); + props.setProperty("oracle.jdbc.thinNetAuthenticationKerberos5Service", "oracle"); + if (!user.isEmpty()) { + props.setProperty("user", user); + } + + connection = DriverManager.getConnection(url, props); + + return new JSONObject().put("ok", true).put("url", url); + } + + private static JSONObject execute(JSONObject req) throws Exception { + if (connection == null || connection.isClosed()) { + return error("Not connected"); + } + + String sql = req.getString("sql"); + int maxRows = req.optInt("maxRows", 0); + + try (Statement stmt = connection.createStatement()) { + if (maxRows > 0) { + stmt.setMaxRows(maxRows); + } + boolean hasResultSet = stmt.execute(sql); + if (!hasResultSet) { + return new JSONObject().put("ok", true).put("rows", new JSONArray()); + } + + try (ResultSet rs = stmt.getResultSet()) { + return new JSONObject().put("ok", true).put("rows", resultSetToJson(rs)); + } + } + } + + private static JSONArray resultSetToJson(ResultSet rs) throws Exception { + ResultSetMetaData meta = rs.getMetaData(); + int columnCount = meta.getColumnCount(); + JSONArray rows = new JSONArray(); + + while (rs.next()) { + JSONArray row = new JSONArray(); + for (int i = 1; i <= columnCount; i++) { + Object value = rs.getObject(i); + row.put(value == null ? JSONObject.NULL : value); + } + rows.put(row); + } + return rows; + } + + private static JSONObject closeConnection() throws Exception { + if (connection != null) { + connection.close(); + connection = null; + } + return new JSONObject().put("ok", true); + } + + private static JSONObject error(String message) { + return new JSONObject().put("ok", false).put("error", message); + } +} diff --git a/package-lock.json b/package-lock.json index 51f3ca2..02fcc34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "Oracle", - "version": "0.2.56", + "version": "0.2.60", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "Oracle", - "version": "0.2.56", + "version": "0.2.60", "dependencies": { "adm-zip": "0.5.9", "async": "3.2.6", @@ -1043,9 +1043,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -1271,7 +1271,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -1953,7 +1952,6 @@ "integrity": "sha512-EEHNdo5cW2w1xwYdBQ7d3IXDqWAtMkfVFrh+9gQ4kYbYJwygY4QXSh1eH80/xVipZdVKujAwBgg/nNNHk56kxQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "tsgolint": "bin/tsgolint.js" }, @@ -2089,7 +2087,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, diff --git a/package.json b/package.json index 31e0bd9..607281b 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,9 @@ }, "scripts": { "lint": "oxlint . || true", - "package": "node esbuild.package.js", + "build:jdbc": "node scripts/build-jdbc-kerberos.js", + "postinstall": "node scripts/build-jdbc-kerberos.js", + "package": "node scripts/build-jdbc-kerberos.js --strict && node esbuild.package.js", "lint:check": "oxlint --type-aware --type-check . || true", "format": "prettier --write ." }, diff --git a/reverse_engineering/api.js b/reverse_engineering/api.js index c4b5af4..541f952 100644 --- a/reverse_engineering/api.js +++ b/reverse_engineering/api.js @@ -29,12 +29,29 @@ module.exports = { }, async testConnection(connectionInfo, logger, callback, app) { + const sshService = app.require('@hackolade/ssh-service'); + try { - await this.connect(connectionInfo, logger, () => {}, app); + logInfo('Test connection', connectionInfo, logger); + oracleHelper.logEnvironment(logger); + await oracleHelper.disconnect(sshService); + await oracleHelper.connect(connectionInfo, sshService, message => { + logger.log('info', message, 'Connection'); + }); callback(null); } catch (error) { logger.log('error', { message: error.message, stack: error.stack, error }, 'Test connection'); callback({ message: error.message, stack: error.stack }); + } finally { + try { + await oracleHelper.disconnect(sshService); + } catch (disconnectError) { + logger.log( + 'warn', + { message: disconnectError.message, stack: disconnectError.stack }, + 'Disconnect after test connection', + ); + } } }, @@ -42,7 +59,11 @@ module.exports = { try { logInfo('Get schemas', connectionInfo, logger); await this.connect(connectionInfo, logger, () => {}, app); - const schemas = await oracleHelper.getSchemaNames(); + const schemas = await oracleHelper.getSchemaNames(connectionInfo, { + info: data => logger.log('info', data, 'Get schemas'), + error: error => + logger.log('error', { message: error.message, stack: error.stack, error }, 'Get schemas'), + }); logger.log('info', schemas, 'All schemas list', connectionInfo.hiddenKeys); return callback(null, schemas); } catch (error) { diff --git a/reverse_engineering/connection_settings_modal/connectionSettingsModalConfig.json b/reverse_engineering/connection_settings_modal/connectionSettingsModalConfig.json index 04e18d9..bc98a7c 100644 --- a/reverse_engineering/connection_settings_modal/connectionSettingsModalConfig.json +++ b/reverse_engineering/connection_settings_modal/connectionSettingsModalConfig.json @@ -99,7 +99,7 @@ } }, { - "inputLabel": "Wallet file", + "inputLabel": "Wallet archive", "inputKeyword": "walletFile", "description": "Specify the full path location and name of the wallet zip file", "inputType": "file", @@ -110,11 +110,23 @@ } }, { - "inputLabel": "Tnsnames Directory", + "inputLabel": "Tnsnames file", "inputKeyword": "TNSpath", - "description": "Specify the full path to directory with tnsnames.ora file", + "description": "Specify the full path to tnsnames.ora.", "inputType": "file", "openType": "openDirectory", + "extensions": ["ora", "txt"], + "dependency": { + "key": "connectionMethod", + "value": ["TNS"] + } + }, + { + "inputLabel": "Mutual TLS (mTLS)", + "inputKeyword": "mutualTLS", + "description": "Enable only when the database requires a client wallet (mutual TLS). Leave unchecked for legacy TNS connections that use a tnsnames.ora directory with username/password only.", + "inputType": "checkbox", + "defaultValue": false, "dependency": { "key": "connectionMethod", "value": ["TNS"] @@ -139,25 +151,17 @@ "inputKeyword": "serviceName", "description": "Specify the service name of the Oracle Instance", "inputType": "text", + "regex": "([^\\s])", "dependency": { - "type": "or", + "type": "and", "values": [ { "key": "connectionMethod", - "value": ["TNS"] + "value": ["Basic"] }, { - "type": "and", - "values": [ - { - "key": "connectionMethod", - "value": ["Basic"] - }, - { - "key": "identifierType", - "value": ["serviceName"] - } - ] + "key": "identifierType", + "value": ["serviceName"] } ] } @@ -165,23 +169,56 @@ { "inputLabel": "Wallet Password", "inputKeyword": "walletPassword", - "description": "Specify the password used to protect the wallet", + "description": "OCI wallet password (not the database user password). Required for Cloud Wallet and TNS with mTLS enabled.", "inputType": "password", "isHiddenKey": true, "dependency": { - "type": "and", + "type": "or", "values": [ { - "key": "connectionMethod", - "value": ["Wallet"] + "type": "and", + "values": [ + { + "key": "connectionMethod", + "value": ["Wallet"] + }, + { + "key": "mode", + "value": ["thin"] + } + ] }, { - "key": "mode", - "value": ["thin"] + "type": "and", + "values": [ + { + "key": "connectionMethod", + "value": ["TNS"] + }, + { + "key": "mode", + "value": ["thin"] + }, + { + "key": "mutualTLS", + "value": [true, "true"] + } + ] } ] } }, + { + "inputLabel": "TNS alias", + "inputKeyword": "serviceName", + "description": "Optional. Leave empty to use the first entry in tnsnames.ora (e.g. _high).", + "inputType": "text", + "inputPlaceholder": "Optional TNS alias", + "dependency": { + "key": "connectionMethod", + "value": ["TNS"] + } + }, { "inputLabel": "SID", "inputKeyword": "sid", @@ -209,21 +246,24 @@ { "inputLabel": "Authentication method", "inputKeyword": "authMethod", + "description": "Kerberos uses JDBC Thin (Java 11–21); thick mode and Instant Client are not used. OS authentication requires Thick mode and Oracle Instant Client.", "inputType": "select", "defaultValue": "Username / Password", - "options": [{ "value": "Username / Password", "label": "Username / Password" }] + "options": [ + { "value": "Username / Password", "label": "Username / Password" }, + { "value": "OS", "label": "OS" }, + { "value": "Kerberos", "label": "Kerberos" } + ] }, { "inputLabel": "User Name", "inputKeyword": "userName", "inputType": "text", "inputPlaceholder": "User Name", + "description": "Kerberos: leave empty (identity from ticket). Proxy only: use [username] format.", "dependency": { "key": "authMethod", "value": ["Username / Password", "Kerberos"] - }, - "validation": { - "regex": "([^\\s])" } }, { @@ -231,13 +271,31 @@ "inputKeyword": "userPassword", "inputType": "password", "inputPlaceholder": "Password", + "isHiddenKey": true, "dependency": { "key": "authMethod", - "value": ["Username / Password", "Kerberos"] - }, - "isHiddenKey": true, - "validation": { - "regex": "([^\\s])" + "value": ["Username / Password"] + } + }, + { + "inputLabel": "Java path", + "inputKeyword": "javaPath", + "inputType": "text", + "inputPlaceholder": "Optional — defaults to JAVA_HOME or java on PATH", + "description": "Java 11–21 for Kerberos JDBC (JDK 25 is not supported). JDBC JARs are built on npm install. Example: /opt/homebrew/opt/openjdk@21/bin/java", + "dependency": { + "key": "authMethod", + "value": ["Kerberos"] + } + }, + { + "inputLabel": "Kerberos ticket cache", + "inputKeyword": "krb5Cc", + "inputType": "text", + "inputPlaceholder": "Optional — default ~/.hackolade/krb5cc_hackolade", + "dependency": { + "key": "authMethod", + "value": ["Kerberos"] } }, { @@ -253,7 +311,11 @@ { "value": "SYSDG", "label": "SYSDG" }, { "value": "SYSKM", "label": "SYSKM" }, { "value": "SYSASM", "label": "SYSASM" } - ] + ], + "dependency": { + "key": "authMethod", + "value": ["Username / Password", "OS"] + } } ] }, diff --git a/reverse_engineering/helpers/extractWallet.js b/reverse_engineering/helpers/extractWallet.js index d3ae40e..14a1ff4 100644 --- a/reverse_engineering/helpers/extractWallet.js +++ b/reverse_engineering/helpers/extractWallet.js @@ -92,3 +92,4 @@ const extractWallet = async ({ walletFile, tempFolder, name }) => { }; module.exports = extractWallet; +module.exports.fixSqlNetOraWalletPath = replaceSqlNetOraDirectoryPath; diff --git a/reverse_engineering/helpers/jdbcKerberosHelper.js b/reverse_engineering/helpers/jdbcKerberosHelper.js new file mode 100644 index 0000000..0203bd1 --- /dev/null +++ b/reverse_engineering/helpers/jdbcKerberosHelper.js @@ -0,0 +1,261 @@ +const { spawn } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); + +const KERBEROS_CC_BASENAME = 'krb5cc_hackolade'; + +let bridgeProcess; +let bridgeReader; +let pendingRequests = new Map(); +let requestSeq = 0; + +const getDefaultKrb5Cc = () => path.join(process.env.HOME || '/tmp', '.hackolade', KERBEROS_CC_BASENAME); + +const getDefaultKrb5Conf = () => { + if (process.env.KRB5_CONFIG && fs.existsSync(process.env.KRB5_CONFIG)) { + return process.env.KRB5_CONFIG; + } + if (fs.existsSync('/etc/krb5.conf')) { + return '/etc/krb5.conf'; + } + return ''; +}; + +const resolveJdbcLibDir = pluginPath => path.join(pluginPath || path.join(__dirname, '..', '..'), 'jdbc', 'lib'); + +const getJavaMajorVersion = javaExecutable => { + try { + const { execFileSync } = require('child_process'); + const output = execFileSync(javaExecutable, ['-version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + const match = `${output}`.match(/version "(\d+)/); + return match ? Number(match[1]) : 0; + } catch { + return 0; + } +}; + +const resolveJavaExecutable = javaPath => { + const candidates = [ + javaPath, + process.env.JAVA_HOME && + path.join(process.env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'java.exe' : 'java'), + '/opt/homebrew/opt/openjdk@21/bin/java', + '/usr/local/opt/openjdk@21/bin/java', + '/opt/homebrew/opt/openjdk@17/bin/java', + '/usr/local/opt/openjdk@17/bin/java', + '/opt/homebrew/opt/openjdk/bin/java', + '/usr/local/opt/openjdk/bin/java', + '/usr/bin/java', + ].filter(Boolean); + + for (const candidate of candidates) { + if (!fs.existsSync(candidate)) { + continue; + } + + const major = getJavaMajorVersion(candidate); + if (major >= 25) { + continue; + } + + return candidate; + } + + throw new Error( + 'Kerberos (JDBC) requires Java 11–21 (JDK 25 breaks Oracle Kerberos). ' + + 'Install: brew install openjdk@21 and set Java path to /opt/homebrew/opt/openjdk@21/bin/java', + ); +}; + +const assertJdbcRuntime = pluginPath => { + const libDir = resolveJdbcLibDir(pluginPath); + const required = ['ojdbc11.jar', 'json.jar', 'kerberos-jdbc-bridge.jar']; + const missing = required.filter(file => !fs.existsSync(path.join(libDir, file))); + + if (missing.length) { + throw new Error( + `Kerberos (JDBC) runtime missing: ${missing.join(', ')} in ${libDir}. ` + + 'Run: npm install (or npm run build:jdbc) in the Oracle plugin directory.', + ); + } + + return libDir; +}; + +const parseConnectEndpoint = connectString => { + const host = connectString.match(/HOST\s*=\s*([^)]+)/i)?.[1]?.trim(); + const port = connectString.match(/PORT\s*=\s*(\d+)/i)?.[1]?.trim(); + const serviceName = connectString.match(/SERVICE_NAME\s*=\s*([^)]+)/i)?.[1]?.trim(); + + if (!host || !port || !serviceName) { + throw new Error(`Unable to parse HOST/PORT/SERVICE_NAME from connect string: ${connectString}`); + } + + return { host, port: Number(port), serviceName }; +}; + +const startBridge = async (pluginPath, javaPath, logger) => { + if (bridgeProcess) { + return; + } + + const libDir = assertJdbcRuntime(pluginPath); + const javaExecutable = resolveJavaExecutable(javaPath); + const classpath = [ + path.join(libDir, 'ojdbc11.jar'), + path.join(libDir, 'json.jar'), + path.join(libDir, 'kerberos-jdbc-bridge.jar'), + ].join(process.platform === 'win32' ? ';' : ':'); + + logger?.({ + message: 'Starting Kerberos JDBC bridge', + java: javaExecutable, + libDir, + }); + + bridgeProcess = spawn(javaExecutable, ['-cp', classpath, 'KerberosJdbcBridge'], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env }, + }); + + bridgeReader = readline.createInterface({ input: bridgeProcess.stdout }); + + bridgeReader.on('line', line => { + let response; + try { + response = JSON.parse(line); + } catch (error) { + return; + } + + if (response.id === undefined) { + return; + } + + const pending = pendingRequests.get(response.id); + if (!pending) { + return; + } + + pendingRequests.delete(response.id); + if (response.ok) { + pending.resolve(response); + } else { + pending.reject(new Error(response.error || 'JDBC bridge error')); + } + }); + + bridgeProcess.stderr.on('data', chunk => { + logger?.({ message: 'JDBC bridge stderr', stderr: String(chunk) }); + }); + + bridgeProcess.on('exit', code => { + bridgeProcess = null; + bridgeReader = null; + for (const [, pending] of pendingRequests) { + pending.reject(new Error(`JDBC bridge exited with code ${code}`)); + } + pendingRequests.clear(); + }); + + await new Promise((resolve, reject) => { + const onLine = line => { + try { + const msg = JSON.parse(line); + if (msg.ok && msg.message === 'ready') { + bridgeReader.off('line', onLine); + resolve(); + } + } catch { + // ignore + } + }; + bridgeReader.on('line', onLine); + bridgeProcess.on('error', reject); + setTimeout(() => reject(new Error('JDBC bridge start timeout')), 30000); + }); +}; + +const request = payload => + new Promise((resolve, reject) => { + if (!bridgeProcess) { + return reject(new Error('JDBC bridge is not running')); + } + + const id = ++requestSeq; + pendingRequests.set(id, { resolve, reject }); + bridgeProcess.stdin.write(`${JSON.stringify({ ...payload, id })}\n`); + }); + +const connect = async ({ pluginPath, javaPath, connectString, krb5Cc, krb5Conf, userName, logger }) => { + await startBridge(pluginPath, javaPath, logger); + + const endpoint = parseConnectEndpoint(connectString); + const ccFile = krb5Cc || getDefaultKrb5Cc(); + const confFile = krb5Conf || getDefaultKrb5Conf(); + + if (!fs.existsSync(ccFile)) { + throw new Error( + `Kerberos ticket cache not found at ${ccFile}. Run docker/scripts/mac-kinit.sh or kinit -c ${ccFile} `, + ); + } + + const response = await request({ + cmd: 'connect', + host: endpoint.host, + port: endpoint.port, + serviceName: endpoint.serviceName, + krb5Cc: ccFile, + krb5Conf: confFile, + user: userName || '', + }); + + logger?.({ + message: 'Kerberos JDBC connected', + url: response.url, + krb5Cc: ccFile, + krb5Conf: confFile, + }); + + return { type: 'jdbc' }; +}; + +const execute = async (sql, options = {}) => { + const response = await request({ + cmd: 'execute', + sql, + maxRows: options.maxRows || 0, + }); + + return response.rows || []; +}; + +const disconnect = async () => { + if (!bridgeProcess) { + return; + } + + try { + await request({ cmd: 'close' }); + } catch { + // ignore close errors + } + + bridgeProcess.stdin.end(); + bridgeProcess.kill(); + bridgeProcess = null; + bridgeReader = null; +}; + +module.exports = { + connect, + execute, + disconnect, + getDefaultKrb5Cc, + getDefaultKrb5Conf, + parseConnectEndpoint, +}; diff --git a/reverse_engineering/helpers/oracleHelper.js b/reverse_engineering/helpers/oracleHelper.js index 510d1bb..3d3bbef 100644 --- a/reverse_engineering/helpers/oracleHelper.js +++ b/reverse_engineering/helpers/oracleHelper.js @@ -1,16 +1,36 @@ const _ = require('lodash'); +const dns = require('dns'); +const dnsPromises = dns.promises; const fs = require('fs'); +const net = require('net'); const path = require('path'); const oracleDB = require('oracledb'); const extractWallet = require('./extractWallet'); +const fixSqlNetOraWalletPath = require('./extractWallet').fixSqlNetOraWalletPath; const parseTns = require('./parseTns'); const { getSchemaSequences } = require('./getSchemaSequences'); const { getSchemaSynonyms } = require('./getSchemaSynonyms'); +const jdbcKerberosHelper = require('./jdbcKerberosHelper'); const noConnectionError = { message: 'Connection error' }; let connection; let useSshTunnel; +let pluginTnsAdmin; +let thickOracleClientInitialized = false; + +const setPluginTnsAdmin = configDir => { + pluginTnsAdmin = configDir; + process.env.TNS_ADMIN = configDir; +}; + +const clearPluginTnsAdmin = () => { + if (pluginTnsAdmin && process.env.TNS_ADMIN === pluginTnsAdmin) { + delete process.env.TNS_ADMIN; + } + + pluginTnsAdmin = null; +}; const parseProxyOptions = (proxyString = '') => { const result = proxyString.match(/http:\/\/(?:.*?:.*?@)?(.*?):(\d+)/i); @@ -25,9 +45,44 @@ const parseProxyOptions = (proxyString = '') => { }; }; +const TNS_NAMES_FILE = 'tnsnames.ora'; + +const resolveTnsConfigDir = tnsPath => { + if (!tnsPath) { + return tnsPath; + } + + const normalizedPath = path.normalize(String(tnsPath).trim()); + + if (!fs.existsSync(normalizedPath)) { + return normalizedPath; + } + + if (fs.statSync(normalizedPath).isDirectory()) { + return normalizedPath; + } + + if (path.basename(normalizedPath).toLowerCase() === TNS_NAMES_FILE) { + return path.dirname(normalizedPath); + } + + throw new Error(`Invalid TNS path "${normalizedPath}". Select the wallet directory or the ${TNS_NAMES_FILE} file.`); +}; + +const assertTnsConfigDir = configDir => { + const tnsNamesOraFile = getTnsNamesOraFile(configDir); + + if (!tnsNamesOraFile || !fs.existsSync(tnsNamesOraFile)) { + throw new Error( + `Cannot find ${TNS_NAMES_FILE} in "${configDir}". Select the wallet directory or the ${TNS_NAMES_FILE} file.`, + ); + } +}; + const getTnsNamesOraFile = configDir => { + const resolvedConfigDir = resolveTnsConfigDir(configDir); const tnsNamesOraFile = [ - configDir, + resolvedConfigDir, process.env.TNS_ADMIN, path.join(process.env.ORACLE_HOME || '', 'network', 'admin'), path.join(process.env.LD_LIBRARY_PATH || '', 'network', 'admin'), @@ -54,11 +109,62 @@ const parseTnsNamesOra = filePath => { return result; }; -const getConnectionStringByTnsNames = (configDir, serviceName, proxy, logger) => { +const WALLET_FILES = ['ewallet.pem', 'cwallet.sso', 'ewallet.p12']; +const MTLS_PORT = '1522'; + +const hasWalletFiles = configDir => + configDir && fs.existsSync(configDir) && WALLET_FILES.some(file => fs.existsSync(path.join(configDir, file))); + +const hasAutoLoginWallet = configDir => configDir && fs.existsSync(path.join(configDir, 'cwallet.sso')); + +const isMutualTlsEnabled = mutualTLS => mutualTLS === true || mutualTLS === 'true'; + +const assertTnsMtlsRequirements = ({ configDir, tnsServicePort, useMutualTls, walletPassword, logger }) => { + if (!useMutualTls) { + if (isMtlsPort(tnsServicePort)) { + logger({ + message: `TNS service uses port ${MTLS_PORT} without mutual TLS enabled. Connecting in legacy TNS mode (server TLS only, no wallet). Enable "Mutual TLS (mTLS)" if the database requires a client wallet.`, + }); + } + + return; + } + + if (!isMtlsPort(tnsServicePort)) { + return; + } + + if (!hasWalletFiles(configDir)) { + throw new Error( + `Mutual TLS requires wallet files (${WALLET_FILES.join(', ')}) in the TNS directory "${configDir}".`, + ); + } + + if (!walletPassword && !hasAutoLoginWallet(configDir)) { + throw new Error( + `Mutual TLS requires a wallet password (OCI wallet zip password), unless the directory contains an auto-login wallet (cwallet.sso).`, + ); + } + + if (!walletPassword && hasAutoLoginWallet(configDir)) { + logger({ + message: 'Using auto-login wallet (cwallet.sso); wallet password not required.', + }); + } +}; + +const isMtlsPort = port => String(port) === MTLS_PORT; + +const connectStringUsesMtlsPort = connectString => /\(PORT\s*=\s*1522\)/i.test(connectString); + +const normalizeTnsAlias = serviceName => (serviceName == null ? '' : String(serviceName).trim()); + +const getResolvedTnsService = (configDir, serviceName, logger) => { + const tnsAlias = normalizeTnsAlias(serviceName); const filePath = getTnsNamesOraFile(configDir); if (!fs.existsSync(filePath)) { - return serviceName; + return null; } logger({ message: 'Found tnsnames.ora file: ' + filePath }); @@ -68,28 +174,80 @@ const getConnectionStringByTnsNames = (configDir, serviceName, proxy, logger) => logger({ message: 'tnsnames.ora successfully parsed' }); const tnsServicesNames = Object.keys(tnsData); - if (!tnsData[serviceName] && tnsServicesNames.length === 0) { - logger({ message: `Cannot find '${serviceName}' in tnsnames.ora and no fallback found` }); - return serviceName; + if (tnsServicesNames.length === 0) { + logger({ message: 'No TNS services found in tnsnames.ora' }); + return null; } const [firstTnsServiceName] = tnsServicesNames; - const tnsService = tnsData[serviceName] || tnsData[firstTnsServiceName]; - if (!tnsData[serviceName]) { + const tnsService = (tnsAlias && tnsData[tnsAlias]) || tnsData[firstTnsServiceName]; + + if (!tnsAlias) { + logger({ + message: `No TNS alias provided. Using first TNS service ${firstTnsServiceName} from ${path.join(configDir, 'tnsnames.ora')}.`, + }); + } else if (!tnsData[tnsAlias]) { logger({ - message: `Connect using first TNS service ${firstTnsServiceName}' from ${path.join(configDir, 'tnsnames.ora')}.`, + message: `TNS alias '${tnsAlias}' not found. Using first TNS service ${firstTnsServiceName} from ${path.join(configDir, 'tnsnames.ora')}.`, }); } else { logger({ - message: `Connect using TNS service ${serviceName}' from ${path.join(configDir, 'tnsnames.ora')}.`, + message: `Connect using TNS service ${tnsAlias} from ${path.join(configDir, 'tnsnames.ora')}.`, }); } - const address = tnsService?.data?.description?.address; - const service = tnsService?.data?.description?.connect_data?.service_name; - const sid = tnsService?.data?.description?.connect_data?.sid; + const description = tnsService?.data?.description; + const address = description?.address; + const resolvedAlias = tnsAlias && tnsData[tnsAlias] ? tnsAlias : firstTnsServiceName; + + return { + description, + address, + service: description?.connect_data?.service_name, + sid: description?.connect_data?.sid, + port: address?.port, + resolvedAlias, + }; +}; - logger({ message: 'tnsnames.ora', address, service }); +const syncConnectionEndpointFromTns = (connectionInfo, configDir, serviceName, logger) => { + const resolved = getResolvedTnsService(configDir, serviceName, logger); + + if (!resolved?.address?.host) { + return resolved; + } + + connectionInfo.host = resolved.address.host; + connectionInfo.port = resolved.address.port; + + logger({ + message: 'Synced connection host/port from tnsnames.ora for connections list', + host: connectionInfo.host, + port: connectionInfo.port, + tnsAlias: resolved.resolvedAlias, + }); + + return resolved; +}; + +const getConnectionStringByTnsNames = (configDir, serviceName, proxy, logger, useWallet = false) => { + const resolved = getResolvedTnsService(configDir, serviceName, logger); + + if (!resolved) { + return serviceName; + } + + const { description, address, service, sid, port, resolvedAlias } = resolved; + + logger({ message: 'tnsnames.ora', address, service, port }); + + if (useWallet) { + logger({ + message: 'Using TNS alias with mTLS wallet', + connectString: resolvedAlias, + }); + return resolvedAlias; + } return getConnectionDescription( _.omitBy( @@ -98,7 +256,10 @@ const getConnectionStringByTnsNames = (configDir, serviceName, proxy, logger) => ...proxy, protocol: address?.protocol || 'tcps', service: service || serviceName, - sid: sid, + sid, + retryCount: description?.retry_count, + retryDelay: description?.retry_delay, + sslServerDnMatch: description?.security?.ssl_server_dn_match, }, _.isUndefined, ), @@ -108,8 +269,48 @@ const getConnectionStringByTnsNames = (configDir, serviceName, proxy, logger) => const combine = (val, str) => (val ? str : ''); -const getConnectionDescription = ({ protocol, host, port, sid, service, httpsProxy, httpsProxyPort }, logger) => { - const connectionString = `(DESCRIPTION= +const normalizeConnectString = connectString => + typeof connectString === 'string' ? connectString.replace(/\s+/g, '') : connectString; + +const UNUSABLE_RESOLVED_HOSTS = new Set(['255.255.255.255', '0.0.0.0']); + +const assertResolvableConnectHost = async (hostname, logger) => { + if (!hostname || net.isIP(hostname)) { + return; + } + + let addresses; + + try { + addresses = await dnsPromises.lookup(hostname, { all: true }); + } catch (error) { + throw new Error(`Cannot resolve hostname "${hostname}": ${error.message}`); + } + + const resolvedAddresses = addresses.map(entry => entry.address); + + logger({ + message: 'Resolved connection hostname for TCP connect', + hostname, + resolvedAddresses, + }); + + const unusableAddress = resolvedAddresses.find(address => UNUSABLE_RESOLVED_HOSTS.has(address)); + + if (unusableAddress) { + throw new Error( + `Hostname "${hostname}" resolves to ${unusableAddress}. This often means an Azure VM is stopped or its public IP was deallocated. Start the VM or update the hostname, then try again.`, + ); + } +}; + +const getConnectionDescription = ( + { protocol, host, port, sid, service, httpsProxy, httpsProxyPort, retryCount, retryDelay, sslServerDnMatch }, + logger, +) => { + const connectionString = normalizeConnectString(`(DESCRIPTION= + ${combine(retryCount, `(RETRY_COUNT=${retryCount})`)} + ${combine(retryDelay, `(RETRY_DELAY=${retryDelay})`)} (ADDRESS= (PROTOCOL=${protocol || 'tcp'}) (HOST=${host}) @@ -120,8 +321,9 @@ const getConnectionDescription = ({ protocol, host, port, sid, service, httpsPro ${combine(sid, `(SID=${sid})`)} ${combine(service, `(SERVICE_NAME=${service})`)} ) - )`; - logger({ message: 'connectionString', connectionString }); + ${combine(sslServerDnMatch, `(SECURITY=(SSL_SERVER_DN_MATCH=${sslServerDnMatch}))`)} + )`); + logger({ message: 'connectString', connectString: connectionString }); return connectionString; }; @@ -134,32 +336,22 @@ const getSshConnectionString = async (data, sshService, logger) => { }; if (['Wallet', 'TNS'].includes(data.connectionMethod)) { - const filePath = getTnsNamesOraFile(data.configDir); + const resolved = getResolvedTnsService(data.configDir, data.serviceName, logger); - if (!fs.existsSync(filePath)) { + if (!resolved) { throw new Error( 'Cannot find tnsnames.ora file. Please, specify tnsnames folder or use Base connection method.', ); } - logger({ message: 'Found tnsnames.ora file: ' + filePath }); - - const tnsData = parseTnsNamesOra(filePath); - - if (!tnsData[data.serviceName]) { - throw new Error('Cannot find "' + data.serviceName + '" in tnsnames.ora'); - } - - const address = tnsData[data.serviceName]?.data?.description?.address; - const service = tnsData[data.serviceName]?.data?.description?.connect_data?.service_name; - const sid = tnsData[data.serviceName]?.data?.description?.connect_data?.sid; + const { address, service, sid } = resolved; logger({ message: 'tnsnames.ora', address, service }); connectionData.protocol = address?.protocol; connectionData.host = address?.host; connectionData.port = address?.port; - connectionData.service = service || data.serviceName; + connectionData.service = service || normalizeTnsAlias(data.serviceName); connectionData.sid = sid; } else { connectionData.host = data.host; @@ -190,8 +382,276 @@ const getSshConnectionString = async (data, sshService, logger) => { ); }; -const connect = async ( - { +const trySyncTnsEndpointEarly = (connectionInfo, { connectionMethod, TNSpath, serviceName }, logger) => { + if (connectionMethod !== 'TNS' || !TNSpath) { + return; + } + + try { + const tnsConfigDir = resolveTnsConfigDir(TNSpath); + const tnsNamesOraFile = getTnsNamesOraFile(tnsConfigDir); + + if (tnsNamesOraFile && fs.existsSync(tnsNamesOraFile)) { + syncConnectionEndpointFromTns(connectionInfo, tnsConfigDir, serviceName, logger); + } + } catch (error) { + logger({ message: `Unable to sync host/port from tnsnames.ora: ${error.message}` }); + } +}; + +const assertBasicServiceName = (connectionMethod, serviceName) => { + if (connectionMethod === 'Basic' && !normalizeTnsAlias(serviceName)) { + throw new Error('Service name is required for Basic connection method.'); + } +}; + +const setupWalletConfigDir = async ({ walletFile, tempFolder, name }, connectionInfo, serviceName, logger) => { + const configDir = await extractWallet({ walletFile, tempFolder, name }); + setPluginTnsAdmin(configDir); + const resolvedTnsService = syncConnectionEndpointFromTns(connectionInfo, configDir, serviceName, logger); + + return { configDir, tnsServicePort: resolvedTnsService?.port }; +}; + +const applyTnsMutualTlsWallet = (configDir, useMutualTls, tnsServicePort, logger) => { + if (useMutualTls && !isMtlsPort(tnsServicePort)) { + logger({ + message: `mTLS is enabled but TNS service uses port ${tnsServicePort ?? 'unknown'} (not ${MTLS_PORT}). Connecting without wallet.`, + }); + return; + } + + if (!useMutualTls || !isMtlsPort(tnsServicePort)) { + return; + } + + if (!hasWalletFiles(configDir)) { + throw new Error( + `Mutual TLS requires wallet files (${WALLET_FILES.join(', ')}) in the TNS directory "${configDir}".`, + ); + } + + fixSqlNetOraWalletPath(path.join(configDir, 'sqlnet.ora'), configDir); + setPluginTnsAdmin(configDir); +}; + +const setupTnsConfigDir = (TNSpath, connectionInfo, { serviceName, useMutualTls, walletPassword }, logger) => { + const configDir = resolveTnsConfigDir(TNSpath); + assertTnsConfigDir(configDir); + + const resolvedTnsService = syncConnectionEndpointFromTns(connectionInfo, configDir, serviceName, logger); + const tnsServicePort = resolvedTnsService?.port; + + assertTnsMtlsRequirements({ configDir, tnsServicePort, useMutualTls, walletPassword, logger }); + applyTnsMutualTlsWallet(configDir, useMutualTls, tnsServicePort, logger); + + return { configDir, tnsServicePort }; +}; + +const resolveConnectionConfigDir = async ( + connectionMethod, + connectionInfo, + { walletFile, walletPassword, tempFolder, name, TNSpath, serviceName }, + useMutualTls, + logger, +) => { + if (connectionMethod === 'Wallet') { + return setupWalletConfigDir({ walletFile, tempFolder, name }, connectionInfo, serviceName, logger); + } + + if (connectionMethod === 'TNS') { + return setupTnsConfigDir(TNSpath, connectionInfo, { serviceName, useMutualTls, walletPassword }, logger); + } + + return { configDir: undefined, tnsServicePort: undefined }; +}; + +const buildSessionConnectString = ( + { connectionMethod, configDir, serviceName, proxy, useMutualTls, tnsServicePort, host, port, sid }, + logger, +) => { + const useTnsWallet = + connectionMethod === 'Wallet' || (connectionMethod === 'TNS' && useMutualTls && isMtlsPort(tnsServicePort)); + + if (['Wallet', 'TNS'].includes(connectionMethod)) { + return getConnectionStringByTnsNames(configDir, serviceName, proxy, logger, useTnsWallet); + } + + return getConnectionDescription({ host, port, sid, service: serviceName }, logger); +}; + +const applySshTunnelIfNeeded = async (ssh, connectString, tunnelParams, sshService, logger) => { + if (!ssh) { + return connectString; + } + + useSshTunnel = true; + return getSshConnectionString(tunnelParams, sshService, logger); +}; + +const shouldUseWalletForConnect = ({ connectionMethod, useMutualTls, tnsServicePort, connectString }) => + connectionMethod === 'Wallet' || + (connectionMethod === 'TNS' && + useMutualTls && + (isMtlsPort(tnsServicePort) || connectStringUsesMtlsPort(connectString))); + +const AUTH_METHOD_USERNAME_PASSWORD = 'Username / Password'; +const AUTH_METHOD_OS = 'OS'; +const AUTH_METHOD_KERBEROS = 'Kerberos'; + +const normalizeAuthMethod = authMethod => authMethod || AUTH_METHOD_USERNAME_PASSWORD; + +const assertExternalAuthMode = (authMethod, mode) => { + if (authMethod === AUTH_METHOD_USERNAME_PASSWORD) { + return; + } + + if (mode === 'thin') { + throw new Error( + `${authMethod} authentication requires Thick mode with Oracle Instant Client or Oracle Home configured for external authentication.`, + ); + } +}; + +const buildConnectionAuthParams = (authMethod, userName, userPassword) => { + if (authMethod === AUTH_METHOD_USERNAME_PASSWORD) { + if (!normalizeTnsAlias(userName) || !userPassword) { + throw new Error('User name and password are required for Username / Password authentication.'); + } + + return { username: userName, password: userPassword }; + } + + if (authMethod === AUTH_METHOD_OS) { + return { externalAuth: true }; + } + + return { username: userName, password: userPassword }; +}; + +const logAuthMethodNotes = (authMethod, userPassword, logger) => { + if (authMethod === AUTH_METHOD_KERBEROS && userPassword) { + logger({ + message: + 'Password is not sent for Kerberos JDBC authentication (identity comes from the Kerberos ticket cache).', + }); + } +}; + +const connectViaJdbcKerberos = async (connectionInfo, sshService, logger) => { + const { + connectionMethod, + host, + port, + serviceName, + sid, + options, + ssh, + ssh_user, + ssh_host, + ssh_port, + ssh_method, + ssh_key_file, + ssh_key_passphrase, + ssh_password, + pluginPath, + javaPath, + krb5Cc, + krb5Conf, + } = connectionInfo; + + if (connectionMethod !== 'Basic') { + throw new Error('Kerberos authentication (JDBC) supports Basic connection method only.'); + } + + assertBasicServiceName(connectionMethod, serviceName); + + const proxy = options?.proxy ? parseProxyOptions(options.proxy) : ''; + let connectString = buildSessionConnectString( + { + connectionMethod, + configDir: undefined, + serviceName, + proxy, + useMutualTls: false, + tnsServicePort: undefined, + host, + port, + sid, + }, + logger, + ); + + connectString = await applySshTunnelIfNeeded( + ssh, + connectString, + { + host, + port, + configDir: undefined, + serviceName, + sid, + connectionMethod, + sshConfig: { + ssh_user, + ssh_host, + ssh_port, + ssh_method, + ssh_key_file, + ssh_password, + ssh_key_passphrase, + }, + }, + sshService, + logger, + ); + + const normalizedConnectString = normalizeConnectString(connectString); + + if (!ssh && host) { + await assertResolvableConnectHost(host, logger); + } + + logger({ + message: 'Oracle connectString (Kerberos JDBC)', + connectString: normalizedConnectString, + hostname: host, + authTransport: 'jdbc', + }); + + const { userName } = connectionInfo; + + connection = await jdbcKerberosHelper.connect({ + pluginPath, + javaPath, + connectString: normalizedConnectString, + krb5Cc, + krb5Conf, + userName: userName?.startsWith('[') ? undefined : userName, + logger, + }); + + return connection; +}; + +const logWalletConnectNotes = ({ connectionMethod, useMutualTls, useWallet, walletPassword }, logger) => { + if (connectionMethod === 'TNS' && useMutualTls && !useWallet) { + logger({ + message: + 'Skipping walletLocation, walletPassword, and configDir for thin connect (TNS service does not use mTLS port 1522).', + }); + } + + if (walletPassword && !useWallet) { + logger({ + message: + 'A wallet password is stored in the connection profile but is not sent to Oracle (mTLS disabled, non-mTLS port, or non-wallet connection method).', + }); + } +}; + +const connect = async (connectionInfo, sshService, logger) => { + const { walletFile, walletPassword, tempFolder, @@ -206,7 +666,6 @@ const connect = async ( clientPath, clientType, queryRequestTimeout, - authMethod, options, sid, ssh, @@ -218,111 +677,133 @@ const connect = async ( ssh_key_passphrase, ssh_password, authRole, + authMethod, mode, - }, - sshService, - logger, -) => { + mutualTLS, + } = connectionInfo; + + trySyncTnsEndpointEarly(connectionInfo, { connectionMethod, TNSpath, serviceName }, logger); + if (connection) { + logger({ message: 'Reusing existing Oracle connection' }); return connection; } - const MODES = { - thin: 'thin', - thick: 'thick', - }; - let configDir; - let libDir; - let credentials = {}; - let proxy = ''; - - if (connectionMethod === 'Wallet') { - configDir = await extractWallet({ walletFile, tempFolder, name }); - process.env.TNS_ADMIN = configDir; + const resolvedAuthMethod = normalizeAuthMethod(authMethod); + if (resolvedAuthMethod === AUTH_METHOD_KERBEROS) { + return connectViaJdbcKerberos(connectionInfo, sshService, logger); } - if (connectionMethod === 'TNS') { - configDir = TNSpath; + if (connectionMethod === 'Basic') { + clearPluginTnsAdmin(); } - if (clientType === 'InstantClient') { - libDir = clientPath; - } + const useMutualTls = isMutualTlsEnabled(mutualTLS); + assertBasicServiceName(connectionMethod, serviceName); - if (options?.proxy) { - proxy = parseProxyOptions(options?.proxy); - } + const { configDir, tnsServicePort } = await resolveConnectionConfigDir( + connectionMethod, + connectionInfo, + { walletFile, walletPassword, tempFolder, name, TNSpath, serviceName }, + useMutualTls, + logger, + ); - if (mode !== MODES.thin) { - oracleDB.initOracleClient({ libDir, configDir }); - } + const libDir = clientType === 'InstantClient' ? clientPath : undefined; + const proxy = options?.proxy ? parseProxyOptions(options.proxy) : ''; - let connectString = ''; + let thickConfigDir = configDir; + if (mode !== 'thin') { + if (!thickOracleClientInitialized) { + try { + oracleDB.initOracleClient({ libDir, configDir: thickConfigDir }); + thickOracleClientInitialized = true; + } catch (err) { + if (!/already been called/i.test(String(err.message))) { + throw err; + } + thickOracleClientInitialized = true; + } + } - if (['Wallet', 'TNS'].includes(connectionMethod)) { - connectString = getConnectionStringByTnsNames(configDir, serviceName, proxy, logger); - } else { - connectString = getConnectionDescription( - { - host, - port, - sid, - service: serviceName, - }, - logger, - ); + if (thickConfigDir) { + setPluginTnsAdmin(thickConfigDir); + } } - if (ssh) { - useSshTunnel = true; - connectString = await getSshConnectionString( - { - host, - port, - configDir, - serviceName, - sid, - connectionMethod, - sshConfig: { - ssh_user, - ssh_host, - ssh_port, - ssh_method, - ssh_key_file, - ssh_password, - ssh_key_passphrase, - }, + let connectString = buildSessionConnectString( + { connectionMethod, configDir, serviceName, proxy, useMutualTls, tnsServicePort, host, port, sid }, + logger, + ); + + connectString = await applySshTunnelIfNeeded( + ssh, + connectString, + { + host, + port, + configDir, + serviceName, + sid, + connectionMethod, + sshConfig: { + ssh_user, + ssh_host, + ssh_port, + ssh_method, + ssh_key_file, + ssh_password, + ssh_key_passphrase, }, - sshService, - logger, - ); - } + }, + sshService, + logger, + ); - if (authMethod === 'OS') { - credentials.externalAuth = true; - } else if (authMethod === 'Kerberos') { - credentials.username = userName; - credentials.password = userPassword; - credentials.externalAuth = true; - } else { - credentials.username = userName; - credentials.password = userPassword; + const useWallet = shouldUseWalletForConnect({ + connectionMethod, + useMutualTls, + tnsServicePort, + connectString, + }); + logWalletConnectNotes({ connectionMethod, useMutualTls, useWallet, walletPassword }, logger); + + assertExternalAuthMode(resolvedAuthMethod, mode); + logAuthMethodNotes(resolvedAuthMethod, userPassword, logger); + + const normalizedConnectString = normalizeConnectString(connectString); + const hostnameToResolve = connectionMethod === 'Basic' ? host : connectionInfo.host; + + if (!ssh && hostnameToResolve) { + await assertResolvableConnectHost(hostnameToResolve, logger); } + const sessionConfigDir = useWallet ? configDir : undefined; + + logger({ + message: 'Oracle connectString', + connectString: normalizedConnectString, + hostname: hostnameToResolve, + useWallet, + walletLocation: useWallet ? configDir : undefined, + configDir: sessionConfigDir, + }); + return authByCredentials({ - connectString, - username: userName, - password: userPassword, + connectString: normalizedConnectString, + ...buildConnectionAuthParams(resolvedAuthMethod, userName, userPassword), queryRequestTimeout, authRole, - walletLocation: configDir, - walletPassword, + configDir: sessionConfigDir, + walletLocation: useWallet ? configDir : undefined, + walletPassword: useWallet ? walletPassword : undefined, }); }; const disconnect = async sshService => { if (!connection) { - return Promise.reject(noConnectionError); + clearPluginTnsAdmin(); + return; } if (useSshTunnel) { @@ -330,9 +811,17 @@ const disconnect = async sshService => { await sshService.closeConsumer(); } + if (connection.type === 'jdbc') { + await jdbcKerberosHelper.disconnect(); + connection = null; + clearPluginTnsAdmin(); + return; + } + return new Promise((resolve, reject) => { connection.close(err => { connection = null; + clearPluginTnsAdmin(); if (err) { return reject(err); } @@ -345,20 +834,27 @@ const authByCredentials = ({ connectString, username, password, + externalAuth, queryRequestTimeout, authRole, walletPassword, walletLocation, + configDir, }) => { return new Promise((resolve, reject) => { - const connectionConfig = { - username, - password, - connectString, - privilege: authRole === 'default' ? undefined : oracleDB[authRole], - walletLocation, - walletPassword, - }; + const connectionConfig = _.omitBy( + { + username, + password, + externalAuth, + connectString, + privilege: authRole === 'default' ? undefined : oracleDB[authRole], + walletLocation, + walletPassword, + configDir, + }, + _.isUndefined, + ); oracleDB.getConnection(connectionConfig, (err, conn) => { if (err) { connection = null; @@ -387,11 +883,7 @@ const getSchemaNames = async ({ includeSystemCollection, schemaName }, logger) = } else { query = `${selectStatement} WHERE ORACLE_MAINTAINED = 'N'${stmt ? ` AND ${stmt}` : ''}`; } - return await execute(query).catch(e => { - logger.info({ message: 'Cannot retrieve schema names' }); - logger.error(e); - return []; - }); + return execute(query); }; const pairToObj = pairs => { @@ -576,6 +1068,14 @@ const execute = (command, options = {}, binds = []) => { if (!connection) { return Promise.reject(noConnectionError); } + + if (connection.type === 'jdbc') { + if (binds?.length) { + return Promise.reject(new Error('Kerberos JDBC bridge does not support bind parameters yet.')); + } + return jdbcKerberosHelper.execute(command, options); + } + return new Promise((resolve, reject) => { connection.execute(command, binds, options, (err, result) => { if (err) { diff --git a/reverse_engineering/helpers/parseTns.js b/reverse_engineering/helpers/parseTns.js index 0cd5f90..8850ef6 100644 --- a/reverse_engineering/helpers/parseTns.js +++ b/reverse_engineering/helpers/parseTns.js @@ -19,7 +19,7 @@ function parseObject(lex, obj = {}) { return { ...obj, - [id]: value, + [id.toLowerCase()]: value, }; } diff --git a/scripts/build-jdbc-kerberos.js b/scripts/build-jdbc-kerberos.js new file mode 100644 index 0000000..ccba99f --- /dev/null +++ b/scripts/build-jdbc-kerberos.js @@ -0,0 +1,187 @@ +#!/usr/bin/env node +'use strict'; + +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const https = require('https'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); +const LIB_DIR = path.join(ROOT, 'jdbc', 'lib'); +const SRC = path.join(ROOT, 'jdbc', 'java', 'KerberosJdbcBridge.java'); +const OUT_JAR = path.join(LIB_DIR, 'kerberos-jdbc-bridge.jar'); +const CLASSES_DIR = path.join(LIB_DIR, 'classes'); + +const OJDBC_VERSION = process.env.OJDBC_VERSION || '23.4.0.24.05'; +const OJDBC_URL = `https://repo1.maven.org/maven2/com/oracle/database/jdbc/ojdbc11/${OJDBC_VERSION}/ojdbc11-${OJDBC_VERSION}.jar`; +const JSON_URL = 'https://repo1.maven.org/maven2/org/json/json/20240303/json-20240303.jar'; + +const strict = process.argv.includes('--strict') || process.env.npm_lifecycle_event === 'package'; + +const log = message => console.log(`[jdbc-kerberos] ${message}`); +const warn = message => console.warn(`[jdbc-kerberos] ${message}`); + +const download = (url, dest) => + new Promise((resolve, reject) => { + const file = fs.createWriteStream(dest); + + const request = urlString => { + https + .get(urlString, response => { + if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + response.resume(); + request(response.headers.location); + return; + } + + if (response.statusCode !== 200) { + reject(new Error(`Download failed (${response.statusCode}): ${urlString}`)); + response.resume(); + return; + } + + response.pipe(file); + file.on('finish', () => file.close(() => resolve(dest))); + }) + .on('error', reject); + }; + + request(url); + }); + +const ensureJar = async (url, dest, label) => { + if (fs.existsSync(dest)) { + return; + } + + fs.mkdirSync(LIB_DIR, { recursive: true }); + log(`Downloading ${label}...`); + await download(url, dest); +}; + +const getJavaMajorVersion = javaExecutable => { + const result = spawnSync(javaExecutable, ['-version'], { encoding: 'utf8' }); + const output = `${result.stdout || ''}${result.stderr || ''}`; + const match = output.match(/version "(\d+)/); + return match ? Number(match[1]) : 0; +}; + +const resolveJavac = () => { + const candidates = [ + process.env.JAVA_COMPILER, + process.env.JAVA_HOME && path.join(process.env.JAVA_HOME, 'bin', 'javac'), + '/opt/homebrew/opt/openjdk@21/bin/javac', + '/usr/local/opt/openjdk@21/bin/javac', + '/opt/homebrew/opt/openjdk@17/bin/javac', + '/usr/local/opt/openjdk@17/bin/javac', + 'javac', + ].filter(Boolean); + + for (const candidate of candidates) { + if (candidate !== 'javac' && !fs.existsSync(candidate)) { + continue; + } + + const check = spawnSync(candidate, ['-version'], { encoding: 'utf8' }); + if (check.status !== 0 && check.error) { + continue; + } + + const javaBin = candidate.replace(/javac$/i, 'java'); + const major = getJavaMajorVersion(fs.existsSync(javaBin) ? javaBin : candidate.replace('javac', 'java')); + if (major >= 25) { + continue; + } + + if (major >= 11 || major === 0) { + return candidate; + } + } + + return null; +}; + +const resolveJarTool = javac => { + const jarCandidate = javac.replace(/javac$/i, 'jar'); + if (fs.existsSync(jarCandidate)) { + return jarCandidate; + } + + const jarOnPath = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['jar'], { encoding: 'utf8' }); + if (jarOnPath.status === 0 && jarOnPath.stdout.trim()) { + return jarOnPath.stdout.trim().split(/\r?\n/)[0]; + } + + return 'jar'; +}; + +const run = (command, args, options = {}) => { + const result = spawnSync(command, args, { stdio: 'inherit', ...options }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed (exit ${result.status ?? 'unknown'})`); + } +}; + +const compileBridge = javac => { + const ojdbc = path.join(LIB_DIR, 'ojdbc11.jar'); + const json = path.join(LIB_DIR, 'json.jar'); + const classpath = [ojdbc, json].join(path.delimiter); + + log(`Compiling KerberosJdbcBridge with ${javac}...`); + fs.rmSync(CLASSES_DIR, { recursive: true, force: true }); + fs.mkdirSync(CLASSES_DIR, { recursive: true }); + + run(javac, ['-cp', classpath, '-d', CLASSES_DIR, SRC]); + + const jarTool = resolveJarTool(javac); + run(jarTool, ['cf', OUT_JAR, '-C', CLASSES_DIR, '.']); + + log(`Built ${OUT_JAR}`); +}; + +const main = async () => { + const ojdbcJar = path.join(LIB_DIR, 'ojdbc11.jar'); + const jsonJar = path.join(LIB_DIR, 'json.jar'); + + try { + await ensureJar(OJDBC_URL, ojdbcJar, 'ojdbc11.jar'); + await ensureJar(JSON_URL, jsonJar, 'json.jar'); + } catch (error) { + const message = `Failed to download JDBC runtime JARs: ${error.message}`; + if (strict) { + console.error(message); + process.exit(1); + } + warn(`${message} (re-run: npm run build:jdbc)`); + return; + } + + const javac = resolveJavac(); + if (!javac) { + const message = + 'Java 11–21 (javac) not found — skipped Kerberos bridge compile. ' + + 'Install JDK 21, then run: npm run build:jdbc'; + if (strict) { + console.error(message); + process.exit(1); + } + warn(message); + return; + } + + try { + compileBridge(javac); + } catch (error) { + const message = `Kerberos JDBC bridge build failed: ${error.message}`; + if (strict) { + console.error(message); + process.exit(1); + } + warn(`${message} (re-run: npm run build:jdbc)`); + } +}; + +main().catch(error => { + console.error(error); + process.exit(strict ? 1 : 0); +});