Compare commits
161 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efb634701c | |||
| aa45be6844 | |||
| 0eaf52fa66 | |||
| eb5cf32d68 | |||
| 83c3a5ea09 | |||
| d465ecfe96 | |||
| fd9b18ea31 | |||
| 89505713f1 | |||
| 085f03c178 | |||
| c06d794b92 | |||
| b7a8cca387 | |||
| 3cbcfa3cdb | |||
| 00bd09df55 | |||
| c8b3acc07e | |||
| e7100f8926 | |||
| d9054ff347 | |||
| 002d969a5d | |||
| 91ef3e3c9b | |||
| 7e3797cbfe | |||
| ccfda4c054 | |||
| 28a6211457 | |||
| 7c123833b5 | |||
| a1def17750 | |||
| 94657735fb | |||
| 74741d2a01 | |||
| 94591fef48 | |||
| 44b06e8fc6 | |||
| e35d485ae9 | |||
| f27c0b066f | |||
| 4840ac024d | |||
| d3aacfa94b | |||
| 8671dfaf32 | |||
| d10f6dfd6d | |||
| 22e0429094 | |||
| 1a34f73ae3 | |||
| aaaf252d4c | |||
| 1bf5dec60f | |||
| d7a458f158 | |||
| a7990e2167 | |||
| 8bb95d0a1b | |||
| b8f06426e3 | |||
| 0af1ee6eb2 | |||
| ac19a67cce | |||
| 380fcc34ec | |||
| 32e44c746b | |||
| 84e0a0bf14 | |||
| c44f244b1d | |||
| afee09866d | |||
| c210ef0a8e | |||
| 7fe4b25563 | |||
| 7f26c0a585 | |||
| 2027c9d44c | |||
| 0bb10cabb9 | |||
| e9a9f0ca25 | |||
| 17dafec9de | |||
| b55e1e5aad | |||
| aefa5e7925 | |||
| 63c576e059 | |||
| a6016afdaa | |||
| 0b3658793a | |||
| 05d771d9ba | |||
| 55f3101a19 | |||
| 790ce092ee | |||
| 7729d7e326 | |||
| bcd10c1407 | |||
| a04040e1e9 | |||
| c26fa2d10f | |||
| d51bfb0d60 | |||
| 5527d19198 | |||
| 2829e7c0e9 | |||
| 1066ce9eb1 | |||
| bc00bbfe5c | |||
| 9c451aedf9 | |||
| 4b430340db | |||
| 0372737f3d | |||
| 33bdc39b49 | |||
| 1baedd134d | |||
| ae3aea2296 | |||
| 3a7b856047 | |||
| ae42baa67c | |||
| 83a5a557b0 | |||
| c43bdbcee6 | |||
| f8dcb84c41 | |||
| 8ee4fe4d68 | |||
| d83ca684d7 | |||
| e5becfd87f | |||
| d12196f53a | |||
| ef26d38fce | |||
| 133c9f1e8f | |||
| cb8be12f1a | |||
| 48b9bde8ae | |||
| 1cb47eaa9c | |||
| 265bbc65df | |||
| 188ba1967d | |||
| 9d2266dffe | |||
| 4ab6abf924 | |||
| af9cb55729 | |||
| d7a553cd8d | |||
| e5fec4df71 | |||
| 071571eca9 | |||
| 1229ecc1d9 | |||
| 03992ae227 | |||
| 48e9a3f5ec | |||
| d7eaa5ef70 | |||
| 5b1b7ecb71 | |||
| de1cad422e | |||
| 86448e5b20 | |||
| 8be07ea8dc | |||
| ffde535390 | |||
| cf0e9ab50d | |||
| 95f263c3a6 | |||
| 83063d757a | |||
| 6b49d13236 | |||
| 610548ed66 | |||
| 8f3a7eb435 | |||
| a88d3d5788 | |||
| ac84b20bb0 | |||
| 0b62c5e3bd | |||
| 241b04247e | |||
| bbdb9841fd | |||
| 1d1e85f1fa | |||
| 86a06d9de0 | |||
| a3cc26d958 | |||
| fe48d63164 | |||
| 21aa4a9854 | |||
| 27baab1a86 | |||
| 33a7add751 | |||
| 7abda79214 | |||
| 9905b17f3d | |||
| 6483cea6c6 | |||
| c185d00dc3 | |||
| 62636426bf | |||
| 027aee434c | |||
| f2657a3d4d | |||
| 851e56bc57 | |||
| c15355e159 | |||
| 7643807717 | |||
| bd7b832394 | |||
| 66e723052d | |||
| 80c000c601 | |||
| f2102003e3 | |||
| a1e07b1a10 | |||
| b89470e965 | |||
| 942c8d440b | |||
| 607d340b71 | |||
| 659d074d00 | |||
| 07a5f03aa9 | |||
| 242f8df49d | |||
| 5475112806 | |||
| db9981f2b0 | |||
| c7b9ae7243 | |||
| a0bc234c8a | |||
| 0ef9982aff | |||
| 5194b3a993 | |||
| 62ab0a3065 | |||
| 9c85535a9b | |||
| 9bf4b74e2e | |||
| 73c9f580a1 | |||
| e5828c7d31 | |||
| 8afdea8795 | |||
| ba8d6ce068 |
@@ -23,7 +23,7 @@ RUN apk add --no-cache curl unzip \
|
||||
| tar -xz --strip-components=1 -C /usr/local/bin \
|
||||
&& chmod +x /usr/local/bin/apko
|
||||
|
||||
# Generate apko.yaml — Node.js instead of Bun
|
||||
# Generate apko.yaml — Node.js binary comes from node:24-slim, not Wolfi
|
||||
RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") \
|
||||
&& printf '%s\n' \
|
||||
"contents:" \
|
||||
@@ -36,9 +36,8 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
|
||||
" - ca-certificates" \
|
||||
" - busybox" \
|
||||
" - tzdata" \
|
||||
" - nodejs-24" \
|
||||
" - docker-cli" \
|
||||
" - docker-compose" \
|
||||
" - docker-compose=5.1.4-r5" \
|
||||
" - docker-cli-buildx" \
|
||||
" - sqlite" \
|
||||
" - postgresql-client" \
|
||||
@@ -66,7 +65,7 @@ RUN apko build apko.yaml dockhand-base:latest output.tar \
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 2: Application Builder (pure Node.js)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:24-slim AS app-builder
|
||||
FROM --platform=$TARGETPLATFORM node:24-slim AS app-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -76,24 +75,29 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& cp "$(dpkg -L libnss-wrapper | grep 'libnss_wrapper\.so$')" /usr/local/lib/libnss_wrapper.so
|
||||
|
||||
# Copy package files and install dependencies
|
||||
# Copy package files and install dependencies (--ignore-scripts blocks malicious postinstall hooks)
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
RUN MAKEFLAGS="-j$(nproc)" npm ci --ignore-scripts \
|
||||
&& MAKEFLAGS="-j$(nproc)" npm rebuild better-sqlite3 argon2
|
||||
|
||||
# Copy source code and build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production dependencies only (rebuilds native addons like better-sqlite3)
|
||||
RUN rm -rf node_modules \
|
||||
&& npm ci --omit=dev \
|
||||
&& rm -rf node_modules/@types
|
||||
# Production dependencies only
|
||||
# Preserve better-sqlite3 native addon (no prebuilds exist for Node 24 ABI 137)
|
||||
RUN cp -r node_modules/better-sqlite3/build /tmp/better-sqlite3-build \
|
||||
&& rm -rf node_modules \
|
||||
&& npm ci --omit=dev --ignore-scripts \
|
||||
&& cp -r /tmp/better-sqlite3-build node_modules/better-sqlite3/build \
|
||||
&& rm -rf node_modules/@types /tmp/better-sqlite3-build
|
||||
|
||||
# Build Go collector
|
||||
FROM golang:1.24 AS go-builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25.11 AS go-builder
|
||||
ARG TARGETARCH
|
||||
WORKDIR /app
|
||||
COPY collector/ ./collector/
|
||||
RUN cd collector && CGO_ENABLED=0 go build -o /app/bin/collection-worker .
|
||||
RUN cd collector && CGO_ENABLED=0 GOARCH=$TARGETARCH go build -o /app/bin/collection-worker .
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 3: Final Image (Scratch + Custom Wolfi OS)
|
||||
@@ -103,6 +107,10 @@ FROM scratch
|
||||
# Install custom Wolfi OS with Node.js
|
||||
COPY --from=os-builder /work/rootfs/ /
|
||||
|
||||
# Copy Node.js binary from official node:24-slim (platform-correct, conservative CPU baseline)
|
||||
# Wolfi's nodejs-24 targets ARMv8.1+ which causes SIGILL on Cortex-A53 (Raspberry Pi 3+)
|
||||
COPY --from=app-builder /usr/local/bin/node /usr/local/bin/node
|
||||
|
||||
# Copy libnss_wrapper for git SSH with arbitrary UIDs
|
||||
COPY --from=app-builder /usr/local/lib/libnss_wrapper.so /usr/lib/libnss_wrapper.so
|
||||
|
||||
@@ -158,7 +166,7 @@ RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/ || exit 1
|
||||
CMD curl -f http://localhost:${PORT:-3000}/ || exit 1
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["node", "/app/server.js"]
|
||||
CMD []
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
# =============================================================================
|
||||
# Dockhand Docker Image - Baseline Build (Alpine/musl, amd64 only)
|
||||
# =============================================================================
|
||||
# For older x86_64 hardware without AVX2/SSE4.2 (TrueNAS, older Intel Atom/Celeron)
|
||||
# Uses node:24-alpine (musl libc) compiled conservatively for all x86_64 CPUs.
|
||||
# The Wolfi/glibc build crashes with SIGILL on CPUs that don't support the
|
||||
# microarchitecture level Wolfi packages are compiled for.
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 1: Application Builder (Alpine - musl-compatible native addons)
|
||||
# -----------------------------------------------------------------------------
|
||||
# IMPORTANT: Must use alpine builder so native addons (better-sqlite3) are
|
||||
# compiled against musl libc, not glibc. Cross-ABI copies would not work.
|
||||
FROM node:24-alpine AS app-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git curl python3 make g++ gcc musl-dev
|
||||
|
||||
# Build getrandom shim for old kernels (< 3.17) that lack the syscall
|
||||
COPY shims/getrandom-shim.c /tmp/
|
||||
RUN gcc -shared -fPIC -O2 -o /tmp/libgetrandom-shim.so /tmp/getrandom-shim.c
|
||||
|
||||
# Copy package files and install dependencies (--ignore-scripts blocks malicious postinstall hooks)
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --ignore-scripts \
|
||||
&& npm rebuild better-sqlite3 argon2
|
||||
|
||||
# Copy source code and build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production dependencies only
|
||||
# Preserve better-sqlite3 native addon (no prebuilds exist for Node 24 ABI 137)
|
||||
RUN cp -r node_modules/better-sqlite3/build /tmp/better-sqlite3-build \
|
||||
&& rm -rf node_modules \
|
||||
&& npm ci --omit=dev --ignore-scripts \
|
||||
&& cp -r /tmp/better-sqlite3-build node_modules/better-sqlite3/build \
|
||||
&& rm -rf node_modules/@types /tmp/better-sqlite3-build
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 2: Go Collector Builder
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM golang:1.25.8 AS go-builder
|
||||
WORKDIR /app
|
||||
COPY collector/ ./collector/
|
||||
RUN cd collector && CGO_ENABLED=0 go build -o /app/bin/collection-worker .
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 3: Final Image (Alpine-based runtime)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:24-alpine
|
||||
|
||||
# Install runtime packages
|
||||
RUN apk add --no-cache \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
docker-cli \
|
||||
docker-compose \
|
||||
docker-cli-buildx \
|
||||
sqlite \
|
||||
postgresql-client \
|
||||
git \
|
||||
openssh \
|
||||
curl \
|
||||
tini \
|
||||
su-exec \
|
||||
libstdc++
|
||||
|
||||
# Create docker compose plugin symlink (skip if package already installed it there)
|
||||
RUN mkdir -p /usr/libexec/docker/cli-plugins \
|
||||
&& [ -x /usr/libexec/docker/cli-plugins/docker-compose ] \
|
||||
|| ln -sf /usr/bin/docker-compose /usr/libexec/docker/cli-plugins/docker-compose
|
||||
|
||||
# Create dockhand user and group
|
||||
RUN addgroup -g 1001 dockhand \
|
||||
&& adduser -u 1001 -G dockhand -h /home/dockhand -D dockhand
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Set up environment variables
|
||||
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
|
||||
NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
HOST=0.0.0.0 \
|
||||
DATA_DIR=/app/data \
|
||||
HOME=/home/dockhand \
|
||||
PUID=1001 \
|
||||
PGID=1001 \
|
||||
LD_PRELOAD=/usr/lib/libgetrandom-shim.so
|
||||
|
||||
# Copy application files with correct ownership
|
||||
COPY --from=app-builder --chown=dockhand:dockhand /app/node_modules ./node_modules
|
||||
COPY --from=app-builder --chown=dockhand:dockhand /app/package.json ./
|
||||
COPY --from=app-builder --chown=dockhand:dockhand /app/build ./build
|
||||
COPY --from=app-builder --chown=dockhand:dockhand /app/server.js ./
|
||||
|
||||
# Copy Go collector binary
|
||||
COPY --from=go-builder --chown=dockhand:dockhand /app/bin/collection-worker ./bin/collection-worker
|
||||
|
||||
# Copy database migrations
|
||||
COPY --chown=dockhand:dockhand drizzle/ ./drizzle/
|
||||
COPY --chown=dockhand:dockhand drizzle-pg/ ./drizzle-pg/
|
||||
|
||||
# Copy legal documents
|
||||
COPY --chown=dockhand:dockhand LICENSE.txt PRIVACY.txt ./
|
||||
|
||||
# Copy getrandom shim for old kernels (Synology DS1513+ with kernel 3.10.x)
|
||||
COPY --from=app-builder /tmp/libgetrandom-shim.so /usr/lib/libgetrandom-shim.so
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY docker-entrypoint-node.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Copy emergency scripts
|
||||
COPY --chown=dockhand:dockhand scripts/emergency/ ./scripts/
|
||||
RUN chmod +x ./scripts/*.sh ./scripts/**/*.sh 2>/dev/null || true
|
||||
|
||||
# Create data directories
|
||||
RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \
|
||||
&& chown dockhand:dockhand /app/data /home/dockhand /home/dockhand/.dockhand /home/dockhand/.dockhand/stacks
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:${PORT:-3000}/ || exit 1
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD []
|
||||
@@ -36,6 +36,108 @@ Dockhand is a modern, efficient Docker management application providing real-tim
|
||||
- **Database**: SQLite or PostgreSQL via Drizzle ORM
|
||||
- **Docker**: direct docker API calls.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot1.webp" alt="Environments overview">
|
||||
<p align="center"><sub><sub><sub><b>Environments overview</b> — manage every Docker host from one place</sub></sub></sub></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot2.webp" alt="Environment dashboard">
|
||||
<p align="center"><sub><sub><sub><b>Environment dashboard</b> — live CPU, memory and disk metrics per host</sub></sub></sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot3.webp" alt="Containers">
|
||||
<p align="center"><sub><sub><sub><b>Containers</b> — real-time status, resources and port mappings</sub></sub></sub></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot6.webp" alt="Compose stacks">
|
||||
<p align="center"><sub><sub><sub><b>Compose stacks</b> — deploy and orchestrate multi-container apps</sub></sub></sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot7.webp" alt="Compose editor">
|
||||
<p align="center"><sub><sub><sub><b>Compose editor</b> — edit YAML side-by-side with env variables</sub></sub></sub></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot8.webp" alt="Images">
|
||||
<p align="center"><sub><sub><sub><b>Images</b> — track tags, sizes, updates and clean up unused</sub></sub></sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot4.webp" alt="Logs and terminal">
|
||||
<p align="center"><sub><sub><sub><b>Logs & terminal</b> — stream logs with a shell next to them</sub></sub></sub></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot5.webp" alt="Interactive shell">
|
||||
<p align="center"><sub><sub><sub><b>Interactive shell</b> — exec straight into any container</sub></sub></sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot10.webp" alt="Add environment">
|
||||
<p align="center"><sub><sub><sub><b>Add environment</b> — connect via socket, agent or direct TCP</sub></sub></sub></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot9.webp" alt="Settings and theming">
|
||||
<p align="center"><sub><sub><sub><b>Settings & theming</b> — themes, fonts, scanners and schedules</sub></sub></sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot11.webp" alt="Network graph">
|
||||
<p align="center"><sub><sub><sub><b>Network graph</b> — visualize how services connect across stacks</sub></sub></sub></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot13.webp" alt="Container file browser">
|
||||
<p align="center"><sub><sub><sub><b>Container files</b> — browse, edit, upload and download in-place</sub></sub></sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot12.webp" alt="Image layers">
|
||||
<p align="center"><sub><sub><sub><b>Image layers</b> — inspect every layer, its size and contents</sub></sub></sub></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot14.webp" alt="Vulnerability scanning">
|
||||
<p align="center"><sub><sub><sub><b>Vulnerability scans</b> — Grype & Trivy CVE results per image</sub></sub></sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot15.webp" alt="Volume browser">
|
||||
<p align="center"><sub><sub><sub><b>Volume browser</b> — explore and edit files inside any volume</sub></sub></sub></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot19.webp" alt="Stack graph editor">
|
||||
<p align="center"><sub><sub><sub><b>Stack graph editor</b> — visual editor for services, networks and secrets</sub></sub></sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot16.webp" alt="Deploy from Git">
|
||||
<p align="center"><sub><sub><sub><b>Deploy from Git</b> — pull stacks from repos with webhooks & auto-sync</sub></sub></sub></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot17.webp" alt="Schedules">
|
||||
<p align="center"><sub><sub><sub><b>Schedules</b> — cron-style automation for prune, updates and cleanup</sub></sub></sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot18.webp" alt="Activity log">
|
||||
<p align="center"><sub><sub><sub><b>Activity log</b> — audit every action across all environments</sub></sub></sub></p>
|
||||
</td>
|
||||
<td width="50%"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## License
|
||||
|
||||
Dockhand is licensed under the [Business Source License 1.1](LICENSE.txt) (BSL 1.1).
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
## How to Report a Security Flaw
|
||||
|
||||
Keeping Dockhand secure is a **top** priority. We highly value community contributions that help protect our users.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> If you discover a security vulnerability, please do not create a public GitHub issue - this can expose users to risk before a fix is available.
|
||||
> If you find a security vulnerability, we ask that you keep it private and avoid opening a public issue on GitHub.
|
||||
> Instead, please email us directly at [[security@dockhand.pro](mailto:security@dockhand.pro)]. This inbox has the highest priority.
|
||||
|
||||
## Details to Include
|
||||
|
||||
To help us track down and resolve the bug as efficiently as possible, please provide the following information in your email:
|
||||
- A clear explanation of the flaw
|
||||
- A step-by-step guide on how to reproduce the issue
|
||||
- The specific Dockhand versions and host environments where the bug is present
|
||||
- Any ideas you have for a patch or temporary workaround
|
||||
|
||||
|
||||
## Our take
|
||||
|
||||
Once you submit a report, we promise to:
|
||||
- Confirm receipt of your message within a couple of hours
|
||||
- Swiftly investigate and verify the vulnerability
|
||||
- Roll out a secure patch as quickly as possible
|
||||
- Keep you updated throughout the entire patching process
|
||||
|
||||
We deeply appreciate your commitment to responsible disclosure and your help in keeping the Dockhand ecosystem safe.
|
||||
@@ -1,3 +1,3 @@
|
||||
module github.com/Finsys/dockhand/collector
|
||||
|
||||
go 1.24
|
||||
go 1.25.11
|
||||
|
||||
@@ -221,13 +221,19 @@ func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Clien
|
||||
baseURL = "http://localhost"
|
||||
|
||||
case "http":
|
||||
// Explicit dial timeout and TCP keepalive so connections over dead
|
||||
// tunnels (VPN/Tailscale drops) are detected at kernel level instead
|
||||
// of hanging indefinitely.
|
||||
tcpDial := (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 15 * time.Second}).DialContext
|
||||
transport = &http.Transport{
|
||||
DialContext: tcpDial,
|
||||
MaxIdleConns: 16,
|
||||
MaxIdleConnsPerHost: 16,
|
||||
MaxConnsPerHost: 16,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
streamTransport = &http.Transport{
|
||||
DialContext: tcpDial,
|
||||
MaxIdleConns: 4,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
MaxConnsPerHost: 4,
|
||||
@@ -242,7 +248,9 @@ func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Clien
|
||||
}
|
||||
streamTLSCfg := tlsCfg.Clone()
|
||||
|
||||
tcpDial := (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 15 * time.Second}).DialContext
|
||||
transport = &http.Transport{
|
||||
DialContext: tcpDial,
|
||||
TLSClientConfig: tlsCfg,
|
||||
MaxIdleConns: 16,
|
||||
MaxIdleConnsPerHost: 16,
|
||||
@@ -250,6 +258,7 @@ func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Clien
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
streamTransport = &http.Transport{
|
||||
DialContext: tcpDial,
|
||||
TLSClientConfig: streamTLSCfg,
|
||||
MaxIdleConns: 4,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
@@ -274,7 +283,11 @@ func buildTLSConfig(cfg *EnvConfig) (*tls.Config, error) {
|
||||
}
|
||||
|
||||
if cfg.CA != "" {
|
||||
pool := x509.NewCertPool()
|
||||
// Start from system cert pool so intermediate CAs can chain to system roots
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if !pool.AppendCertsFromPEM([]byte(cfg.CA)) {
|
||||
return nil, fmt.Errorf("failed to parse CA certificate")
|
||||
}
|
||||
@@ -318,15 +331,32 @@ func (e *environment) doStreamRequest(ctx context.Context, method, path string)
|
||||
return e.streamClient.Do(req)
|
||||
}
|
||||
|
||||
func (e *environment) ping(ctx context.Context) bool {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
resp, err := e.doRequest(ctx, "GET", "/_ping")
|
||||
if err != nil {
|
||||
return false
|
||||
func (e *environment) ping(ctx context.Context) error {
|
||||
attempt := func() error {
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
resp, err := e.doRequest(pingCtx, "GET", "/_ping")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
drainAndClose(resp)
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("ping returned status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
drainAndClose(resp)
|
||||
return resp.StatusCode == 200
|
||||
|
||||
if err := attempt(); err == nil {
|
||||
return nil
|
||||
} else if ctx.Err() != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Stale pooled connections (e.g. after a VPN/tunnel drop) hang requests
|
||||
// until timeout while the host is actually reachable. Evict the pool and
|
||||
// retry once on a guaranteed-fresh connection.
|
||||
e.closeTransports()
|
||||
return attempt()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -354,11 +384,11 @@ func (m *manager) runMetrics(env *environment) {
|
||||
}
|
||||
|
||||
func (m *manager) collectMetrics(env *environment) {
|
||||
if !env.ping(env.ctx) {
|
||||
if err := env.ping(env.ctx); err != nil {
|
||||
if env.online || !env.statusReported {
|
||||
env.online = false
|
||||
env.statusReported = true
|
||||
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable"})
|
||||
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable: " + err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -417,7 +447,7 @@ func (m *manager) collectMetrics(env *environment) {
|
||||
sCtx, sCancel := context.WithTimeout(env.ctx, 10*time.Second)
|
||||
defer sCancel()
|
||||
|
||||
sResp, sErr := env.doRequest(sCtx, "GET", fmt.Sprintf("/containers/%s/stats?stream=false&one-shot=true", id))
|
||||
sResp, sErr := env.doRequest(sCtx, "GET", fmt.Sprintf("/containers/%s/stats?stream=false", id))
|
||||
if sErr != nil {
|
||||
return
|
||||
}
|
||||
@@ -554,11 +584,11 @@ func (m *manager) runEvents(env *environment) {
|
||||
}
|
||||
|
||||
// Stream mode
|
||||
if !env.ping(env.ctx) {
|
||||
if err := env.ping(env.ctx); err != nil {
|
||||
if env.online || !env.statusReported {
|
||||
env.online = false
|
||||
env.statusReported = true
|
||||
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable"})
|
||||
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable: " + err.Error()})
|
||||
}
|
||||
if !waitOrCancel(reconnectDelay) {
|
||||
return
|
||||
@@ -605,12 +635,32 @@ func (m *manager) runEvents(env *environment) {
|
||||
// Force-close the body on context cancellation so scanner.Scan()
|
||||
// unblocks. Without this, the goroutine can leak if the transport's
|
||||
// internal cancel watcher doesn't fire (Go runtime implementation detail).
|
||||
//
|
||||
// The watchdog ticker handles half-open connections (e.g. after a
|
||||
// VPN/tunnel drop): the stream client has no timeout, so Scan() would
|
||||
// otherwise block forever on a dead connection that never errors.
|
||||
// A failed ping (which retries on a fresh connection internally)
|
||||
// means the host is unreachable — close the body so the reconnect
|
||||
// loop takes over.
|
||||
bodyDone := make(chan struct{})
|
||||
var closeBodyOnce sync.Once
|
||||
closeBody := func() { closeBodyOnce.Do(func() { resp.Body.Close() }) }
|
||||
go func() {
|
||||
select {
|
||||
case <-env.ctx.Done():
|
||||
resp.Body.Close()
|
||||
case <-bodyDone:
|
||||
watchdog := time.NewTicker(90 * time.Second)
|
||||
defer watchdog.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-env.ctx.Done():
|
||||
closeBody()
|
||||
return
|
||||
case <-bodyDone:
|
||||
return
|
||||
case <-watchdog.C:
|
||||
if env.ping(env.ctx) != nil {
|
||||
closeBody()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -634,7 +684,7 @@ func (m *manager) runEvents(env *environment) {
|
||||
}
|
||||
}
|
||||
close(bodyDone)
|
||||
resp.Body.Close()
|
||||
closeBody()
|
||||
|
||||
if env.ctx.Err() != nil {
|
||||
return
|
||||
@@ -649,11 +699,11 @@ func (m *manager) runEvents(env *environment) {
|
||||
}
|
||||
|
||||
func (m *manager) pollEvents(env *environment) {
|
||||
if !env.ping(env.ctx) {
|
||||
if err := env.ping(env.ctx); err != nil {
|
||||
if env.online || !env.statusReported {
|
||||
env.online = false
|
||||
env.statusReported = true
|
||||
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable"})
|
||||
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable: " + err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -732,7 +782,7 @@ func (m *manager) runDiskChecks(env *environment) {
|
||||
}
|
||||
|
||||
func (m *manager) checkDisk(env *environment) {
|
||||
if !env.ping(env.ctx) {
|
||||
if env.ping(env.ctx) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -928,6 +978,11 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// stdin closed — parent process exited or pipe broke. Shut down cleanly
|
||||
// so Node.js can restart us if needed.
|
||||
if err := scanner.Err(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[collector] stdin read error: %v\n", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[collector] stdin closed, exiting\n")
|
||||
mgr.shutdown()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Dockhand Docker Entrypoint (Node.js)
|
||||
# === Configuration ===
|
||||
PUID=${PUID:-1001}
|
||||
PGID=${PGID:-1001}
|
||||
|
||||
# Increase body size limit for container file uploads (default 512KB is too small)
|
||||
export BODY_SIZE_LIMIT=${BODY_SIZE_LIMIT:-2G}
|
||||
|
||||
# Default command (--expose-gc allows forced GC from /api/debug/memory?gc=true)
|
||||
# Custom CA: set NODE_EXTRA_CA_CERTS=/path/to/ca.crt (appends to built-in CAs, git ops auto-merge with system CAs)
|
||||
# Enterprise (system CA store): set NODE_OPTIONS="--use-openssl-ca"
|
||||
if [ "$MEMORY_MONITOR" = "true" ]; then
|
||||
DEFAULT_CMD="node --dns-result-order=ipv4first --no-network-family-autoselection --expose-gc /app/server.js"
|
||||
else
|
||||
DEFAULT_CMD="node --dns-result-order=ipv4first --no-network-family-autoselection /app/server.js"
|
||||
fi
|
||||
|
||||
# === Detect if running as root ===
|
||||
RUNNING_AS_ROOT=false
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
RUNNING_AS_ROOT=true
|
||||
fi
|
||||
|
||||
# === Non-root mode (user: directive in compose) ===
|
||||
if [ "$RUNNING_AS_ROOT" = "false" ]; then
|
||||
echo "Running as user $(id -u):$(id -g) (set via container user directive)"
|
||||
|
||||
DATA_DIR="${DATA_DIR:-/app/data}"
|
||||
if [ ! -d "$DATA_DIR/db" ]; then
|
||||
echo "Creating database directory at $DATA_DIR/db"
|
||||
mkdir -p "$DATA_DIR/db" 2>/dev/null || {
|
||||
echo "ERROR: Cannot create $DATA_DIR/db directory"
|
||||
echo "Ensure the data volume is mounted with correct permissions for user $(id -u):$(id -g)"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
if [ ! -d "$DATA_DIR/stacks" ]; then
|
||||
mkdir -p "$DATA_DIR/stacks" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
SOCKET_PATH="/var/run/docker.sock"
|
||||
if [ -S "$SOCKET_PATH" ]; then
|
||||
if test -r "$SOCKET_PATH" 2>/dev/null; then
|
||||
echo "Docker socket accessible at $SOCKET_PATH"
|
||||
if [ -z "$DOCKHAND_HOSTNAME" ]; then
|
||||
DETECTED_HOSTNAME=$(curl -s --unix-socket "$SOCKET_PATH" http://localhost/info 2>/dev/null | sed -n 's/.*"Name":"\([^"]*\)".*/\1/p')
|
||||
if [ -n "$DETECTED_HOSTNAME" ]; then
|
||||
export DOCKHAND_HOSTNAME="$DETECTED_HOSTNAME"
|
||||
echo "Detected Docker host hostname: $DOCKHAND_HOSTNAME"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
SOCKET_GID=$(stat -c '%g' "$SOCKET_PATH" 2>/dev/null || echo "unknown")
|
||||
echo "WARNING: Docker socket not readable by user $(id -u)"
|
||||
echo "Add --group-add $SOCKET_GID to your docker run command"
|
||||
fi
|
||||
else
|
||||
echo "No Docker socket found at $SOCKET_PATH"
|
||||
echo "Configure Docker environments via the web UI (Settings > Environments)"
|
||||
fi
|
||||
|
||||
if [ "$1" = "" ]; then
|
||||
exec $DEFAULT_CMD
|
||||
else
|
||||
exec "$@"
|
||||
fi
|
||||
fi
|
||||
|
||||
# === User Setup ===
|
||||
if [ "$PUID" = "0" ]; then
|
||||
echo "Running as root user (PUID=0)"
|
||||
RUN_USER="root"
|
||||
elif [ "$RUNNING_AS_ROOT" = "true" ] && [ "$PUID" = "1001" ] && [ "$PGID" = "1001" ]; then
|
||||
echo "Running as root user"
|
||||
RUN_USER="root"
|
||||
else
|
||||
RUN_USER="dockhand"
|
||||
if [ "$PUID" != "1001" ] || [ "$PGID" != "1001" ]; then
|
||||
echo "Configuring user with PUID=$PUID PGID=$PGID"
|
||||
|
||||
deluser dockhand 2>/dev/null || true
|
||||
delgroup dockhand 2>/dev/null || true
|
||||
|
||||
SKIP_USER_CREATE=false
|
||||
EXISTING=$(awk -F: -v uid="$PUID" '$3 == uid { print $1 }' /etc/passwd)
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "WARNING: UID $PUID already in use by '$EXISTING'. Using default UID 1001."
|
||||
PUID=1001
|
||||
fi
|
||||
|
||||
TARGET_GROUP=$(awk -F: -v gid="$PGID" '$3 == gid { print $1 }' /etc/group)
|
||||
if [ -z "$TARGET_GROUP" ]; then
|
||||
addgroup -g "$PGID" dockhand
|
||||
TARGET_GROUP="dockhand"
|
||||
fi
|
||||
|
||||
if [ "$SKIP_USER_CREATE" = "false" ]; then
|
||||
adduser -u "$PUID" -G "$TARGET_GROUP" -h /home/dockhand -D dockhand
|
||||
fi
|
||||
fi
|
||||
|
||||
# === Directory Ownership ===
|
||||
# Only chown Dockhand's own subdirectories, not the entire /app/data tree.
|
||||
# Recursive chown on /app/data breaks stack volumes mounted with relative paths
|
||||
# (e.g. ./postgresql:/var/lib/postgresql) that need different ownership (#719).
|
||||
DATA_DIR="${DATA_DIR:-/app/data}"
|
||||
chown "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
|
||||
for subdir in db stacks git-repos tmp icons snapshots scanner-cache; do
|
||||
if [ -d "$DATA_DIR/$subdir" ]; then
|
||||
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR/$subdir" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
if [ "$RUN_USER" = "dockhand" ]; then
|
||||
chown -R dockhand:dockhand /home/dockhand 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -n "$DATA_DIR" ] && [ "$DATA_DIR" != "/app/data" ] && [ "$DATA_DIR" != "./data" ]; then
|
||||
mkdir -p "$DATA_DIR"
|
||||
chown "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
|
||||
for subdir in db stacks git-repos tmp icons snapshots scanner-cache; do
|
||||
if [ -d "$DATA_DIR/$subdir" ]; then
|
||||
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR/$subdir" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# === Docker Socket Access ===
|
||||
SOCKET_PATH="/var/run/docker.sock"
|
||||
|
||||
if [ -S "$SOCKET_PATH" ]; then
|
||||
if [ "$RUN_USER" != "root" ]; then
|
||||
SOCKET_GID=$(stat -c '%g' "$SOCKET_PATH" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$SOCKET_GID" ]; then
|
||||
if ! su-exec "$RUN_USER" test -r "$SOCKET_PATH" 2>/dev/null; then
|
||||
echo "Docker socket GID: $SOCKET_GID - adding $RUN_USER to docker group..."
|
||||
|
||||
DOCKER_GROUP=$(awk -F: -v gid="$SOCKET_GID" '$3 == gid { print $1 }' /etc/group)
|
||||
if [ -z "$DOCKER_GROUP" ]; then
|
||||
DOCKER_GROUP="docker"
|
||||
addgroup -g "$SOCKET_GID" "$DOCKER_GROUP" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
addgroup "$RUN_USER" "$DOCKER_GROUP" 2>/dev/null || \
|
||||
adduser "$RUN_USER" "$DOCKER_GROUP" 2>/dev/null || true
|
||||
|
||||
if su-exec "$RUN_USER" test -r "$SOCKET_PATH" 2>/dev/null; then
|
||||
echo "Docker socket accessible at $SOCKET_PATH"
|
||||
else
|
||||
echo "WARNING: Could not grant Docker socket access to $RUN_USER"
|
||||
echo "Try running container with: --group-add $SOCKET_GID"
|
||||
fi
|
||||
else
|
||||
echo "Docker socket accessible at $SOCKET_PATH"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Docker socket accessible at $SOCKET_PATH"
|
||||
fi
|
||||
|
||||
if [ -z "$DOCKHAND_HOSTNAME" ]; then
|
||||
DETECTED_HOSTNAME=$(curl -s --unix-socket "$SOCKET_PATH" http://localhost/info 2>/dev/null | sed -n 's/.*"Name":"\([^"]*\)".*/\1/p')
|
||||
if [ -n "$DETECTED_HOSTNAME" ]; then
|
||||
export DOCKHAND_HOSTNAME="$DETECTED_HOSTNAME"
|
||||
echo "Detected Docker host hostname: $DOCKHAND_HOSTNAME"
|
||||
fi
|
||||
else
|
||||
echo "Using configured hostname: $DOCKHAND_HOSTNAME"
|
||||
fi
|
||||
else
|
||||
echo "No local Docker socket mounted (this is normal when using socket-proxy or remote Docker)"
|
||||
echo "Configure your Docker environment via the web UI: Settings > Environments"
|
||||
fi
|
||||
|
||||
# === Run Application ===
|
||||
if [ "$RUN_USER" = "root" ]; then
|
||||
if [ "$1" = "" ]; then
|
||||
exec $DEFAULT_CMD
|
||||
else
|
||||
exec "$@"
|
||||
fi
|
||||
else
|
||||
echo "Running as user: $RUN_USER"
|
||||
if [ "$1" = "" ]; then
|
||||
exec su-exec "$RUN_USER" $DEFAULT_CMD
|
||||
else
|
||||
exec su-exec "$RUN_USER" "$@"
|
||||
fi
|
||||
fi
|
||||
@@ -113,14 +113,28 @@ else
|
||||
fi
|
||||
|
||||
# === Directory Ownership ===
|
||||
chown -R "$RUN_USER":"$RUN_USER" /app/data 2>/dev/null || true
|
||||
# Only chown Dockhand's own subdirectories, not the entire /app/data tree.
|
||||
# Recursive chown on /app/data breaks stack volumes mounted with relative paths
|
||||
# (e.g. ./postgresql:/var/lib/postgresql) that need different ownership (#719).
|
||||
DATA_DIR="${DATA_DIR:-/app/data}"
|
||||
chown "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
|
||||
for subdir in db stacks git-repos tmp icons snapshots scanner-cache; do
|
||||
if [ -d "$DATA_DIR/$subdir" ]; then
|
||||
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR/$subdir" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
if [ "$RUN_USER" = "dockhand" ]; then
|
||||
chown -R dockhand:dockhand /home/dockhand 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -n "$DATA_DIR" ] && [ "$DATA_DIR" != "/app/data" ] && [ "$DATA_DIR" != "./data" ]; then
|
||||
mkdir -p "$DATA_DIR"
|
||||
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
|
||||
chown "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
|
||||
for subdir in db stacks git-repos tmp icons snapshots scanner-cache; do
|
||||
if [ -d "$DATA_DIR/$subdir" ]; then
|
||||
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR/$subdir" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 365 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 150 KiB |
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "git_stacks" ADD COLUMN "build_on_deploy" boolean DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE "git_stacks" ADD COLUMN "repull_images" boolean DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE "git_stacks" ADD COLUMN "force_redeploy" boolean DEFAULT false;
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE IF NOT EXISTS "api_tokens" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"token_hash" text NOT NULL,
|
||||
"token_prefix" text NOT NULL,
|
||||
"last_used" timestamp,
|
||||
"expires_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now(),
|
||||
CONSTRAINT "api_tokens_token_hash_unique" UNIQUE("token_hash")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "api_tokens_user_id_idx" ON "api_tokens" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "api_tokens_token_prefix_idx" ON "api_tokens" USING btree ("token_prefix");
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "git_stacks" ADD COLUMN "context_dir" text;--> statement-breakpoint
|
||||
ALTER TABLE "git_stacks" ADD COLUMN "no_build_cache" boolean DEFAULT false;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "git_stacks" ADD COLUMN "synced_files" text;
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE "template_sources" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"source_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"enabled" boolean DEFAULT true,
|
||||
"builtin" boolean DEFAULT false,
|
||||
"sort_order" integer DEFAULT 0,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now(),
|
||||
CONSTRAINT "template_sources_source_id_unique" UNIQUE("source_id")
|
||||
);
|
||||
@@ -2352,14 +2352,14 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"external_compose_path": {
|
||||
"name": "external_compose_path",
|
||||
"compose_path": {
|
||||
"name": "compose_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"external_env_path": {
|
||||
"name": "external_env_path",
|
||||
"env_path": {
|
||||
"name": "env_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
|
||||
@@ -29,6 +29,41 @@
|
||||
"when": 1767687362730,
|
||||
"tag": "0003_add_stack_paths",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1774155653752,
|
||||
"tag": "0004_add_git_stack_deploy_options",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1775312212996,
|
||||
"tag": "0005_add_api_tokens",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1777220350655,
|
||||
"tag": "0006_add_git_stack_context_dir",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1781158711008,
|
||||
"tag": "0007_add_synced_files",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1781620381909,
|
||||
"tag": "0008_add_template_sources",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE `git_stacks` ADD `build_on_deploy` integer DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE `git_stacks` ADD `repull_images` integer DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE `git_stacks` ADD `force_redeploy` integer DEFAULT false;
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE `api_tokens` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`token_hash` text NOT NULL,
|
||||
`token_prefix` text NOT NULL,
|
||||
`last_used` text,
|
||||
`expires_at` text,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` text DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `api_tokens_token_hash_unique` ON `api_tokens` (`token_hash`);--> statement-breakpoint
|
||||
CREATE INDEX `api_tokens_user_id_idx` ON `api_tokens` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `api_tokens_token_prefix_idx` ON `api_tokens` (`token_prefix`);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `git_stacks` ADD `context_dir` text;--> statement-breakpoint
|
||||
ALTER TABLE `git_stacks` ADD `no_build_cache` integer DEFAULT false;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `git_stacks` ADD `synced_files` text;
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE `template_sources` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`source_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`url` text NOT NULL,
|
||||
`enabled` integer DEFAULT true,
|
||||
`builtin` integer DEFAULT false,
|
||||
`sort_order` integer DEFAULT 0,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` text DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `template_sources_source_id_unique` ON `template_sources` (`source_id`);
|
||||
@@ -29,6 +29,41 @@
|
||||
"when": 1767689000000,
|
||||
"tag": "0003_add_stack_paths",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1774155653752,
|
||||
"tag": "0004_add_git_stack_deploy_options",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1775311743346,
|
||||
"tag": "0005_add_api_tokens",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1777220350655,
|
||||
"tag": "0006_add_git_stack_context_dir",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1781158702731,
|
||||
"tag": "0007_add_synced_files",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1781620376161,
|
||||
"tag": "0008_add_template_sources",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dockhand",
|
||||
"private": true,
|
||||
"version": "1.0.19",
|
||||
"version": "1.0.34",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npx vite dev",
|
||||
@@ -63,30 +63,33 @@
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
"@codemirror/lang-sql": "6.10.0",
|
||||
"@codemirror/lang-xml": "6.1.0",
|
||||
"@codemirror/lang-yaml": "6.1.2",
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.12.1",
|
||||
"@codemirror/legacy-modes": "6.5.3",
|
||||
"@codemirror/search": "6.6.0",
|
||||
"@codemirror/state": "6.5.4",
|
||||
"@codemirror/theme-one-dark": "6.1.3",
|
||||
"@codemirror/view": "6.39.11",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@lucide/lab": "^0.1.2",
|
||||
"argon2": "^0.41.1",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"codemirror": "6.0.2",
|
||||
"@lucide/lab": "0.1.2",
|
||||
"ansi_up": "6.0.6",
|
||||
"argon2": "0.41.1",
|
||||
"better-sqlite3": "11.7.0",
|
||||
"croner": "9.1.0",
|
||||
"cronstrue": "3.9.0",
|
||||
"devalue": "5.6.3",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"ldapts": "^8.1.3",
|
||||
"nodemailer": "^7.0.12",
|
||||
"otpauth": "^9.4.1",
|
||||
"devalue": "5.8.1",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"fast-xml-parser": "5.7.3",
|
||||
"js-yaml": "4.1.1",
|
||||
"ldapts": "8.1.3",
|
||||
"nodemailer": "8.0.9",
|
||||
"otpauth": "9.4.1",
|
||||
"postgres": "3.4.8",
|
||||
"qrcode": "^1.5.4",
|
||||
"svelte-dnd-action": "0.9.69",
|
||||
"qrcode": "1.5.4",
|
||||
"rollup": "4.60.0",
|
||||
"svelte-sonner": "1.0.7",
|
||||
"ws": "^8.18.0"
|
||||
"undici": "7.24.5",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.10.1",
|
||||
@@ -100,7 +103,7 @@
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/nodemailer": "7.0.5",
|
||||
"@types/nodemailer": "7.0.11",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
@@ -114,13 +117,12 @@
|
||||
"d3-shape": "^3.2.0",
|
||||
"drizzle-kit": "0.31.8",
|
||||
"layerchart": "^1.0.13",
|
||||
"lucide-svelte": "^0.562.0",
|
||||
"lucide-svelte": "0.562.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "5.53.5",
|
||||
"svelte": "5.55.7",
|
||||
"svelte-check": "^4.3.5",
|
||||
"svelte-easy-crop": "^5.0.0",
|
||||
"svelte-virtual-scroll-list": "^1.3.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
@@ -135,6 +137,7 @@
|
||||
"@codemirror/commands": "6.10.1",
|
||||
"@codemirror/search": "6.6.0",
|
||||
"@lezer/common": "1.5.0",
|
||||
"@lezer/highlight": "1.2.3"
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"devalue": "5.8.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
/**
|
||||
* Production Server Wrapper
|
||||
*
|
||||
* Wraps @sveltejs/adapter-node's output with WebSocket support for:
|
||||
* - Terminal exec connections (xterm.js ↔ Docker exec)
|
||||
* - Hawser Edge agent connections
|
||||
*
|
||||
* Usage: node ./server.js
|
||||
*/
|
||||
|
||||
import { createServer as createHttpServer, request as httpRequest } from 'node:http';
|
||||
import { createServer as createHttpsServer, request as httpsRequest } from 'node:https';
|
||||
import { createConnection } from 'node:net';
|
||||
import { connect as tlsConnect, rootCertificates } from 'node:tls';
|
||||
import { randomUUID, X509Certificate } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { handler } from './build/handler.js';
|
||||
|
||||
// Patch console to prepend ISO timestamps
|
||||
const _log = console.log;
|
||||
const _error = console.error;
|
||||
const _warn = console.warn;
|
||||
const ts = () => new Date().toISOString();
|
||||
console.log = (...args) => _log(ts(), ...args);
|
||||
console.error = (...args) => _error(ts(), ...args);
|
||||
console.warn = (...args) => _warn(ts(), ...args);
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
|
||||
// Optional native HTTPS listener (#1102). Off by default to keep existing
|
||||
// deployments unchanged. When HTTPS_MODE=on, HTTPS_CERT_PATH and
|
||||
// HTTPS_KEY_PATH must both point to readable PEM files.
|
||||
const HTTPS_MODE = (process.env.HTTPS_MODE || 'off').toLowerCase();
|
||||
const useHttps = HTTPS_MODE === 'on';
|
||||
|
||||
let server;
|
||||
if (useHttps) {
|
||||
const certPath = process.env.HTTPS_CERT_PATH;
|
||||
const keyPath = process.env.HTTPS_KEY_PATH;
|
||||
const caPath = process.env.HTTPS_CA_PATH;
|
||||
|
||||
console.log('[HTTPS] mode=on');
|
||||
console.log(`[HTTPS] cert=${certPath || '(missing)'}`);
|
||||
console.log(`[HTTPS] key=${keyPath || '(missing)'}`);
|
||||
console.log(`[HTTPS] ca=${caPath || '(none)'}`);
|
||||
|
||||
if (!certPath || !keyPath) {
|
||||
console.error('[HTTPS] HTTPS_MODE=on requires HTTPS_CERT_PATH and HTTPS_KEY_PATH');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let certPem, keyPem, caPem;
|
||||
try {
|
||||
certPem = readFileSync(certPath);
|
||||
keyPem = readFileSync(keyPath);
|
||||
if (caPath) caPem = readFileSync(caPath);
|
||||
} catch (e) {
|
||||
console.error(`[HTTPS] Failed to read cert/key file: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse cert metadata so operators can confirm they mounted the right file.
|
||||
try {
|
||||
const x509 = new X509Certificate(certPem);
|
||||
console.log(`[HTTPS] cert subject: ${x509.subject.replace(/\n/g, ', ')}`);
|
||||
console.log(`[HTTPS] cert issuer: ${x509.issuer.replace(/\n/g, ', ')}`);
|
||||
console.log(`[HTTPS] cert SAN: ${x509.subjectAltName || '(none)'}`);
|
||||
console.log(`[HTTPS] cert valid: ${x509.validFrom} → ${x509.validTo}`);
|
||||
const expiresAt = new Date(x509.validTo).getTime();
|
||||
const daysLeft = Math.floor((expiresAt - Date.now()) / 86400000);
|
||||
if (daysLeft < 0) {
|
||||
console.warn(`[HTTPS] WARNING: certificate expired ${-daysLeft} day(s) ago`);
|
||||
} else if (daysLeft < 30) {
|
||||
console.warn(`[HTTPS] WARNING: certificate expires in ${daysLeft} day(s)`);
|
||||
} else {
|
||||
console.log(`[HTTPS] cert expires in ${daysLeft} day(s)`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[HTTPS] Failed to parse certificate: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tlsOptions = { cert: certPem, key: keyPem };
|
||||
if (caPem) tlsOptions.ca = caPem;
|
||||
|
||||
// HSTS — only meaningful over HTTPS, so wired only here. Default 1 year;
|
||||
// set HSTS_MAX_AGE=0 to disable.
|
||||
const hstsMaxAge = parseInt(process.env.HSTS_MAX_AGE ?? '31536000', 10);
|
||||
const hstsHeader = hstsMaxAge > 0 ? `max-age=${hstsMaxAge}` : null;
|
||||
if (hstsHeader) {
|
||||
console.log(`[HTTPS] HSTS enabled: ${hstsHeader}`);
|
||||
} else {
|
||||
console.log('[HTTPS] HSTS disabled (HSTS_MAX_AGE=0)');
|
||||
}
|
||||
|
||||
server = createHttpsServer(tlsOptions, (req, res) => {
|
||||
if (hstsHeader) res.setHeader('Strict-Transport-Security', hstsHeader);
|
||||
handler(req, res);
|
||||
});
|
||||
} else {
|
||||
console.log(`[HTTPS] mode=off (set HTTPS_MODE=on to enable native TLS)`);
|
||||
server = createHttpServer((req, res) => {
|
||||
handler(req, res);
|
||||
});
|
||||
}
|
||||
|
||||
// Create WebSocket server attached to the HTTP server
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
// Track connections
|
||||
const wsConnections = new Map();
|
||||
let wsConnectionCounter = 0;
|
||||
|
||||
// Track Edge exec sessions: execId -> { ws, environmentId }
|
||||
const edgeExecSessions = new Map();
|
||||
|
||||
// Register global send function for Hawser Edge WebSocket messages.
|
||||
// hawser.ts checks this first, and handleEdgeExec uses it for terminal relay.
|
||||
// Reads from __hawserEdgeConnections which is populated by hawser.ts.
|
||||
globalThis.__hawserSendMessage = (envId, message) => {
|
||||
const connections = globalThis.__hawserEdgeConnections;
|
||||
if (!connections) return false;
|
||||
const conn = connections.get(envId);
|
||||
if (!conn || !conn.ws) return false;
|
||||
try {
|
||||
conn.ws.send(message);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('[Hawser WS] sendMessage error:', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Register global handler for exec messages from Hawser Edge agents
|
||||
// Called by hawser.ts when it receives exec_ready/exec_output/exec_end/error messages
|
||||
globalThis.__terminalHandleExecMessage = (msg) => {
|
||||
const execId = msg.execId || msg.requestId;
|
||||
if (!execId) return;
|
||||
|
||||
const session = edgeExecSessions.get(execId);
|
||||
if (!session || session.ws.readyState !== 1) return;
|
||||
|
||||
if (msg.type === 'exec_ready') {
|
||||
// Agent is ready, frontend is already waiting for output
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'exec_output') {
|
||||
const data = Buffer.from(msg.data, 'base64').toString('utf-8');
|
||||
session.ws.send(JSON.stringify({ type: 'output', data }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'exec_end') {
|
||||
session.ws.send(JSON.stringify({ type: 'exit' }));
|
||||
session.ws.close();
|
||||
edgeExecSessions.delete(execId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'error') {
|
||||
session.ws.send(JSON.stringify({ type: 'error', message: msg.error || msg.message }));
|
||||
session.ws.close();
|
||||
edgeExecSessions.delete(execId);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle WebSocket upgrade
|
||||
server.on('upgrade', async (req, socket, head) => {
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
||||
|
||||
// Only handle our specific WebSocket paths
|
||||
const isTerminal = url.pathname.includes('/api/containers/') && url.pathname.includes('/exec');
|
||||
const isHawser = url.pathname === '/api/hawser/connect';
|
||||
|
||||
if (!isTerminal && !isHawser) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
let wsAuth = null;
|
||||
if (isTerminal) {
|
||||
try {
|
||||
if (typeof globalThis.__authenticateWsUpgrade !== 'function') {
|
||||
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wsAuth = await globalThis.__authenticateWsUpgrade(req.headers);
|
||||
if (!wsAuth) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WS] auth error during upgrade:', err);
|
||||
socket.write('HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
if (wsAuth) ws.__auth = wsAuth;
|
||||
wss.emit('connection', ws, req);
|
||||
});
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
||||
const connId = `ws-${++wsConnectionCounter}`;
|
||||
const remoteIp = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|
||||
|| req.socket.remoteAddress
|
||||
|| 'unknown';
|
||||
|
||||
if (url.pathname === '/api/hawser/connect') {
|
||||
handleHawserConnection(ws, connId, remoteIp);
|
||||
} else {
|
||||
handleTerminalConnection(ws, url, connId);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle terminal exec WebSocket connections.
|
||||
* Supports all connection types: socket, direct TCP/TLS, hawser-standard, hawser-edge.
|
||||
*
|
||||
* Uses globalThis functions exposed by the SvelteKit app (docker.ts):
|
||||
* - __terminalGetTarget(envId) - resolves connection info from environment
|
||||
* - __terminalCreateExec(containerId, shell, user, envId) - creates exec via Docker API
|
||||
* - __terminalResizeExec(execId, cols, rows, envId) - resizes exec terminal
|
||||
*/
|
||||
async function handleTerminalConnection(ws, url, connId) {
|
||||
const pathParts = url.pathname.split('/');
|
||||
const containerIdIndex = pathParts.indexOf('containers') + 1;
|
||||
const containerId = pathParts[containerIdIndex];
|
||||
const shell = url.searchParams.get('shell') || '/bin/sh';
|
||||
const user = url.searchParams.get('user') || 'root';
|
||||
const envIdParam = url.searchParams.get('envId');
|
||||
const envId = envIdParam ? parseInt(envIdParam, 10) : undefined;
|
||||
|
||||
if (!containerId) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'No container ID' }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ws.__auth && typeof globalThis.__canAccessEnvForUser === 'function') {
|
||||
try {
|
||||
const ok = await globalThis.__canAccessEnvForUser(ws.__auth, envId);
|
||||
if (!ok) {
|
||||
console.warn(`[WS] env access denied: user=${ws.__auth.username} envId=${envId}`);
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Access denied for this environment' }));
|
||||
ws.close(1008, 'env access denied');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WS] env access check failed:', err);
|
||||
ws.close(1011, 'internal error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve Docker target via SvelteKit app's database
|
||||
let target;
|
||||
if (typeof globalThis.__terminalGetTarget === 'function') {
|
||||
target = await globalThis.__terminalGetTarget(envId);
|
||||
} else {
|
||||
// Fallback: local socket only (SvelteKit not yet loaded)
|
||||
target = { type: 'socket', connectionType: 'socket', socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' };
|
||||
}
|
||||
|
||||
// Handle Hawser Edge mode - relay through agent WebSocket
|
||||
if (target.connectionType === 'hawser-edge') {
|
||||
handleEdgeExec(ws, connId, containerId, shell, user, target.environmentId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create exec instance via SvelteKit app (handles all connection types)
|
||||
let execId;
|
||||
if (typeof globalThis.__terminalCreateExec === 'function') {
|
||||
execId = await globalThis.__terminalCreateExec(containerId, shell, user, envId);
|
||||
} else {
|
||||
// Fallback: create exec directly via local socket
|
||||
execId = await createExecLocal(containerId, shell, user, target.socketPath || '/var/run/docker.sock');
|
||||
}
|
||||
|
||||
// Open raw bidirectional stream to Docker for the exec session
|
||||
const startBody = JSON.stringify({ Detach: false, Tty: true });
|
||||
let dockerStream;
|
||||
|
||||
if (target.type === 'socket') {
|
||||
const socketPath = target.socketPath || '/var/run/docker.sock';
|
||||
dockerStream = createConnection({ path: socketPath });
|
||||
} else if (target.type === 'https' && target.tls) {
|
||||
const tlsOpts = {
|
||||
host: target.host,
|
||||
port: target.port,
|
||||
servername: target.host,
|
||||
rejectUnauthorized: target.tls.rejectUnauthorized ?? true
|
||||
};
|
||||
if (target.tls.ca) tlsOpts.ca = [target.tls.ca, ...rootCertificates];
|
||||
if (target.tls.cert) tlsOpts.cert = [target.tls.cert];
|
||||
if (target.tls.key) tlsOpts.key = target.tls.key;
|
||||
dockerStream = tlsConnect(tlsOpts);
|
||||
} else {
|
||||
// Plain HTTP (direct TCP or hawser-standard)
|
||||
dockerStream = createConnection({ host: target.host, port: target.port });
|
||||
}
|
||||
|
||||
dockerStream.on('connect', () => {
|
||||
const host = target.host || 'localhost';
|
||||
const tokenHeader = target.hawserToken ? `X-Hawser-Token: ${target.hawserToken}\r\n` : '';
|
||||
dockerStream.write(
|
||||
`POST /exec/${execId}/start HTTP/1.1\r\n` +
|
||||
`Host: ${host}\r\n` +
|
||||
`Content-Type: application/json\r\n` +
|
||||
`${tokenHeader}` +
|
||||
`Connection: Upgrade\r\n` +
|
||||
`Upgrade: tcp\r\n` +
|
||||
`Content-Length: ${Buffer.byteLength(startBody)}\r\n` +
|
||||
`\r\n` +
|
||||
startBody
|
||||
);
|
||||
});
|
||||
|
||||
let headersStripped = false;
|
||||
let isChunked = false;
|
||||
|
||||
dockerStream.on('data', (data) => {
|
||||
if (ws.readyState !== 1) return;
|
||||
|
||||
let text = data.toString('utf-8');
|
||||
if (!headersStripped) {
|
||||
if (text.toLowerCase().includes('transfer-encoding: chunked')) {
|
||||
isChunked = true;
|
||||
}
|
||||
const headerEnd = text.indexOf('\r\n\r\n');
|
||||
if (headerEnd > -1) {
|
||||
text = text.slice(headerEnd + 4);
|
||||
headersStripped = true;
|
||||
} else if (text.startsWith('HTTP/')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isChunked && text) {
|
||||
text = text.replace(/^[0-9a-fA-F]+\r\n/gm, '').replace(/\r\n$/g, '');
|
||||
}
|
||||
if (text) {
|
||||
ws.send(JSON.stringify({ type: 'output', data: text }));
|
||||
}
|
||||
});
|
||||
|
||||
dockerStream.on('close', () => {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({ type: 'exit' }));
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
dockerStream.on('error', (err) => {
|
||||
console.error('[Terminal WS] Socket error:', err.message);
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: err.message }));
|
||||
}
|
||||
});
|
||||
|
||||
// Forward terminal input from browser to Docker
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === 'input' && msg.data) {
|
||||
dockerStream.write(msg.data);
|
||||
} else if (msg.type === 'resize' && msg.cols && msg.rows) {
|
||||
// Use SvelteKit's resize function if available (works for all connection types)
|
||||
if (typeof globalThis.__terminalResizeExec === 'function') {
|
||||
globalThis.__terminalResizeExec(execId, msg.cols, msg.rows, envId).catch(() => {});
|
||||
} else {
|
||||
// Fallback: resize via local socket
|
||||
const socketPath = target.socketPath || '/var/run/docker.sock';
|
||||
const resizeReq = httpRequest({
|
||||
socketPath,
|
||||
path: `/exec/${execId}/resize?h=${msg.rows}&w=${msg.cols}`,
|
||||
method: 'POST',
|
||||
}, () => {});
|
||||
resizeReq.on('error', () => {});
|
||||
resizeReq.end();
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
dockerStream.destroy();
|
||||
});
|
||||
|
||||
wsConnections.set(connId, { stream: dockerStream, ws });
|
||||
} catch (err) {
|
||||
console.error('[Terminal WS] Error:', err.message);
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: err.message }));
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
ws.on('close', () => {
|
||||
wsConnections.delete(connId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Hawser Edge exec session.
|
||||
* Sends exec commands through the Hawser WebSocket relay.
|
||||
*/
|
||||
function handleEdgeExec(ws, connId, containerId, shell, user, environmentId) {
|
||||
if (typeof globalThis.__hawserSendMessage !== 'function') {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Edge agent handler not ready' }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const execId = randomUUID();
|
||||
edgeExecSessions.set(execId, { ws, execId, environmentId });
|
||||
|
||||
// Send exec_start to the Hawser agent
|
||||
const execStartMsg = JSON.stringify({
|
||||
type: 'exec_start',
|
||||
execId,
|
||||
containerId,
|
||||
cmd: shell,
|
||||
user,
|
||||
cols: 120,
|
||||
rows: 30
|
||||
});
|
||||
|
||||
const sent = globalThis.__hawserSendMessage(environmentId, execStartMsg);
|
||||
if (!sent) {
|
||||
edgeExecSessions.delete(execId);
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Edge agent not connected' }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward terminal input/resize from browser to agent
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === 'input' && msg.data) {
|
||||
const inputMsg = JSON.stringify({
|
||||
type: 'exec_input',
|
||||
execId,
|
||||
data: Buffer.from(msg.data).toString('base64')
|
||||
});
|
||||
globalThis.__hawserSendMessage(environmentId, inputMsg);
|
||||
} else if (msg.type === 'resize' && msg.cols && msg.rows) {
|
||||
const resizeMsg = JSON.stringify({
|
||||
type: 'exec_resize',
|
||||
execId,
|
||||
cols: msg.cols,
|
||||
rows: msg.rows
|
||||
});
|
||||
globalThis.__hawserSendMessage(environmentId, resizeMsg);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
// Notify agent that exec session ended
|
||||
if (typeof globalThis.__hawserSendMessage === 'function') {
|
||||
const endMsg = JSON.stringify({
|
||||
type: 'exec_end',
|
||||
execId,
|
||||
reason: 'user_closed'
|
||||
});
|
||||
globalThis.__hawserSendMessage(environmentId, endMsg);
|
||||
}
|
||||
edgeExecSessions.delete(execId);
|
||||
wsConnections.delete(connId);
|
||||
});
|
||||
|
||||
wsConnections.set(connId, { ws });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: Create exec via local Docker socket (used before SvelteKit app is loaded)
|
||||
*/
|
||||
function createExecLocal(containerId, shell, user, socketPath) {
|
||||
const createBody = JSON.stringify({
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Tty: true,
|
||||
Cmd: [shell],
|
||||
User: user
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = httpRequest({
|
||||
socketPath,
|
||||
path: `/containers/${containerId}/exec`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(createBody),
|
||||
},
|
||||
}, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString());
|
||||
if (res.statusCode === 201 && body.Id) {
|
||||
resolve(body.Id);
|
||||
} else {
|
||||
reject(new Error(body.message || `Exec create failed: ${res.statusCode}`));
|
||||
}
|
||||
} catch (e) {
|
||||
reject(new Error('Failed to parse exec response'));
|
||||
}
|
||||
});
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(createBody);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Hawser Edge WebSocket connections.
|
||||
* The full Hawser protocol is handled by the SvelteKit app
|
||||
* via the global hawser connection manager.
|
||||
*/
|
||||
function handleHawserConnection(ws, connId, remoteIp) {
|
||||
console.log('[Hawser WS] New connection pending authentication');
|
||||
|
||||
ws.on('message', async (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
|
||||
// Use the global hawser message handler injected by the SvelteKit app
|
||||
if (typeof globalThis.__hawserHandleMessage === 'function') {
|
||||
try {
|
||||
await globalThis.__hawserHandleMessage(ws, msg, connId, remoteIp);
|
||||
} catch (handlerError) {
|
||||
console.error('[Hawser WS] Handler error:', handlerError);
|
||||
// Don't close connection - let it recover
|
||||
}
|
||||
} else {
|
||||
console.warn('[Hawser WS] No global handler registered');
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Server not ready' }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Hawser WS] Message parse error:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (typeof globalThis.__hawserHandleDisconnect === 'function') {
|
||||
globalThis.__hawserHandleDisconnect(ws, connId);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('[Hawser WS] Connection error:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Start the server
|
||||
server.listen(PORT, HOST, () => {
|
||||
const scheme = useHttps ? 'https' : 'http';
|
||||
console.log(`Listening on ${scheme}://${HOST}:${PORT}/ with WebSocket`);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* getrandom() shim for old kernels (< 3.17) that lack the syscall.
|
||||
*
|
||||
* musl libc calls getrandom() which returns ENOSYS on kernel 3.10.x
|
||||
* (e.g. Synology DS1513+). This shim intercepts the call and falls
|
||||
* back to /dev/urandom, which is cryptographically secure after boot
|
||||
* and is the same entropy source getrandom() reads from on modern kernels.
|
||||
*
|
||||
* Usage: LD_PRELOAD=/usr/lib/libgetrandom-shim.so <command>
|
||||
*/
|
||||
|
||||
#define _GNU_SOURCE
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifndef SYS_getrandom
|
||||
# ifdef __x86_64__
|
||||
# define SYS_getrandom 318
|
||||
# elif defined(__aarch64__)
|
||||
# define SYS_getrandom 278
|
||||
# else
|
||||
# error "Unsupported architecture"
|
||||
# endif
|
||||
#endif
|
||||
|
||||
ssize_t getrandom(void *buf, size_t buflen, unsigned int flags) {
|
||||
/* Try the real syscall first */
|
||||
long ret = syscall(SYS_getrandom, buf, buflen, flags);
|
||||
if (ret >= 0 || errno != ENOSYS)
|
||||
return (ssize_t)ret;
|
||||
|
||||
/* Kernel too old — fall back to /dev/urandom */
|
||||
int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC);
|
||||
if (fd < 0)
|
||||
return -1;
|
||||
|
||||
ssize_t total = 0;
|
||||
while ((size_t)total < buflen) {
|
||||
ssize_t n = read(fd, (char *)buf + total, buflen - (size_t)total);
|
||||
if (n <= 0) {
|
||||
if (n < 0 && errno == EINTR)
|
||||
continue;
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
total += n;
|
||||
}
|
||||
|
||||
close(fd);
|
||||
return total;
|
||||
}
|
||||
@@ -74,6 +74,33 @@ html {
|
||||
max-width: calc(90px * var(--grid-font-size-scale, 1)) !important;
|
||||
}
|
||||
|
||||
/* Scrollbar theming — WebKit only (Sencho-style). No global * selector and
|
||||
* no scrollbar-width override, so Firefox/native scrollbars render at OS
|
||||
* default width. Dark-mode thumb bumped to be visible on dark surfaces. */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
/* Light mode: medium gray that holds up against white. Pale border-color
|
||||
* at 50% was nearly invisible. */
|
||||
background: hsl(0 0% 60% / 0.6);
|
||||
border-radius: 4px;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(0 0% 40% / 0.8);
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: hsl(0 0% 50% / 0.5);
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(0 0% 65% / 0.7);
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
|
||||
@@ -1314,6 +1341,16 @@ html {
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
/* Icon animation toggle (#1169): when html.no-icon-animation is set, the
|
||||
common Tailwind animation utilities collapse to no-op. This keeps the
|
||||
layout (spinners still occupy space) but removes the motion. */
|
||||
html.no-icon-animation .animate-spin,
|
||||
html.no-icon-animation .animate-pulse,
|
||||
html.no-icon-animation .animate-bounce,
|
||||
html.no-icon-animation .animate-ping {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/* Icon glow utilities - standard size (4px blur, 0.6 opacity) */
|
||||
.glow-green { filter: drop-shadow(0 0 4px rgba(34, 197, 94, 0.6)); }
|
||||
.glow-green-sm { filter: drop-shadow(0 0 3px rgba(34, 197, 94, 0.5)); }
|
||||
@@ -1715,3 +1752,69 @@ html {
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ansi_up color classes (use_classes = true) — shared by all log viewers */
|
||||
.ansi-black-fg { color: #3f3f46; }
|
||||
.ansi-red-fg { color: #ef4444; }
|
||||
.ansi-green-fg { color: #22c55e; }
|
||||
.ansi-yellow-fg { color: #eab308; }
|
||||
.ansi-blue-fg { color: #3b82f6; }
|
||||
.ansi-magenta-fg { color: #d946ef; }
|
||||
.ansi-cyan-fg { color: #06b6d4; }
|
||||
.ansi-white-fg { color: #e4e4e7; }
|
||||
.ansi-bright-black-fg { color: #71717a; }
|
||||
.ansi-bright-red-fg { color: #f87171; }
|
||||
.ansi-bright-green-fg { color: #4ade80; }
|
||||
.ansi-bright-yellow-fg { color: #facc15; }
|
||||
.ansi-bright-blue-fg { color: #60a5fa; }
|
||||
.ansi-bright-magenta-fg { color: #e879f9; }
|
||||
.ansi-bright-cyan-fg { color: #22d3ee; }
|
||||
.ansi-bright-white-fg { color: #fafafa; }
|
||||
.ansi-black-bg { background-color: #18181b; }
|
||||
.ansi-red-bg { background-color: #dc2626; }
|
||||
.ansi-green-bg { background-color: #16a34a; }
|
||||
.ansi-yellow-bg { background-color: #ca8a04; }
|
||||
.ansi-blue-bg { background-color: #2563eb; }
|
||||
.ansi-magenta-bg { background-color: #c026d3; }
|
||||
.ansi-cyan-bg { background-color: #0891b2; }
|
||||
.ansi-white-bg { background-color: #d4d4d8; }
|
||||
.ansi-bright-black-bg { background-color: #52525b; }
|
||||
.ansi-bright-red-bg { background-color: #ef4444; }
|
||||
.ansi-bright-green-bg { background-color: #22c55e; }
|
||||
.ansi-bright-yellow-bg { background-color: #eab308; }
|
||||
.ansi-bright-blue-bg { background-color: #3b82f6; }
|
||||
.ansi-bright-magenta-bg { background-color: #d946ef; }
|
||||
.ansi-bright-cyan-bg { background-color: #06b6d4; }
|
||||
.ansi-bright-white-bg { background-color: #fafafa; }
|
||||
.ansi-bold { font-weight: bold; }
|
||||
.ansi-dim { opacity: 0.7; }
|
||||
.ansi-italic { font-style: italic; }
|
||||
.ansi-underline { text-decoration: underline; }
|
||||
|
||||
/* Log line numbers */
|
||||
.log-line {
|
||||
min-height: 1.2em;
|
||||
}
|
||||
pre.show-line-numbers {
|
||||
counter-reset: log-line;
|
||||
}
|
||||
pre.show-line-numbers .log-line {
|
||||
counter-increment: log-line;
|
||||
padding-left: 4.5em;
|
||||
position: relative;
|
||||
}
|
||||
pre.show-line-numbers .log-line::before {
|
||||
content: counter(log-line);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 3.5em;
|
||||
text-align: right;
|
||||
padding-right: 0.75em;
|
||||
user-select: none;
|
||||
color: rgb(113 113 122); /* zinc-500 */
|
||||
border-right: 1px solid rgb(63 63 70); /* zinc-700 */
|
||||
}
|
||||
:where(.light, .light *) pre.show-line-numbers .log-line::before {
|
||||
color: rgb(156 163 175); /* gray-400 */
|
||||
border-right-color: rgb(209 213 219); /* gray-300 */
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
|
||||
import type { AuthenticatedUser } from '$lib/server/auth';
|
||||
|
||||
// Build-time constants injected by Vite
|
||||
declare const __BUILD_DATE__: string | null;
|
||||
declare const __BUILD_COMMIT__: string | null;
|
||||
|
||||
declare global {
|
||||
// Build-time constants injected by Vite
|
||||
const __APP_VERSION__: string | null;
|
||||
const __BUILD_DATE__: string | null;
|
||||
const __BUILD_COMMIT__: string | null;
|
||||
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// v1.0.12
|
||||
import '$lib/server/dns-dispatcher.js';
|
||||
import { initDatabase, hasAdminUser } from '$lib/server/db';
|
||||
import { startSubprocesses, stopSubprocesses } from '$lib/server/subprocess-manager';
|
||||
import { startScheduler } from '$lib/server/scheduler';
|
||||
import { isAuthEnabled, validateSession } from '$lib/server/auth';
|
||||
import { validateApiToken } from '$lib/server/api-tokens';
|
||||
import { requestContext } from '$lib/server/request-context';
|
||||
import { setServerStartTime } from '$lib/server/uptime';
|
||||
import { checkLicenseExpiry, getHostname } from '$lib/server/license';
|
||||
import { initCryptoFallback } from '$lib/server/crypto-fallback';
|
||||
@@ -15,6 +18,11 @@ import { join } from 'path';
|
||||
import type { HandleServerError, Handle } from '@sveltejs/kit';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { startRssTracker, stopRssTracker, rssBeforeOp, rssAfterOp } from '$lib/server/rss-tracker';
|
||||
import { getClientIp } from '$lib/server/client-ip';
|
||||
// Side-effect import: installs globalThis.__authenticateWsUpgrade and
|
||||
// globalThis.__canAccessEnvForUser used by the raw WS upgrade handlers in
|
||||
// server.js / vite.config.ts to authenticate /api/containers/*/exec.
|
||||
import '$lib/server/ws-auth';
|
||||
|
||||
// Content types worth compressing
|
||||
const COMPRESSIBLE_TYPES = [
|
||||
@@ -197,6 +205,48 @@ if (!initialized) {
|
||||
}
|
||||
}
|
||||
|
||||
// Bearer token auth failure rate limiting (per IP, 5-minute cooldown after 10 failures)
|
||||
const bearerFailCounts = new Map<string, { count: number; firstFail: number }>();
|
||||
const BEARER_FAIL_WINDOW_MS = 60_000; // 1-minute sliding window
|
||||
const BEARER_FAIL_MAX = 15; // max failures per window
|
||||
const BEARER_COOLDOWN_MS = 5 * 60 * 1000; // 5-minute cooldown after exceeding limit
|
||||
const bearerCooldowns = new Map<string, number>(); // IP → cooldown-until timestamp
|
||||
|
||||
// Periodic cleanup
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, until] of bearerCooldowns) {
|
||||
if (now > until) bearerCooldowns.delete(ip);
|
||||
}
|
||||
for (const [ip, entry] of bearerFailCounts) {
|
||||
if (now - entry.firstFail > BEARER_FAIL_WINDOW_MS) bearerFailCounts.delete(ip);
|
||||
}
|
||||
}, BEARER_COOLDOWN_MS).unref?.();
|
||||
|
||||
function recordBearerFailure(ip: string): void {
|
||||
const now = Date.now();
|
||||
const entry = bearerFailCounts.get(ip);
|
||||
if (!entry || now - entry.firstFail > BEARER_FAIL_WINDOW_MS) {
|
||||
bearerFailCounts.set(ip, { count: 1, firstFail: now });
|
||||
return;
|
||||
}
|
||||
entry.count++;
|
||||
if (entry.count >= BEARER_FAIL_MAX) {
|
||||
bearerCooldowns.set(ip, now + BEARER_COOLDOWN_MS);
|
||||
bearerFailCounts.delete(ip);
|
||||
}
|
||||
}
|
||||
|
||||
function isBearerRateLimited(ip: string): boolean {
|
||||
const until = bearerCooldowns.get(ip);
|
||||
if (!until) return false;
|
||||
if (Date.now() > until) {
|
||||
bearerCooldowns.delete(ip);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Routes that don't require authentication
|
||||
const PUBLIC_PATHS = [
|
||||
'/login',
|
||||
@@ -246,21 +296,51 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Check if auth is enabled
|
||||
const authEnabled = await isAuthEnabled();
|
||||
|
||||
// If auth is disabled, allow everything (app works as before)
|
||||
// If auth is disabled, allow everything
|
||||
if (!authEnabled) {
|
||||
event.locals.user = null;
|
||||
event.locals.authEnabled = false;
|
||||
return compressResponse(event.request, await resolve(event));
|
||||
const ctx = { user: null, authEnabled: false, authMethod: 'none' as const };
|
||||
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
|
||||
}
|
||||
|
||||
// Auth is enabled - check session first
|
||||
let user = await validateSession(event.cookies);
|
||||
let authMethod: 'cookie' | 'bearer' | 'none' = user ? 'cookie' : 'none';
|
||||
|
||||
// If no session, try Bearer token on API routes
|
||||
if (!user && event.url.pathname.startsWith('/api/')) {
|
||||
const authHeader = event.request.headers.get('authorization');
|
||||
if (authHeader && authHeader.startsWith('Bearer dh_') && authHeader.length <= 207) {
|
||||
const clientIp = getClientIp(event);
|
||||
|
||||
// Rate limit failed Bearer attempts
|
||||
if (isBearerRateLimited(clientIp)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Too many failed authentication attempts' }),
|
||||
{ status: 429, headers: { 'Content-Type': 'application/json', 'Retry-After': '300' } }
|
||||
);
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7); // strip "Bearer "
|
||||
user = await validateApiToken(token);
|
||||
|
||||
if (user) {
|
||||
authMethod = 'bearer';
|
||||
} else {
|
||||
recordBearerFailure(clientIp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auth is enabled - check session
|
||||
const user = await validateSession(event.cookies);
|
||||
event.locals.user = user;
|
||||
event.locals.authEnabled = true;
|
||||
|
||||
const ctx = { user, authEnabled: true, authMethod };
|
||||
|
||||
// Public paths don't require authentication
|
||||
if (isPublicPath(event.url.pathname)) {
|
||||
return compressResponse(event.request, await resolve(event));
|
||||
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
|
||||
}
|
||||
|
||||
// If not authenticated
|
||||
@@ -269,7 +349,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
// This enables the first admin user to be created during initial setup
|
||||
const noAdminSetupMode = !(await hasAdminUser());
|
||||
if (noAdminSetupMode && event.url.pathname === '/api/users' && event.request.method === 'POST') {
|
||||
return compressResponse(event.request, await resolve(event));
|
||||
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
|
||||
}
|
||||
|
||||
// API routes return 401
|
||||
@@ -288,7 +368,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
redirect(307, `/login?redirect=${redirectUrl}`);
|
||||
}
|
||||
|
||||
return compressResponse(event.request, await resolve(event));
|
||||
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
|
||||
} finally {
|
||||
rssAfterOp('http', httpBefore);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import { themeStore } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
interface Props {
|
||||
userId?: number; // omit for global default (login page / auth-disabled)
|
||||
}
|
||||
|
||||
let { userId }: Props = $props();
|
||||
|
||||
// Same "skip applying" rule as ThemeSelector: don't toggle the live document
|
||||
// when the admin is editing the global default while logged in (their own
|
||||
// per-user preference still drives their session).
|
||||
const skipApply = $derived($authStore.loading ? true : ($authStore.authEnabled && !userId));
|
||||
|
||||
let checked = $state(true);
|
||||
$effect(() => {
|
||||
checked = $themeStore.animateIcons;
|
||||
});
|
||||
|
||||
function onToggle(value: boolean) {
|
||||
checked = value;
|
||||
themeStore.setPreference('animateIcons', value, userId, skipApply);
|
||||
toast.success(value ? 'Icon animation enabled' : 'Icon animation disabled');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<Label>Animate icons</Label>
|
||||
<TogglePill {checked} onchange={onToggle} />
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">Spinners during pulls, scans and updates.</p>
|
||||
</div>
|
||||
@@ -8,9 +8,26 @@
|
||||
imageUrl: string;
|
||||
onCancel: () => void;
|
||||
onSave: (dataUrl: string) => void;
|
||||
cropShape?: 'round' | 'rect';
|
||||
outputSize?: number;
|
||||
outputFormat?: 'image/jpeg' | 'image/webp';
|
||||
outputQuality?: number;
|
||||
title?: string;
|
||||
saveLabel?: string;
|
||||
}
|
||||
|
||||
let { show, imageUrl, onCancel, onSave }: Props = $props();
|
||||
let {
|
||||
show,
|
||||
imageUrl,
|
||||
onCancel,
|
||||
onSave,
|
||||
cropShape = 'round',
|
||||
outputSize = 256,
|
||||
outputFormat = 'image/jpeg',
|
||||
outputQuality = 0.9,
|
||||
title = 'Crop avatar',
|
||||
saveLabel = 'Save avatar'
|
||||
}: Props = $props();
|
||||
|
||||
// Cropper state
|
||||
let crop = $state({ x: 0, y: 0 });
|
||||
@@ -144,9 +161,9 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Set canvas size to output size (256x256 for avatar)
|
||||
canvas.width = 256;
|
||||
canvas.height = 256;
|
||||
// Set canvas size to output size
|
||||
canvas.width = outputSize;
|
||||
canvas.height = outputSize;
|
||||
|
||||
// Ensure we use a square crop area to avoid stretching
|
||||
// Center the square within the original crop area
|
||||
@@ -163,12 +180,12 @@
|
||||
size,
|
||||
0,
|
||||
0,
|
||||
256,
|
||||
256
|
||||
outputSize,
|
||||
outputSize
|
||||
);
|
||||
|
||||
// Convert to data URL
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
|
||||
const dataUrl = canvas.toDataURL(outputFormat, outputQuality);
|
||||
resolve(dataUrl);
|
||||
};
|
||||
|
||||
@@ -204,16 +221,18 @@
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if show && imageUrl}
|
||||
<div class="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4">
|
||||
<div class="fixed inset-0 bg-black/80 z-[200] flex items-center justify-center p-4">
|
||||
<div class="bg-background rounded-lg w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b">
|
||||
<h3 class="text-lg font-semibold">Crop avatar</h3>
|
||||
<h3 class="text-lg font-semibold">{title}</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Drag to reposition. Use the slider to zoom.
|
||||
</p>
|
||||
@@ -226,7 +245,8 @@
|
||||
bind:crop
|
||||
bind:zoom
|
||||
aspect={1}
|
||||
cropShape="round"
|
||||
minZoom={0.5}
|
||||
cropShape={cropShape}
|
||||
showGrid={false}
|
||||
on:cropcomplete={onCropComplete}
|
||||
on:mediaLoaded={onMediaLoaded}
|
||||
@@ -239,7 +259,7 @@
|
||||
<ZoomOut class="w-5 h-5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
min="0.5"
|
||||
max="3"
|
||||
step="0.1"
|
||||
bind:value={zoom}
|
||||
@@ -266,7 +286,7 @@
|
||||
disabled={saving || !imageLoaded}
|
||||
>
|
||||
<Check class="w-4 h-4" />
|
||||
{saving ? 'Uploading...' : !imageLoaded ? 'Loading...' : 'Save avatar'}
|
||||
{saving ? 'Uploading...' : !imageLoaded ? 'Loading...' : saveLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,7 @@
|
||||
import { Progress } from '$lib/components/ui/progress';
|
||||
import { Check, X, Loader2, Circle, Ban } from 'lucide-svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
|
||||
const progressText: Record<string, string> = {
|
||||
remove: 'removing',
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { GitPullRequestArrow } from 'lucide-svelte';
|
||||
import { parseChangelogTokens, tokenHref, type ChangelogToken } from '$lib/utils/changelog-tokens';
|
||||
|
||||
let { text }: { text: string } = $props();
|
||||
|
||||
type Group = { kind: 'text'; value: string } | { kind: 'refs'; refs: ChangelogToken[] };
|
||||
|
||||
const groups = $derived.by<Group[]>(() => {
|
||||
const tokens = parseChangelogTokens(text);
|
||||
const result: Group[] = [];
|
||||
let textBuf = '';
|
||||
let refBuf: ChangelogToken[] = [];
|
||||
|
||||
const flushText = () => {
|
||||
if (textBuf) {
|
||||
result.push({ kind: 'text', value: textBuf });
|
||||
textBuf = '';
|
||||
}
|
||||
};
|
||||
const flushRefs = () => {
|
||||
if (refBuf.length) {
|
||||
result.push({ kind: 'refs', refs: refBuf });
|
||||
refBuf = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const t of tokens) {
|
||||
if (t.kind === 'text') {
|
||||
// If the gap between consecutive ref groups is only "glue" (whitespace,
|
||||
// commas, parens), keep collecting into the same refs group. Otherwise
|
||||
// it ends the group.
|
||||
if (refBuf.length && /^[\s,()]*$/.test(t.value)) {
|
||||
continue;
|
||||
}
|
||||
if (refBuf.length) {
|
||||
flushRefs();
|
||||
}
|
||||
// Strip a trailing " (" left over before the upcoming refs group.
|
||||
textBuf += t.value;
|
||||
} else {
|
||||
// Trim trailing glue from textBuf so we don't render "foo (".
|
||||
if (refBuf.length === 0) {
|
||||
textBuf = textBuf.replace(/[\s(]+$/, '');
|
||||
}
|
||||
flushText();
|
||||
refBuf.push(t);
|
||||
}
|
||||
}
|
||||
flushRefs();
|
||||
// Trim trailing glue (e.g. ")") from leftover text.
|
||||
textBuf = textBuf.replace(/^[\s,)]+/, '');
|
||||
flushText();
|
||||
return result;
|
||||
});
|
||||
|
||||
function refLabel(token: ChangelogToken): string {
|
||||
if (token.kind === 'issue') return `#${token.num}`;
|
||||
if (token.kind === 'pr') return `#${token.num}`;
|
||||
if (token.kind === 'user') return `@${token.name}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
function refTitle(token: ChangelogToken): string {
|
||||
if (token.kind === 'issue') return `Issue #${token.num}`;
|
||||
if (token.kind === 'pr') return `Pull request #${token.num}`;
|
||||
if (token.kind === 'user') return `@${token.name} on GitHub`;
|
||||
return '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<span class="text-sm">
|
||||
{#each groups as group, i (i)}
|
||||
{#if group.kind === 'text'}
|
||||
{group.value}
|
||||
{:else}
|
||||
<span class="changelog-refs">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
{#each group.refs as ref, j (j)}
|
||||
{#if j > 0}<span class="changelog-refs-sep"> · </span>{/if}
|
||||
<a
|
||||
href={tokenHref(ref)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={refTitle(ref)}
|
||||
class="changelog-refs-link"
|
||||
>{#if ref.kind === 'pr'}<GitPullRequestArrow class="changelog-pr-icon" />{/if}{refLabel(ref)}</a>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.changelog-refs {
|
||||
display: inline;
|
||||
opacity: 0.55;
|
||||
margin-left: 4px;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
.changelog-refs svg {
|
||||
display: inline;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
vertical-align: -1px;
|
||||
margin-right: 3px;
|
||||
}
|
||||
.changelog-refs-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.changelog-refs-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.changelog-refs-sep {
|
||||
color: inherit;
|
||||
}
|
||||
.changelog-refs-link :global(.changelog-pr-icon) {
|
||||
display: inline;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
vertical-align: -1px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { EditorState, StateField, StateEffect, RangeSet } from '@codemirror/state';
|
||||
import { EditorState, StateField, StateEffect, RangeSet, Prec } from '@codemirror/state';
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, gutter, GutterMarker, Decoration, WidgetType, type DecorationSet } from '@codemirror/view';
|
||||
// Note: Secret masking was removed - secrets are now excluded from the raw editor entirely
|
||||
// and are only stored in the database (never written to .env file)
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, StreamLanguage, type StreamParser } from '@codemirror/language';
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab, insertNewlineAndIndent } from '@codemirror/commands';
|
||||
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, indentUnit, StreamLanguage, type StreamParser } from '@codemirror/language';
|
||||
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
||||
import { toml } from '@codemirror/legacy-modes/mode/toml';
|
||||
import { properties } from '@codemirror/legacy-modes/mode/properties';
|
||||
|
||||
// Simple dotenv/env file language parser
|
||||
const dotenvParser: StreamParser<{ inValue: boolean }> = {
|
||||
@@ -405,7 +409,7 @@
|
||||
new RegExp(`(?<!\\$)\\$\\{${marker.name}\\?`),
|
||||
new RegExp(`(?<!\\$)\\$\\{${marker.name}:\\+`),
|
||||
new RegExp(`(?<!\\$)\\$\\{${marker.name}\\+`),
|
||||
new RegExp(`(?<!\\$)\\$${marker.name}(?![a-zA-Z0-9_])`)
|
||||
new RegExp(`(?<![A-Za-z0-9\\$])\\$${marker.name}(?![a-zA-Z0-9_])`)
|
||||
];
|
||||
|
||||
const hasVariable = varPatterns.some(p => p.test(line));
|
||||
@@ -496,6 +500,21 @@
|
||||
initialSpacer: () => new VariableGutterMarker('required')
|
||||
});
|
||||
|
||||
// YAML Enter handler: after a key-only line ending with ":", indent one level
|
||||
// deeper than what the default indent service returns (it can't predict child
|
||||
// indent when no child content exists yet).
|
||||
function yamlNewlineAndIndent(view: EditorView): boolean {
|
||||
const { state } = view;
|
||||
const line = state.doc.lineAt(state.selection.main.head);
|
||||
const withoutComment = line.text.trimEnd().replace(/#.*$/, '').trimEnd();
|
||||
if (!withoutComment.endsWith(':')) return false;
|
||||
insertNewlineAndIndent(view);
|
||||
const unit = state.facet(indentUnit);
|
||||
const cursor = view.state.selection.main.head;
|
||||
view.dispatch({ changes: { from: cursor, insert: unit }, selection: { anchor: cursor + unit.length } });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get language extension based on language name
|
||||
function getLanguageExtension(lang: string) {
|
||||
switch (lang) {
|
||||
@@ -527,12 +546,18 @@
|
||||
return xml();
|
||||
case 'sql':
|
||||
return sql();
|
||||
case 'dockerfile':
|
||||
case 'shell':
|
||||
case 'bash':
|
||||
case 'sh':
|
||||
// No dedicated shell/dockerfile support, use basic highlighting
|
||||
return [];
|
||||
return StreamLanguage.define(shell);
|
||||
case 'dockerfile':
|
||||
return StreamLanguage.define(dockerFile);
|
||||
case 'toml':
|
||||
return StreamLanguage.define(toml);
|
||||
case 'ini':
|
||||
case 'conf':
|
||||
case 'properties':
|
||||
return StreamLanguage.define(properties);
|
||||
case 'dotenv':
|
||||
case 'env':
|
||||
return StreamLanguage.define(dotenvParser);
|
||||
@@ -671,7 +696,9 @@
|
||||
]),
|
||||
...themeExtensions,
|
||||
EditorView.lineWrapping,
|
||||
getLanguageExtension(language)
|
||||
EditorState.tabSize.of(2),
|
||||
getLanguageExtension(language),
|
||||
...(language === 'yaml' ? [Prec.high(keymap.of([{ key: 'Enter', run: yamlNewlineAndIndent }]))] : [])
|
||||
].flat();
|
||||
|
||||
if (readonly) {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
onConfirm: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: Snippet<[{ open: boolean }]>;
|
||||
extraContent?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -35,7 +36,8 @@
|
||||
disabled = false,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
children
|
||||
children,
|
||||
extraContent
|
||||
}: Props = $props();
|
||||
|
||||
const triggerClass = $derived(unstyled
|
||||
@@ -103,11 +105,16 @@
|
||||
align={position === 'left' ? 'start' : 'end'}
|
||||
sideOffset={8}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs whitespace-nowrap">{action} {itemType} {#if displayName}<strong>{displayName}</strong>{/if}?</span>
|
||||
<Button size="sm" {variant} class="h-6 px-2 text-xs" onclick={handleConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs whitespace-nowrap">{action} {itemType} {#if displayName}<strong>{displayName}</strong>{/if}?</span>
|
||||
<Button size="sm" {variant} class="h-6 px-2 text-xs" onclick={handleConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
{#if extraContent}
|
||||
{@render extraContent()}
|
||||
{/if}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { getIconComponent, isCustomIcon } from '$lib/utils/icons';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
icon: string;
|
||||
envId: number;
|
||||
class?: string;
|
||||
cacheBust?: string | number;
|
||||
}
|
||||
|
||||
let { icon, envId, class: className = 'w-4 h-4', cacheBust }: Props = $props();
|
||||
|
||||
const isCustom = $derived(isCustomIcon(icon));
|
||||
const LucideIcon = $derived(!isCustom ? getIconComponent(icon) : null) as Component | null;
|
||||
const imgSrc = $derived(isCustom ? `/api/environments/${envId}/icon${cacheBust ? `?v=${cacheBust}` : ''}` : '');
|
||||
</script>
|
||||
|
||||
{#if isCustom}
|
||||
<img src={imgSrc} alt="" class="{className} rounded-full object-cover" />
|
||||
{:else if LucideIcon}
|
||||
<LucideIcon class={className} />
|
||||
{/if}
|
||||
@@ -1,13 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { Sun, Moon } from 'lucide-svelte';
|
||||
import { getTimeFormat } from '$lib/stores/settings';
|
||||
|
||||
interface Props {
|
||||
logs: string | null;
|
||||
darkMode?: boolean;
|
||||
timezone?: string;
|
||||
onToggleTheme?: () => void;
|
||||
}
|
||||
|
||||
let { logs, darkMode = true, onToggleTheme }: Props = $props();
|
||||
let { logs, darkMode = true, timezone, onToggleTheme }: Props = $props();
|
||||
|
||||
// Parse log lines with timestamp and content
|
||||
function parseLogLine(line: string): { timestamp: string; content: string; type: 'trivy' | 'grype' | 'error' | 'default' } {
|
||||
@@ -44,7 +46,15 @@
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: string): string {
|
||||
return timestamp.split('T')[1]?.replace('Z', '') || timestamp;
|
||||
const d = new Date(timestamp);
|
||||
if (isNaN(d.getTime())) return timestamp;
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: timezone || undefined,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: getTimeFormat() === '12h'
|
||||
}).format(d);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { appendEnvParam } from '$lib/stores/environment';
|
||||
import { watchJob } from '$lib/utils/sse-fetch';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
|
||||
interface LayerProgress {
|
||||
id: string;
|
||||
@@ -98,12 +99,6 @@
|
||||
localStorage.setItem('logTheme', logDarkMode ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
@@ -314,7 +309,7 @@
|
||||
class="h-10"
|
||||
>
|
||||
{#if isPulling}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
<Download class="w-4 h-4 mr-2 animate-spin" />
|
||||
Pulling...
|
||||
{:else}
|
||||
<Download class="w-4 h-4" />
|
||||
@@ -332,7 +327,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if status === 'pulling'}
|
||||
<Loader2 class="w-4 h-4 animate-spin text-blue-600" />
|
||||
<Download class="w-4 h-4 animate-spin text-blue-600" />
|
||||
<span class="text-sm">Pulling layers...</span>
|
||||
{:else if status === 'complete'}
|
||||
<CheckCircle2 class="w-4 h-4 text-green-600" />
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
imageName: string;
|
||||
envId?: number | null;
|
||||
autoStart?: boolean;
|
||||
activeScanner?: 'grype' | 'trivy';
|
||||
onComplete?: (results: ScanResult[]) => void;
|
||||
onError?: (error: string) => void;
|
||||
onStatusChange?: (status: ScanStatus) => void;
|
||||
@@ -47,6 +48,7 @@
|
||||
imageName,
|
||||
envId = null,
|
||||
autoStart = false,
|
||||
activeScanner = $bindable<'grype' | 'trivy'>('grype'),
|
||||
onComplete,
|
||||
onError,
|
||||
onStatusChange
|
||||
@@ -226,7 +228,7 @@
|
||||
<Shield class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="text-sm text-muted-foreground">Ready to scan</span>
|
||||
{:else if status === 'scanning'}
|
||||
<Loader2 class="w-4 h-4 animate-spin text-blue-600" />
|
||||
<Shield class="w-4 h-4 animate-spin text-blue-600" />
|
||||
<span class="text-sm">Scanning for vulnerabilities...</span>
|
||||
{:else if status === 'complete'}
|
||||
{#if hasCriticalOrHigh}
|
||||
@@ -362,7 +364,7 @@
|
||||
{:else}
|
||||
<!-- Scan Results -->
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<ScanResultsView {results} />
|
||||
<ScanResultsView {results} bind:activeScanner />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -114,12 +114,7 @@
|
||||
}
|
||||
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
let value = trimmed.slice(eqIndex + 1);
|
||||
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
const value = trimmed.slice(eqIndex + 1);
|
||||
|
||||
if (key) {
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||
@@ -200,8 +195,8 @@
|
||||
* Sync rawContent TO variables.
|
||||
* Parses raw content for non-secrets, preserves existing secrets.
|
||||
*/
|
||||
function syncRawToVariables() {
|
||||
const { vars, warnings } = parseRawContent(rawContent);
|
||||
function syncRawToVariables(content?: string) {
|
||||
const { vars, warnings } = parseRawContent(content ?? rawContent);
|
||||
parseWarnings = warnings;
|
||||
|
||||
// Preserve existing secrets (they're not in rawContent)
|
||||
@@ -240,8 +235,9 @@
|
||||
// Form → Text: sync variables to raw (preserves comments)
|
||||
syncVariablesToRaw();
|
||||
} else if (newMode === 'form' && viewMode === 'text') {
|
||||
// Text → Form: sync raw to variables (preserves secrets)
|
||||
syncRawToVariables();
|
||||
// Text → Form: use textEditorContent which falls back to generatedRawContent
|
||||
// when rawContent is empty (fixes vars lost on view switch for git stacks)
|
||||
syncRawToVariables(textEditorContent);
|
||||
}
|
||||
|
||||
viewMode = newMode;
|
||||
|
||||
@@ -43,15 +43,17 @@
|
||||
let selectedEditorFont = $state('system-mono');
|
||||
|
||||
onMount(async () => {
|
||||
// Load monospace fonts for dropdown previews
|
||||
// Load bundled monospace fonts for dropdown previews
|
||||
const fontsToLoad = monospaceFonts.filter(f => f.googleFont);
|
||||
if (fontsToLoad.length > 0) {
|
||||
const families = fontsToLoad.map(f => `family=${f.googleFont}`).join('&');
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = `https://fonts.googleapis.com/css2?${families}&display=swap`;
|
||||
link.onload = () => { monoFontsLoaded = true; };
|
||||
document.head.appendChild(link);
|
||||
let loaded = 0;
|
||||
for (const font of fontsToLoad) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = `/fonts/${font.id}/font.css`;
|
||||
link.onload = () => { if (++loaded >= fontsToLoad.length) monoFontsLoaded = true; };
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
} else {
|
||||
monoFontsLoaded = true;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,22 @@
|
||||
'Europe/Kyiv': 'Europe/Kiev',
|
||||
'Asia/Ho_Chi_Minh': 'Asia/Saigon',
|
||||
'America/Nuuk': 'America/Godthab',
|
||||
'Pacific/Kanton': 'Pacific/Enderbury'
|
||||
'Pacific/Kanton': 'Pacific/Enderbury',
|
||||
'Asia/Kolkata': 'Asia/Calcutta',
|
||||
'Asia/Kathmandu': 'Asia/Katmandu',
|
||||
'Asia/Yangon': 'Asia/Rangoon',
|
||||
'Asia/Kashgar': 'Asia/Urumqi',
|
||||
'Atlantic/Faroe': 'Atlantic/Faeroe',
|
||||
'Europe/Uzhgorod': 'Europe/Kiev',
|
||||
'Europe/Zaporozhye': 'Europe/Kiev',
|
||||
'America/Atikokan': 'America/Coral_Harbour',
|
||||
'America/Argentina/Buenos_Aires': 'America/Buenos_Aires',
|
||||
'America/Argentina/Catamarca': 'America/Catamarca',
|
||||
'America/Argentina/Cordoba': 'America/Cordoba',
|
||||
'America/Argentina/Jujuy': 'America/Jujuy',
|
||||
'America/Argentina/Mendoza': 'America/Mendoza',
|
||||
'Pacific/Pohnpei': 'Pacific/Ponape',
|
||||
'Pacific/Chuuk': 'Pacific/Truk'
|
||||
};
|
||||
|
||||
// Reverse map: canonical → modern alias names (for display hints)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Sparkles, Bug, Zap, CheckCircle, ScrollText } from 'lucide-svelte';
|
||||
import { compareVersions } from '$lib/utils/version';
|
||||
import ChangelogText from '$lib/components/ChangelogText.svelte';
|
||||
|
||||
interface ChangelogEntry {
|
||||
version: string;
|
||||
@@ -62,11 +63,11 @@
|
||||
<span class="text-muted-foreground font-normal">({release.date})</span>
|
||||
</h3>
|
||||
<div class="space-y-1.5 ml-1">
|
||||
{#each release.changes as change}
|
||||
{#each [...release.changes].sort((a, b) => a.type === b.type ? 0 : a.type === 'feature' ? -1 : 1) as change}
|
||||
{@const { icon: Icon, class: iconClass } = getChangeIcon(change.type)}
|
||||
<div class="flex items-start gap-2">
|
||||
<Icon class="w-4 h-4 mt-0.5 shrink-0 {iconClass}" />
|
||||
<span class="text-sm">{change.text}</span>
|
||||
<ChangelogText text={change.text} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -22,11 +22,16 @@
|
||||
User,
|
||||
ClipboardList,
|
||||
Activity,
|
||||
Timer
|
||||
Timer,
|
||||
LibraryBig
|
||||
} from 'lucide-svelte';
|
||||
import { licenseStore } from '$lib/stores/license';
|
||||
import { authStore, hasAnyAccess } from '$lib/stores/auth';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
|
||||
const appVersion = __APP_VERSION__ || 'unknown';
|
||||
const buildCommit = __BUILD_COMMIT__ ?? null;
|
||||
|
||||
import type { Permissions } from '$lib/stores/auth';
|
||||
|
||||
@@ -97,6 +102,7 @@
|
||||
{ href: '/images', Icon: Images, label: 'Images', permission: 'images' },
|
||||
{ href: '/volumes', Icon: HardDrive, label: 'Volumes', permission: 'volumes' },
|
||||
{ href: '/networks', Icon: Network, label: 'Networks', permission: 'networks' },
|
||||
{ href: '/templates', Icon: LibraryBig, label: 'Templates', permission: 'templates' },
|
||||
{ href: '/registry', Icon: Download, label: 'Registry', permission: 'registries' },
|
||||
{ href: '/activity', Icon: Activity, label: 'Activity', permission: 'activity' },
|
||||
{ href: '/schedules', Icon: Timer, label: 'Schedules', permission: 'schedules' },
|
||||
@@ -155,6 +161,25 @@
|
||||
</Sidebar.Group>
|
||||
</Sidebar.Content>
|
||||
|
||||
<!-- Version (expanded sidebar only) -->
|
||||
<div class="group-data-[state=collapsed]:hidden px-3 py-2 mt-auto text-center">
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<span class="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-default">
|
||||
{appVersion}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="top" align="start" sideOffset={8} class="text-xs">
|
||||
<div class="space-y-0.5">
|
||||
<div class="flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0" viewBox="0 0 24 18" fill="currentColor"><path d="M23.76 8.68c-.26-.18-.86-.58-1.53-.58-.24 0-.48.04-.72.12-.12-.84-.68-1.56-1.34-2.14l-.28-.22-.24.26c-.28.34-.48.72-.56 1.14-.1.42-.06.82.1 1.2-.42.22-.88.36-1.32.42-.24.04-.48.06-.72.06H.78a.77.77 0 0 0-.78.78c-.02 1.46.22 2.9.72 4.24.56 1.44 1.4 2.5 2.5 3.16 1.26.74 3.32 1.16 5.64 1.16.98 0 2-.1 2.98-.3a11.5 11.5 0 0 0 3.3-1.3 9.67 9.67 0 0 0 2.54-2.34c1.16-1.42 1.86-3.02 2.34-4.38h.2c1.22 0 1.98-.48 2.4-.9.28-.26.5-.58.64-.94l.08-.24-.28-.2zM2.74 8.84H4.7c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H2.74c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.72 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zM5.46 6.2h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm0-2.64h1.96c.1 0 .18-.08.18-.18V1.74c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 5.28h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18z"/></svg><span class="font-mono">fnsys/dockhand:{appVersion}</span></div>
|
||||
{#if buildCommit}
|
||||
<div>Commit: <span class="font-mono">{buildCommit.slice(0, 7)}</span></div>
|
||||
{/if}
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
|
||||
<!-- User info footer (only when auth is enabled) -->
|
||||
{#if $authStore.authEnabled && $authStore.authenticated && $authStore.user}
|
||||
<Sidebar.Footer class="border-t">
|
||||
|
||||
@@ -19,17 +19,20 @@
|
||||
// Detect schedule type from cron expression
|
||||
function detectScheduleType(cron: string): 'daily' | 'weekly' | 'custom' {
|
||||
const parts = cron.split(' ');
|
||||
if (parts.length < 5) return 'custom';
|
||||
if (parts.length !== 5) return 'custom';
|
||||
|
||||
const [, , day, month, dow] = parts;
|
||||
const [min, hr, day, month, dow] = parts;
|
||||
|
||||
// Weekly: specific day of week (0-6), day and month are wildcards
|
||||
if (dow !== '*' && day === '*' && month === '*') {
|
||||
// Simple minute and hour: plain numbers only (not */n, ranges, or lists)
|
||||
const isSimpleNumber = (s: string) => /^\d+$/.test(s);
|
||||
|
||||
// Weekly: specific single day of week (0-6), day and month are wildcards, simple min/hour
|
||||
if (dow !== '*' && /^\d$/.test(dow) && day === '*' && month === '*' && isSimpleNumber(min) && isSimpleNumber(hr)) {
|
||||
return 'weekly';
|
||||
}
|
||||
|
||||
// Daily: all wildcards except minute and hour
|
||||
if (day === '*' && month === '*' && dow === '*') {
|
||||
// Daily: all wildcards except simple minute and hour
|
||||
if (day === '*' && month === '*' && dow === '*' && isSimpleNumber(min) && isSimpleNumber(hr)) {
|
||||
return 'daily';
|
||||
}
|
||||
|
||||
@@ -134,23 +137,15 @@
|
||||
onchange(newValue);
|
||||
}
|
||||
|
||||
// Validate cron expression
|
||||
// Validate cron expression (supports 5-field and 6-field with seconds)
|
||||
function isValidCron(cron: string): boolean {
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return false;
|
||||
|
||||
const [min, hr, day, month, dow] = parts;
|
||||
if (parts.length !== 5 && parts.length !== 6) return false;
|
||||
|
||||
// Basic pattern validation (number, *, */n, range, list)
|
||||
const cronFieldPattern = /^(\*|(\*\/\d+)|\d+(-\d+)?(,\d+(-\d+)?)*)$/;
|
||||
|
||||
return (
|
||||
cronFieldPattern.test(min) &&
|
||||
cronFieldPattern.test(hr) &&
|
||||
cronFieldPattern.test(day) &&
|
||||
cronFieldPattern.test(month) &&
|
||||
cronFieldPattern.test(dow)
|
||||
);
|
||||
return parts.every((part) => cronFieldPattern.test(part));
|
||||
}
|
||||
|
||||
// Human-readable description using cronstrue
|
||||
|
||||
@@ -329,18 +329,40 @@
|
||||
onExpandChange?.(key, nowExpanded);
|
||||
}
|
||||
|
||||
// Sort persistence
|
||||
const SORT_STORAGE_KEY = `dockhand-${gridId}-sort`;
|
||||
let sortInitialized = false;
|
||||
|
||||
// Restore saved sort on mount
|
||||
onMount(() => {
|
||||
if (!onSortChange) return;
|
||||
try {
|
||||
const saved = localStorage.getItem(SORT_STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as DataGridSortState;
|
||||
if (parsed.field && parsed.direction) {
|
||||
onSortChange(parsed);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
sortInitialized = true;
|
||||
});
|
||||
|
||||
// Persist sort state whenever it changes (after init)
|
||||
$effect(() => {
|
||||
if (!sortInitialized || !sortState) return;
|
||||
try { localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(sortState)); } catch {}
|
||||
});
|
||||
|
||||
// Sort helpers
|
||||
function toggleSort(field: string) {
|
||||
if (!onSortChange) return;
|
||||
|
||||
if (sortState?.field === field) {
|
||||
onSortChange({
|
||||
field,
|
||||
direction: sortState.direction === 'asc' ? 'desc' : 'asc'
|
||||
});
|
||||
} else {
|
||||
onSortChange({ field, direction: 'asc' });
|
||||
}
|
||||
const newState: DataGridSortState = sortState?.field === field
|
||||
? { field, direction: sortState.direction === 'asc' ? 'desc' : 'asc' }
|
||||
: { field, direction: 'asc' };
|
||||
|
||||
onSortChange(newState);
|
||||
}
|
||||
|
||||
// Virtual scroll state
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2 } from 'lucide-svelte';
|
||||
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2, Search, Server, X } from 'lucide-svelte';
|
||||
import { whale } from '@lucide/lab';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { currentEnvironment, environments, type Environment } from '$lib/stores/environment';
|
||||
import { sseConnected } from '$lib/stores/events';
|
||||
import { getIconComponent } from '$lib/utils/icons';
|
||||
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { themeStore, type FontSize } from '$lib/stores/theme';
|
||||
import { formatTime } from '$lib/stores/settings';
|
||||
import { getTimeFormat } from '$lib/stores/settings';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
|
||||
// Font size scaling for header
|
||||
let fontSize = $state<FontSize>('normal');
|
||||
@@ -77,6 +78,8 @@
|
||||
let diskUsageLoading = $state(false);
|
||||
let envAbortController: AbortController | null = null; // Aborts ALL requests when switching envs
|
||||
let showDropdown = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let searchInputRef = $state<HTMLInputElement | null>(null);
|
||||
let currentEnvId = $state<number | null>(null);
|
||||
let lastUpdated = $state<Date>(new Date());
|
||||
let isConnected = $state(false);
|
||||
@@ -92,8 +95,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Display string for the env hostname / IP in the header (#962).
|
||||
// Show both when available; drop only the field that is unknown/empty.
|
||||
// Hide the whole block when neither is meaningful (e.g. hawser-edge
|
||||
// reports 'unknown' for both).
|
||||
const hostLabel = $derived.by(() => {
|
||||
if (!hostInfo) return '';
|
||||
const isMeaningful = (v: string | undefined) => {
|
||||
const t = (v || '').trim();
|
||||
return t && t.toLowerCase() !== 'unknown';
|
||||
};
|
||||
const h = isMeaningful(hostInfo.hostname) ? hostInfo.hostname.trim() : '';
|
||||
const ip = isMeaningful(hostInfo.ipAddress) ? hostInfo.ipAddress.trim() : '';
|
||||
if (h && ip && h !== ip) return `${h} (${ip})`;
|
||||
return h || ip;
|
||||
});
|
||||
|
||||
// Reactive environment list from store
|
||||
let envList = $derived($environments);
|
||||
const showSearch = $derived(envList.length > 8);
|
||||
const filteredEnvList = $derived(
|
||||
searchTerm.trim()
|
||||
? envList.filter((e: Environment) => e.name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
: envList
|
||||
);
|
||||
|
||||
// Clear search and focus when dropdown opens/closes
|
||||
$effect(() => {
|
||||
if (showDropdown && showSearch) {
|
||||
// Use tick to wait for DOM render
|
||||
setTimeout(() => searchInputRef?.focus(), 0);
|
||||
} else {
|
||||
searchTerm = '';
|
||||
}
|
||||
});
|
||||
|
||||
sseConnected.subscribe(v => isConnected = v);
|
||||
|
||||
@@ -200,14 +235,6 @@
|
||||
(diskUsage.Volumes?.reduce((sum: number, v: any) => sum + (v.UsageData?.Size || 0), 0) || 0);
|
||||
});
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
async function switchEnvironment(envId: number) {
|
||||
// Don't switch if already on this environment
|
||||
if (Number(envId) === Number(currentEnvId)) {
|
||||
@@ -305,6 +332,20 @@
|
||||
hostInfo ? ((hostInfo.totalMemory - hostInfo.freeMemory) / hostInfo.totalMemory) * 100 : 0
|
||||
);
|
||||
|
||||
let currentTimezone = $derived(
|
||||
$environments.find((e: Environment) => Number(e.id) === Number(currentEnvId))?.timezone ?? 'UTC'
|
||||
);
|
||||
|
||||
function formatLastUpdated(date: Date, timezone: string): string {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: timezone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: getTimeFormat() === '12h'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.env-dropdown')) {
|
||||
@@ -335,14 +376,12 @@
|
||||
class="flex items-center gap-1.5 -ml-1 px-1 py-1 rounded-md hover:bg-muted transition-colors cursor-pointer"
|
||||
>
|
||||
{#if hostInfo?.environment && Number(hostInfo.environment.id) === Number(currentEnvId)}
|
||||
{@const EnvIcon = getIconComponent(hostInfo.environment.icon || 'globe')}
|
||||
<EnvIcon class="{iconSizeLargeClass()} text-primary" />
|
||||
<EnvironmentIcon icon={hostInfo.environment.icon || 'globe'} envId={hostInfo.environment.id} class="{iconSizeLargeClass()} text-primary" />
|
||||
<span class="font-medium text-foreground">{hostInfo.environment.name}</span>
|
||||
{:else if currentEnvId && envList.length > 0}
|
||||
{@const currentEnv = envList.find(e => Number(e.id) === Number(currentEnvId))}
|
||||
{#if currentEnv}
|
||||
{@const EnvIcon = getIconComponent(currentEnv.icon || 'globe')}
|
||||
<EnvIcon class="{iconSizeLargeClass()} text-primary" />
|
||||
<EnvironmentIcon icon={currentEnv.icon || 'globe'} envId={currentEnv.id} class="{iconSizeLargeClass()} text-primary" />
|
||||
<span class="font-medium text-foreground">{currentEnv.name}</span>
|
||||
{:else}
|
||||
<Globe class="{iconSizeLargeClass()} text-muted-foreground" />
|
||||
@@ -357,9 +396,40 @@
|
||||
|
||||
{#if showDropdown && envList.length > 0}
|
||||
<div class="absolute top-full left-0 mt-1 min-w-56 w-max max-w-80 bg-popover border rounded-md shadow-lg z-50">
|
||||
<div class="py-1">
|
||||
{#each envList as env (env.id)}
|
||||
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
|
||||
{#if showSearch}
|
||||
<div class="sticky top-0 bg-popover border-b px-2 py-1.5">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<input
|
||||
bind:this={searchInputRef}
|
||||
bind:value={searchTerm}
|
||||
type="text"
|
||||
placeholder="Search environments..."
|
||||
class="w-full pl-7 pr-7 py-1 text-sm bg-transparent border rounded focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (searchTerm) {
|
||||
searchTerm = '';
|
||||
} else {
|
||||
showDropdown = false;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if searchTerm}
|
||||
<button
|
||||
class="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-muted"
|
||||
onclick={(e) => { e.stopPropagation(); searchTerm = ''; searchInputRef?.focus(); }}
|
||||
>
|
||||
<X class="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="py-1 max-h-[calc(100vh-8rem)] overflow-y-auto">
|
||||
{#each filteredEnvList as env (env.id)}
|
||||
{@const isOffline = offlineEnvIds.has(env.id)}
|
||||
{@const isSwitching = switchingEnvId === env.id}
|
||||
<button
|
||||
@@ -373,7 +443,7 @@
|
||||
{:else if isOffline}
|
||||
<WifiOff class="{iconSizeLargeClass()} text-destructive shrink-0" />
|
||||
{:else}
|
||||
<EnvIcon class="{iconSizeLargeClass()} text-muted-foreground shrink-0" />
|
||||
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="{iconSizeLargeClass()} text-muted-foreground shrink-0" />
|
||||
{/if}
|
||||
<span class="flex-1 whitespace-nowrap" class:text-muted-foreground={isOffline}>{env.name}</span>
|
||||
{#if isOffline && !isSwitching}
|
||||
@@ -382,6 +452,10 @@
|
||||
<Check class="{iconSizeLargeClass()} text-primary shrink-0" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="px-3 py-2 text-sm text-muted-foreground">
|
||||
No matching environments
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -391,6 +465,16 @@
|
||||
{#if hostInfo}
|
||||
<span class="text-border">|</span>
|
||||
|
||||
<!-- Hostname / IP (#962) — first info segment after the env dropdown.
|
||||
Hidden on narrow viewports to keep the strip readable. -->
|
||||
{#if hostLabel}
|
||||
<div class="hidden xl:flex items-center gap-1" title="Daemon hostname / IP">
|
||||
<Server class="{iconSizeClass()}" />
|
||||
<span>{hostLabel}</span>
|
||||
</div>
|
||||
<span class="hidden xl:inline text-border">|</span>
|
||||
{/if}
|
||||
|
||||
<!-- Platform/OS -->
|
||||
<span class="hidden md:inline">{hostInfo.platform} {hostInfo.arch}</span>
|
||||
|
||||
@@ -452,7 +536,7 @@
|
||||
class="flex items-center gap-2 {isConnected ? 'text-emerald-500' : 'text-muted-foreground'}"
|
||||
title={isConnected ? 'Live updates connected' : 'Live updates disconnected'}
|
||||
>
|
||||
<span class="text-muted-foreground">{formatTime(lastUpdated, { includeSeconds: true })}</span>
|
||||
<span class="text-muted-foreground" title={currentTimezone}>{formatLastUpdated(lastUpdated, currentTimezone)}</span>
|
||||
{#if isConnected}
|
||||
<Wifi class="{iconSizeLargeClass()}" />
|
||||
<span class="font-medium">Live</span>
|
||||
|
||||
@@ -1,44 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Sun, Moon } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { Sun, Moon, Monitor } from 'lucide-svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onDarkModeChange } from '$lib/stores/theme';
|
||||
|
||||
let isDark = $state(false);
|
||||
type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
let mode = $state<ThemeMode>('system');
|
||||
let mediaQuery: MediaQueryList | null = null;
|
||||
|
||||
onMount(() => {
|
||||
// Check for saved preference or system preference
|
||||
const saved = localStorage.getItem('theme');
|
||||
if (saved) {
|
||||
isDark = saved === 'dark';
|
||||
} else {
|
||||
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
updateTheme();
|
||||
const saved = localStorage.getItem('theme') as ThemeMode | null;
|
||||
mode = saved === 'light' || saved === 'dark' || saved === 'system' ? saved : 'system';
|
||||
|
||||
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQuery.addEventListener('change', onSystemChange);
|
||||
|
||||
applyMode();
|
||||
});
|
||||
|
||||
function updateTheme() {
|
||||
onDestroy(() => {
|
||||
mediaQuery?.removeEventListener('change', onSystemChange);
|
||||
});
|
||||
|
||||
function onSystemChange() {
|
||||
if (mode === 'system') {
|
||||
applyMode();
|
||||
}
|
||||
}
|
||||
|
||||
function applyMode() {
|
||||
const isDark = mode === 'dark' || (mode === 'system' && !!mediaQuery?.matches);
|
||||
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
// Apply the correct theme colors for the new mode
|
||||
onDarkModeChange();
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
isDark = !isDark;
|
||||
updateTheme();
|
||||
function cycleTheme() {
|
||||
const order: ThemeMode[] = ['light', 'dark', 'system'];
|
||||
mode = order[(order.indexOf(mode) + 1) % order.length];
|
||||
localStorage.setItem('theme', mode);
|
||||
applyMode();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button variant="ghost" size="icon" onclick={toggleTheme} class="h-9 w-9">
|
||||
{#if isDark}
|
||||
<Button variant="ghost" size="icon" onclick={cycleTheme} class="h-9 w-9" title={mode === 'system' ? 'Theme: system' : mode === 'dark' ? 'Theme: dark' : 'Theme: light'}>
|
||||
{#if mode === 'dark'}
|
||||
<Moon class="h-4 w-4" />
|
||||
{:else if mode === 'light'}
|
||||
<Sun class="h-4 w-4" />
|
||||
{:else}
|
||||
<Moon class="h-4 w-4" />
|
||||
<Monitor class="h-4 w-4" />
|
||||
{/if}
|
||||
<span class="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
|
||||
@@ -10,7 +10,7 @@ export const containerColumns: ColumnConfig[] = [
|
||||
{ id: 'uptime', label: 'Uptime', sortable: true, sortField: 'uptime', width: 80, minWidth: 60 },
|
||||
{ id: 'restartCount', label: 'Restarts', width: 70, minWidth: 50 },
|
||||
{ id: 'cpu', label: 'CPU', sortable: true, sortField: 'cpu', width: 50, minWidth: 40, align: 'right' },
|
||||
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 60, minWidth: 50, align: 'right' },
|
||||
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 95, minWidth: 70, align: 'right' },
|
||||
{ id: 'networkIO', label: 'Net I/O', width: 85, minWidth: 70, align: 'right' },
|
||||
{ id: 'diskIO', label: 'Disk I/O', width: 85, minWidth: 70, align: 'right' },
|
||||
{ id: 'ip', label: 'IP', sortable: true, sortField: 'ip', width: 100, minWidth: 80 },
|
||||
@@ -76,6 +76,7 @@ export const volumeColumns: ColumnConfig[] = [
|
||||
{ id: 'select', label: '', fixed: 'start', width: 32, resizable: false },
|
||||
{ id: 'name', label: 'Name', sortable: true, sortField: 'name', width: 400, minWidth: 150, grow: true },
|
||||
{ id: 'driver', label: 'Driver', sortable: true, sortField: 'driver', width: 80, minWidth: 60 },
|
||||
{ id: 'type', label: 'Type', sortable: true, sortField: 'type', width: 80, minWidth: 60 },
|
||||
{ id: 'scope', label: 'Scope', width: 70, minWidth: 50 },
|
||||
{ id: 'stack', label: 'Stack', sortable: true, sortField: 'stack', width: 120, minWidth: 80 },
|
||||
{ id: 'usedBy', label: 'Used by', width: 150, minWidth: 80 },
|
||||
@@ -118,6 +119,23 @@ export const scheduleColumns: ColumnConfig[] = [
|
||||
{ id: 'actions', label: '', fixed: 'end', width: 100, resizable: false }
|
||||
];
|
||||
|
||||
// Environment grid columns (dashboard list view)
|
||||
export const environmentColumns: ColumnConfig[] = [
|
||||
{ id: 'status', label: '', width: 36, resizable: false },
|
||||
{ id: 'name', label: 'Environment', sortable: true, sortField: 'name', width: 180, minWidth: 100, grow: true },
|
||||
{ id: 'connection', label: 'Connection', sortable: true, sortField: 'connection', width: 110, minWidth: 80 },
|
||||
{ id: 'host', label: 'Host', sortable: true, sortField: 'host', width: 150, minWidth: 80 },
|
||||
{ id: 'containers', label: 'Containers', sortable: true, sortField: 'containers', width: 100, minWidth: 70 },
|
||||
{ id: 'updates', label: 'Updates', sortable: true, sortField: 'updates', width: 75, minWidth: 55 },
|
||||
{ id: 'cpu', label: 'CPU', sortable: true, sortField: 'cpu', width: 110, minWidth: 80 },
|
||||
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 110, minWidth: 80 },
|
||||
{ id: 'images', label: 'Images', sortable: true, sortField: 'images', width: 65, minWidth: 50 },
|
||||
{ id: 'volumes', label: 'Volumes', sortable: true, sortField: 'volumes', width: 70, minWidth: 50 },
|
||||
{ id: 'stacks', label: 'Stacks', sortable: true, sortField: 'stacks', width: 85, minWidth: 65 },
|
||||
{ id: 'events', label: 'Events', sortable: true, sortField: 'events', width: 65, minWidth: 50 },
|
||||
{ id: 'labels', label: 'Labels', width: 150, minWidth: 80 }
|
||||
];
|
||||
|
||||
// Map of grid ID to column definitions
|
||||
export const gridColumnConfigs: Record<GridId, ColumnConfig[]> = {
|
||||
containers: containerColumns,
|
||||
@@ -128,7 +146,8 @@ export const gridColumnConfigs: Record<GridId, ColumnConfig[]> = {
|
||||
volumes: volumeColumns,
|
||||
activity: activityColumns,
|
||||
schedules: scheduleColumns,
|
||||
audit: auditColumns
|
||||
audit: auditColumns,
|
||||
environments: environmentColumns
|
||||
};
|
||||
|
||||
// Get configurable columns (not fixed)
|
||||
|
||||
@@ -1,7 +1,294 @@
|
||||
[
|
||||
{
|
||||
"version": "1.0.34",
|
||||
"date": "2026-06-17",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "raw file download — no tar wrapping (#1180)" },
|
||||
{ "type": "fix", "text": "update modal stuck after closing mid-pull (#1094)" },
|
||||
{ "type": "fix", "text": "vulnerability scans on Podman hosts (direct TCP and Hawser) (#1076)" },
|
||||
{ "type": "fix", "text": "crash-looping containers now appear in the logs page list (#227)" },
|
||||
{ "type": "feature", "text": "filter containers by \"Update available\" (#1063)" },
|
||||
{ "type": "feature", "text": "show hostname / IP of the selected environment in the top header (#962)" },
|
||||
{ "type": "feature", "text": "internal auth and validation hardening and dependency bumps" },
|
||||
{ "type": "feature", "text": "Traefik and Pangolin integration — surface proxy URLs on container and stack panels (#2)" },
|
||||
{ "type": "feature", "text": "release-notes link next to images with updates available (#538)" },
|
||||
{ "type": "feature", "text": "lifecycle action buttons in the container details modal (#461)" },
|
||||
{ "type": "feature", "text": "template library — browse and deploy compose templates from configurable sources (#48)" },
|
||||
{ "type": "fix", "text": "file browser fails on containers with ls in /usr/sbin (#1185)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.34"
|
||||
},
|
||||
{
|
||||
"version": "1.0.33",
|
||||
"date": "2026-06-15",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "in-place container property updates without restart — restart policy, CPU/memory limits (#1153)" },
|
||||
{ "type": "feature", "text": "clickable stack badge in container and volume inspect modals (#1121)" },
|
||||
{ "type": "feature", "text": "clickable stack badge in volumes list row (#1122)" },
|
||||
{ "type": "feature", "text": "volumes list shows driver_opts type (NFS, CIFS, etc.) with sort and filter (#1123)" },
|
||||
{ "type": "feature", "text": "Bark iOS notifications (#1095, PR#1097, @undirectlookable)" },
|
||||
{ "type": "feature", "text": "Signal notifications via signal-cli-rest-api (#1099)" },
|
||||
{ "type": "feature", "text": "Apprise passthrough — forward to a self-hosted caronc/apprise-api server (#1099)" },
|
||||
{ "type": "fix", "text": "env editor flagged Docker/Compose built-ins as MISSING (#141)" },
|
||||
{ "type": "fix", "text": "YAML editor indentation was inconsistent when pressing Enter (#1156)" },
|
||||
{ "type": "feature", "text": "`dockhand.update=false`, `dockhand.hidden=true` and `localhost/*` images skip registry polling (#1083)" },
|
||||
{ "type": "fix", "text": "registry authentication for image pulls (#1105)" },
|
||||
{ "type": "feature", "text": "native HTTPS listener, off by default (#1102)" },
|
||||
{ "type": "fix", "text": "environments stuck \"Failed\" after VPN/Tailscale tunnel drops until agent restart (#1160)" },
|
||||
{ "type": "fix", "text": "health_status events flooding container_events table (#1165)" },
|
||||
{ "type": "fix", "text": "git stack sync removes files deleted from the repo (hash-verified) (#966, #1162)" },
|
||||
{ "type": "feature", "text": "upload TLS/mTLS certificate files in environment editor (#125)" },
|
||||
{ "type": "feature", "text": "syntax highlighting for shell, Dockerfile, TOML, INI/conf and .env files in the file browser viewer (#1055)" },
|
||||
{ "type": "feature", "text": "Animated icons now configurable (#1169)" },
|
||||
{ "type": "fix", "text": "stack deploys ignored the env's configured socket path (#1172)" },
|
||||
{ "type": "fix", "text": "environment names with characters that break path resolution (e.g. `*`) are now rejected (#1179)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.33"
|
||||
},
|
||||
{
|
||||
"version": "1.0.32",
|
||||
"date": "2026-06-06",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "container details tweaks: process count, label filter, copy all labels (#812)" },
|
||||
{ "type": "feature", "text": "log improvements (#1130)" },
|
||||
{ "type": "fix", "text": "cleared Resources fields not persisted on container edit (#1119)" },
|
||||
{ "type": "fix", "text": "long container names overflowed in activity event details dialog (#1129)" },
|
||||
{ "type": "fix", "text": "git stack recreate and start operations ignored Dockhand-stored env vars (#1132)" },
|
||||
{ "type": "fix", "text": "dashboard stopped count reset to 0 after refresh for gracefully stopped containers (#1133)" },
|
||||
{ "type": "fix", "text": "auto-update preserves runtime `-e` env and `-l` label overrides (#1135)" },
|
||||
{ "type": "fix", "text": "git stack volume binds resolved to wrong host path when compose was in a subdirectory (#1139)" },
|
||||
{ "type": "fix", "text": "git stacks: subdir compose files now find their adjacent env files (#1136)" },
|
||||
{ "type": "feature", "text": "env editor doesn't flag Docker/Compose built-in variables as unused (#141)" },
|
||||
{ "type": "feature", "text": "container network mode: share another container's network namespace (#161)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.32"
|
||||
},
|
||||
{
|
||||
"version": "1.0.31",
|
||||
"date": "2026-05-30",
|
||||
"changes": [
|
||||
{ "type": "fix", "text": "502 Bad Gateway behind nginx-based reverse proxies — SvelteKit 2.51+ bloated the Link response header, pinned to 2.50.0 (#1114)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.31"
|
||||
},
|
||||
{
|
||||
"version": "1.0.30",
|
||||
"date": "2026-05-30",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "time range filter for log viewer — filter logs by From/To date and time (#1068)" },
|
||||
{ "type": "feature", "text": "configurable tail line count in log viewer — choose from 100 to all lines (#1066)" },
|
||||
{ "type": "feature", "text": "toggleable line numbers in log viewer (#1067)" },
|
||||
{ "type": "feature", "text": "\"some unused\" image filter — show images with both used and unused tags for selective cleanup (#621)" },
|
||||
{ "type": "feature", "text": "IP binding and port ranges in container port mappings (#581)" },
|
||||
{ "type": "feature", "text": "remove individual containers directly from stacks page (#576)" },
|
||||
{ "type": "fix", "text": "scan cache lookup by tag name never matched — results now resolved via image digest (#1064)" },
|
||||
{ "type": "fix", "text": "image-baked env vars not updated during auto-update container recreation (#1061)" },
|
||||
{ "type": "fix", "text": "git stack deploy via Hawser fails with \"Invalid string length\" when repo has large files (#1040)" },
|
||||
{ "type": "feature", "text": "Gotify notification priority via URL query param — gotify://host/token?priority=5 (#1033)" },
|
||||
{ "type": "fix", "text": "consistent action button order across container and stack views (#1079)" },
|
||||
{ "type": "feature", "text": "named custom URL labels — dockhand.url=[Name](https://...) markdown syntax (#1065)" },
|
||||
{ "type": "fix", "text": "HTTPS git credentials no longer leaked in process arguments (#1081)" },
|
||||
{ "type": "feature", "text": "bump Docker Compose to 5.1.4 (GHSA-pmwq-pjrm-6p5r)" },
|
||||
{ "type": "feature", "text": "dockhand.order label to control container display order within stacks (#847)" },
|
||||
{ "type": "feature", "text": "live network attach/detach for running containers — join or leave Docker networks without restarting (#1051)" },
|
||||
{ "type": "fix", "text": "environment variable values with nested quotes progressively corrupted on each save (#1036, #1086)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.30"
|
||||
},
|
||||
{
|
||||
"version": "1.0.29",
|
||||
"date": "2026-05-17",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "optionally display internal (exposed) container ports alongside published ports (#193)" },
|
||||
{ "type": "feature", "text": "show app version in sidebar with build info tooltip (#209)" },
|
||||
{ "type": "feature", "text": "central label management — rename or delete labels across all environments (#661)" },
|
||||
{ "type": "feature", "text": "find next available host port when creating or editing containers (#116)" },
|
||||
{ "type": "feature", "text": "theme-aware scrollbar styling — scrollbars adapt to dark/light mode and color palettes (#462)" },
|
||||
{ "type": "fix", "text": "update buttons (single, selected, and all) now respect the \"confirm dangerous actions\" setting (#638, #751)" },
|
||||
{ "type": "feature", "text": "custom URL labels - dockhand.url or dockhand.port.{port}.url to add links alongside container ports (#266)" },
|
||||
{ "type": "feature", "text": "generate and copy token for Hawser Standard mode with run command hint (#337)" },
|
||||
{ "type": "fix", "text": "environment stack directory not cleaned up when environment is deleted (#1023)" },
|
||||
{ "type": "feature", "text": "toggle to hide timestamps and container name prefix in log viewer (#124)" },
|
||||
{ "type": "fix", "text": "Podman containers health status not showing (#737)" },
|
||||
{ "type": "fix", "text": "containers with exit code 0 (init/migration) no longer cause stack \"partial\" status (#1026)" },
|
||||
{ "type": "fix", "text": "stats stream 400 on reconnect by skipping overlapping fetches (#1044)" },
|
||||
{ "type": "fix", "text": "env var validation false positive for values containing $ followed by text (#1048)" },
|
||||
{ "type": "fix", "text": "git-repos directory not cleaned up when environment is deleted (#1049)" },
|
||||
{ "type": "fix", "text": "webhook secret auto-generated when left empty despite hint saying otherwise (#1050)" },
|
||||
{ "type": "feature", "text": "scan reports — combined or individual Grype/Trivy (#1056)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.29"
|
||||
},
|
||||
{
|
||||
"version": "1.0.28",
|
||||
"date": "2026-05-09",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "context directory for git stacks — reference files from anywhere in the repo (#864)" },
|
||||
{ "type": "feature", "text": "no-cache build option for git stacks (#880)" },
|
||||
{ "type": "fix", "text": "env vars lost when switching between raw/form view (#964)" },
|
||||
{ "type": "fix", "text": "compose name property not respected during stack scan (#922)" },
|
||||
{ "type": "feature", "text": "editable schedule for scanner cache cleanup (#979)" },
|
||||
{ "type": "fix", "text": "container labels cannot be deleted (#984)" },
|
||||
{ "type": "fix", "text": "env var values leaked in deploy logs — now all values are redacted (#985)" },
|
||||
{ "type": "fix", "text": "volume export keeps helper container alive, preventing volume prune/deletion (#983)" },
|
||||
{ "type": "fix", "text": "ntfy self-hosted notifications fail when using ?auth= query parameter (#840)" },
|
||||
{ "type": "fix", "text": "scrollbar appears in dashboard tiles when content overflows (#969)" },
|
||||
{ "type": "fix", "text": "case-sensitive environment sort order — lowercase names sorted after uppercase (#975)" },
|
||||
{ "type": "fix", "text": "inaccurate dashboard CPU gauge caused by one-shot stats flag (#932)" },
|
||||
{ "type": "feature", "text": "ntfy notifications support ?tags=, ?title=, and ?priority= URL query parameters (#689)" },
|
||||
{ "type": "fix", "text": "stack .env file wiped when saving from graph view (#988)" },
|
||||
{ "type": "feature", "text": "dismiss update available indicators without updating (#853)" },
|
||||
{ "type": "feature", "text": "public IP setting available for hawser-edge environments — enables clickable port links (#350)" },
|
||||
{ "type": "fix", "text": "git stack creation silently destroys existing stacks with the same name (#1001)" },
|
||||
{ "type": "feature", "text": "static IP/MAC address configuration for containers (#297)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.28"
|
||||
},
|
||||
{
|
||||
"version": "1.0.27",
|
||||
"comingSoon": false,
|
||||
"date": "2026-04-26",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "network graph visualization on networks page (#894, @Penlane)" },
|
||||
{ "type": "feature", "text": "customizable compose template for new stacks in settings (#632, @oratory)" },
|
||||
{ "type": "feature", "text": "Microsoft Teams notifications via Power Automate Workflows (#355, @slokhorst)" },
|
||||
{ "type": "feature", "text": "container label controls: dockhand.update, dockhand.hidden, dockhand.notify (#6, #53, #94, #215)" },
|
||||
{ "type": "feature", "text": "configurable label filter matching mode (any/all) for environment dashboard (#607)" },
|
||||
{ "type": "feature", "text": "log search filter mode to hide non-matching lines (#916)" },
|
||||
{ "type": "feature", "text": "inline terminal on logs page with resizable split layout (#900)" },
|
||||
{ "type": "fix", "text": "disable Telegram link preview in notifications (#910, @deenle)" },
|
||||
{ "type": "fix", "text": "cron editor rejects 6-field expressions with seconds (#839, @GiulioSavini)" },
|
||||
{ "type": "fix", "text": "mirror Dockhand's ExtraHosts into scanner and self-update containers (#836, @YewFence)" },
|
||||
{ "type": "fix", "text": "duplicate volume binds during container recreate (#765, @itsDNNS)" },
|
||||
{ "type": "fix", "text": "log timestamp formatting not applied on main logs page (#882)" },
|
||||
{ "type": "fix", "text": "uploaded files now inherit container user ownership (#732, @ivanjx)" },
|
||||
{ "type": "fix", "text": "extraneous backslash in Telegram notification environment name (#955)" },
|
||||
{ "type": "fix", "text": "collapse ports into ranges only if 3 or more consecutive ports" },
|
||||
{ "type": "fix", "text": "git operations auto-merge system CAs with custom cert (#967)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.27"
|
||||
},
|
||||
{
|
||||
"version": "1.0.26",
|
||||
"date": "2026-04-19",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "persist sort order across page navigation for all data grids (#861, #912)" },
|
||||
{ "type": "feature", "text": "show git repository URL and branch in git stack edit modal (#856)" },
|
||||
{ "type": "feature", "text": "show memory limit alongside usage in containers and stacks views (#893)" },
|
||||
{ "type": "feature", "text": "option to delete associated volumes when removing a stack (#655)" },
|
||||
{ "type": "feature", "text": "collapse consecutive port mappings into ranges in container list (#821)" },
|
||||
{ "type": "fix", "text": "bearer token authentication fails with enterprise license active" },
|
||||
{ "type": "fix", "text": "clicking stack name toggles stats accordion instead of just opening editor (#628)" },
|
||||
{ "type": "fix", "text": "scheduled image prune notifications missing environment name (#770)" },
|
||||
{ "type": "fix", "text": "Gotify, ntfy, Pushover, and webhook notifications missing environment name (#943)" },
|
||||
{ "type": "fix", "text": "MFA code field not recognized by Bitwarden and other password managers (#566)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.26"
|
||||
},
|
||||
{
|
||||
"version": "1.0.25",
|
||||
"date": "2026-04-18",
|
||||
"comingSoon": false,
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "API token authentication — Bearer tokens for CI/CD pipelines and scripts" },
|
||||
{ "type": "feature", "text": "Telegram topic support — send notifications to supergroup topics (#855)" },
|
||||
{ "type": "fix", "text": "allow removing healthcheck, ports, and honor startAfterUpdate=false during container edit (#892)" },
|
||||
{ "type": "fix", "text": "validate stack names and prevent broken DB entries on invalid input (#876)" },
|
||||
{ "type": "fix", "text": "use per-environment timezone for schedule execution log timestamps (#882)" },
|
||||
{ "type": "fix", "text": "\"Pull image before update\" and \"Start after update\" settings ignored (#909)" },
|
||||
{ "type": "fix", "text": "image prune timeout on hawser-standard when pruning many images (#905)" },
|
||||
{ "type": "fix", "text": "bump Docker Compose to 5.1.3" },
|
||||
{ "type": "fix", "text": "mask secret environment variables in container inspect modal (#924)" },
|
||||
{ "type": "fix", "text": "viewer role can toggle, delete, and run schedules (#923)" },
|
||||
{ "type": "fix", "text": "settings show defaults instead of saved values after login until page refresh (#921)" },
|
||||
{ "type": "fix", "text": "settings toggle notifications show wrong state (#931)" },
|
||||
{ "type": "fix", "text": "stack memory tooltip shows inflated total on multi-container stacks (#936)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.25"
|
||||
},
|
||||
{
|
||||
"version": "1.0.24",
|
||||
"date": "2026-04-03",
|
||||
"changes": [
|
||||
{ "type": "fix", "text": "browsing HTTP registries fails with SSL error (#868)" },
|
||||
{ "type": "fix", "text": "git stack deploy options (build, re-pull, force redeploy) not persisted in edit dialog" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.24"
|
||||
},
|
||||
{
|
||||
"version": "1.0.23",
|
||||
"date": "2026-04-03",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "theme toggle with system option — auto-follows OS light/dark preference (#803)" },
|
||||
{ "type": "feature", "text": "custom user option for terminal shell sessions, persisted per container (#830)" },
|
||||
{ "type": "feature", "text": "redeploy button for internal stacks with pull/build/force-recreate options (#152)" },
|
||||
{ "type": "feature", "text": "build, re-pull images and force redeployment options for git stacks (#792, #472)" },
|
||||
{ "type": "fix", "text": "allow underscores in hostname validation (#790)" },
|
||||
{ "type": "fix", "text": "HTTPS git repos with self-signed CA certificates fail to clone/pull (#842)" },
|
||||
{ "type": "fix", "text": "stack restart fails for containers using network_mode: service:<container> — added recreate option (#844)" },
|
||||
{ "type": "fix", "text": "git stack sync deletes data in relative volume paths (#831)" },
|
||||
{ "type": "fix", "text": "batch update skips Hawser containers (#485)" },
|
||||
{ "type": "fix", "text": "registry delete fails for multi-arch/OCI manifest images" },
|
||||
{ "type": "fix", "text": "scanner cache cleanup to prevent volume bloat (#808)" },
|
||||
{ "type": "fix", "text": "negotiate Docker API version for scanner/updater sidecar containers (#759)" },
|
||||
{ "type": "fix", "text": "scan vulnerability counts mismatch with displayed list (#705)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.23"
|
||||
},
|
||||
{
|
||||
"version": "1.0.22",
|
||||
"date": "2026-03-21",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "dashboard list view with inline search and connection filters (#740)" },
|
||||
{ "type": "feature", "text": "custom environment icon (#754)" },
|
||||
{ "type": "feature", "text": "show +N indicator for containers with multiple IP addresses (#644)" },
|
||||
{ "type": "feature", "text": "bundle all fonts locally for privacy and offline use (#734)" },
|
||||
{ "type": "fix", "text": "respect PROXY settings when checking for container updates" },
|
||||
{ "type": "fix", "text": "git stacks force-redeploy after a failed sync (#693)" },
|
||||
{ "type": "fix", "text": "What's New modal shown before login, exposing version info (#717)" },
|
||||
{ "type": "fix", "text": "git repository files not removed from disk on delete (#671)" },
|
||||
{ "type": "fix", "text": "recursive chown at startup breaks stack volumes with different ownership (#719)" },
|
||||
{ "type": "fix", "text": "missing notification event toggles for container healthy, image prune events (#659)" },
|
||||
{ "type": "fix", "text": "container disappears when edit fails (e.g. invalid memory/swap) (#736)" },
|
||||
{ "type": "fix", "text": "regression: network container count always shows 0 (#761)" },
|
||||
{ "type": "fix", "text": "Grype/Trivy scan containers don't inherit proxy env vars (#780)" },
|
||||
{ "type": "fix", "text": "pin vulnerability scanner images to specific versions not :latest" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.22"
|
||||
},
|
||||
{
|
||||
"version": "1.0.21",
|
||||
"date": "2026-03-13",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "option to truncate port list (#702)" },
|
||||
{ "type": "feature", "text": "log viewer supports ANSII 256 colors (#743)" },
|
||||
{ "type": "fix", "text": "IPv6 Problems (#714, #731)" },
|
||||
{ "type": "fix", "text": "polling storm & mass disconnect (#733, #741)" },
|
||||
{ "type": "fix", "text": "custom cron schedule displayed incorrectly (#727)" },
|
||||
{ "type": "fix", "text": "wrong cron schedule (#706)" },
|
||||
{ "type": "fix", "text": "file browser does not allow upload over 512 KB (#687)" },
|
||||
{ "type": "fix", "text": "can't set memory swappiness when using Podman (#691)" },
|
||||
{ "type": "fix", "text": "compose API negotiation fix (#692, #696)" },
|
||||
{ "type": "fix", "text": "not deployed git stacks continue to show the Down action (#694)" },
|
||||
{ "type": "fix", "text": "display time doesn't reflect time zone (#735)" },
|
||||
{ "type": "fix", "text": "prune dangling images counter not working (#718)" },
|
||||
{ "type": "fix", "text": "own PORT env not used in HEALTHCHECK (#745)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.21"
|
||||
},
|
||||
{
|
||||
"version": "1.0.20",
|
||||
"date": "2026-03-02",
|
||||
"changes": [
|
||||
{ "type": "fix", "text": "regression on Synology DSM" },
|
||||
{ "type": "fix", "text": "Fix ARM64 regression: Go collector crashing on Raspberry Pi and other ARM devices" },
|
||||
{ "type": "fix", "text": "autoupdate hangs on \"waiting for Dockhand\"" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.20"
|
||||
},
|
||||
{
|
||||
"version": "1.0.19",
|
||||
"comingSoon": true,
|
||||
"date": "2026-03-01",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "Inline logs panel on stacks page — view container logs without leaving the page" },
|
||||
{ "type": "feature", "text": "Make ports column sortable in containers grid" },
|
||||
|
||||
@@ -275,6 +275,12 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/chalk/ansi-styles"
|
||||
},
|
||||
{
|
||||
"name": "ansi_up",
|
||||
"version": "6.0.6",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/drudru/ansi_up"
|
||||
},
|
||||
{
|
||||
"name": "argon2",
|
||||
"version": "0.41.1",
|
||||
@@ -289,7 +295,7 @@
|
||||
},
|
||||
{
|
||||
"name": "aria-query",
|
||||
"version": "5.3.2",
|
||||
"version": "5.3.1",
|
||||
"license": "Apache-2.0",
|
||||
"repository": "https://github.com/A11yance/aria-query"
|
||||
},
|
||||
@@ -425,6 +431,12 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sveltejs/devalue"
|
||||
},
|
||||
{
|
||||
"name": "devalue",
|
||||
"version": "5.6.4",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sveltejs/devalue"
|
||||
},
|
||||
{
|
||||
"name": "dijkstrajs",
|
||||
"version": "1.0.3",
|
||||
@@ -781,7 +793,7 @@
|
||||
},
|
||||
{
|
||||
"name": "svelte",
|
||||
"version": "5.53.1",
|
||||
"version": "5.53.5",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sveltejs/svelte"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* API Token Management
|
||||
*
|
||||
* Provides Bearer token authentication for CI/CD pipelines and scripts.
|
||||
* Tokens use `dh_` prefix, Argon2id hashing, and prefix-based lookup.
|
||||
*
|
||||
* Performance: An in-memory cache (SHA-256 key, 60s TTL) avoids running
|
||||
* Argon2id on every request. First request: ~100ms. Subsequent: ~0ms.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db, eq, and } from '$lib/server/db/drizzle';
|
||||
import { hashPassword, verifyPassword, type AuthenticatedUser } from './auth';
|
||||
import { secureRandomBytes } from './crypto-fallback';
|
||||
import { getUserRoles, userHasAdminRole, type Permissions } from './db';
|
||||
import { isEnterprise } from './license';
|
||||
import { tokenCache, ensureCleanupInterval, invalidateTokenCacheForUser, clearTokenCache } from './token-cache';
|
||||
|
||||
// Re-export cache functions so existing consumers don't need to change imports
|
||||
export { invalidateTokenCacheForUser, clearTokenCache } from './token-cache';
|
||||
|
||||
// Dynamic schema import (same pattern as db.ts)
|
||||
let apiTokensTable: any;
|
||||
|
||||
async function getApiTokensTable() {
|
||||
if (apiTokensTable) return apiTokensTable;
|
||||
const isPostgres = !!(process.env.DATABASE_URL && (
|
||||
process.env.DATABASE_URL.startsWith('postgres://') ||
|
||||
process.env.DATABASE_URL.startsWith('postgresql://')
|
||||
));
|
||||
const schema = isPostgres
|
||||
? await import('./db/schema/pg-schema.js')
|
||||
: await import('./db/schema/index.js');
|
||||
apiTokensTable = schema.apiTokens;
|
||||
return apiTokensTable;
|
||||
}
|
||||
|
||||
// Token format: dh_ + 32 bytes base64url = dh_ + 43 chars
|
||||
const TOKEN_PREFIX = 'dh_';
|
||||
const TOKEN_BYTES = 32;
|
||||
const PREFIX_LENGTH = 8; // chars after dh_ stored for identification
|
||||
const MAX_TOKEN_LENGTH = 200;
|
||||
const CACHE_TTL = 60_000; // 60 seconds
|
||||
|
||||
function cacheKey(rawToken: string): string {
|
||||
return createHash('sha256').update(rawToken).digest('hex');
|
||||
}
|
||||
|
||||
// Pre-computed dummy hash for timing protection on invalid prefixes
|
||||
let dummyHash: string | null = null;
|
||||
|
||||
async function getDummyHash(): Promise<string> {
|
||||
if (!dummyHash) {
|
||||
dummyHash = await hashPassword('dh_dummy_token_for_timing_protection');
|
||||
}
|
||||
return dummyHash;
|
||||
}
|
||||
|
||||
// Initialize dummy hash on import (fire and forget)
|
||||
void getDummyHash();
|
||||
|
||||
/**
|
||||
* Generate a new API token.
|
||||
* Returns the plaintext token (shown once) and the database record.
|
||||
*/
|
||||
export async function generateApiToken(
|
||||
userId: number,
|
||||
name: string,
|
||||
expiresAt?: string | null
|
||||
): Promise<{ token: string; id: number; tokenPrefix: string }> {
|
||||
const table = await getApiTokensTable();
|
||||
|
||||
// Generate random token
|
||||
const randomBytes = secureRandomBytes(TOKEN_BYTES);
|
||||
const rawToken = TOKEN_PREFIX + randomBytes.toString('base64url');
|
||||
const tokenPrefix = rawToken.substring(TOKEN_PREFIX.length, TOKEN_PREFIX.length + PREFIX_LENGTH);
|
||||
|
||||
// Hash for storage
|
||||
const tokenHash = await hashPassword(rawToken);
|
||||
|
||||
const result = await db.insert(table).values({
|
||||
userId,
|
||||
name,
|
||||
tokenHash,
|
||||
tokenPrefix,
|
||||
expiresAt: expiresAt || null
|
||||
}).returning();
|
||||
|
||||
return {
|
||||
token: rawToken,
|
||||
id: result[0].id,
|
||||
tokenPrefix
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Bearer token and return the associated user.
|
||||
* Uses cache to avoid Argon2id on every request.
|
||||
*/
|
||||
export async function validateApiToken(rawToken: string): Promise<AuthenticatedUser | null> {
|
||||
// Input validation
|
||||
if (!rawToken || rawToken.length > MAX_TOKEN_LENGTH || !rawToken.startsWith(TOKEN_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
ensureCleanupInterval();
|
||||
const key = cacheKey(rawToken);
|
||||
const cached = tokenCache.get(key);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.user;
|
||||
}
|
||||
|
||||
const table = await getApiTokensTable();
|
||||
|
||||
// Extract prefix for lookup
|
||||
const prefix = rawToken.substring(TOKEN_PREFIX.length, TOKEN_PREFIX.length + PREFIX_LENGTH);
|
||||
|
||||
// Find tokens with matching prefix (deleted tokens are gone, no isActive filter needed)
|
||||
const candidates = await db
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.tokenPrefix, prefix));
|
||||
|
||||
if (candidates.length === 0) {
|
||||
// Timing protection: run Argon2id anyway
|
||||
await verifyPassword(rawToken, await getDummyHash());
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify against each candidate (usually just one)
|
||||
for (const candidate of candidates) {
|
||||
const valid = await verifyPassword(rawToken, candidate.tokenHash);
|
||||
if (!valid) continue;
|
||||
|
||||
// Check expiration AFTER hash verification to avoid timing oracle
|
||||
if (candidate.expiresAt && new Date(candidate.expiresAt) < new Date()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build AuthenticatedUser from the token's user
|
||||
const user = await buildUserFromToken(candidate);
|
||||
if (!user) continue;
|
||||
|
||||
// Update lastUsed (fire and forget — non-critical audit field)
|
||||
void db.update(table)
|
||||
.set({ lastUsed: new Date().toISOString() })
|
||||
.where(eq(table.id, candidate.id))
|
||||
.catch((err) => {
|
||||
if (typeof process !== 'undefined' && process.env.DB_VERBOSE_LOGGING === 'true') {
|
||||
console.debug('[api-tokens] lastUsed update failed:', err?.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Cache the result — cap TTL at token expiry time if sooner
|
||||
let cacheTtl = CACHE_TTL;
|
||||
if (candidate.expiresAt) {
|
||||
const timeUntilExpiry = new Date(candidate.expiresAt).getTime() - Date.now();
|
||||
if (timeUntilExpiry < cacheTtl) {
|
||||
cacheTtl = Math.max(0, timeUntilExpiry);
|
||||
}
|
||||
}
|
||||
tokenCache.set(key, { user, expiresAt: Date.now() + cacheTtl });
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an AuthenticatedUser from a token's database record.
|
||||
*/
|
||||
async function buildUserFromToken(tokenRecord: any): Promise<AuthenticatedUser | null> {
|
||||
// Import getUserWithoutPassword dynamically to avoid circular deps
|
||||
// This avoids keeping passwordHash in memory unnecessarily
|
||||
const { getUserWithoutPassword } = await import('./db');
|
||||
|
||||
const dbUser = await getUserWithoutPassword(tokenRecord.userId);
|
||||
if (!dbUser || !dbUser.isActive) return null;
|
||||
|
||||
const enterprise = await isEnterprise();
|
||||
let isAdmin = false;
|
||||
let permissions: Permissions;
|
||||
|
||||
if (!enterprise) {
|
||||
// Free edition: everyone is effectively admin
|
||||
isAdmin = true;
|
||||
const { getRoleByName } = await import('./db');
|
||||
const adminRole = await getRoleByName('Admin');
|
||||
permissions = adminRole?.permissions ?? {} as Permissions;
|
||||
} else {
|
||||
isAdmin = await userHasAdminRole(dbUser.id);
|
||||
const userRoleAssignments = await getUserRoles(dbUser.id);
|
||||
// Merge permissions from all roles
|
||||
permissions = {} as Permissions;
|
||||
for (const assignment of userRoleAssignments) {
|
||||
if (!assignment.role) continue;
|
||||
const rolePerms = typeof assignment.role.permissions === 'string'
|
||||
? JSON.parse(assignment.role.permissions)
|
||||
: assignment.role.permissions;
|
||||
if (!rolePerms) continue;
|
||||
for (const [key, actions] of Object.entries(rolePerms)) {
|
||||
if (!permissions[key as keyof Permissions]) {
|
||||
permissions[key as keyof Permissions] = [];
|
||||
}
|
||||
for (const action of actions as string[]) {
|
||||
if (!permissions[key as keyof Permissions].includes(action)) {
|
||||
permissions[key as keyof Permissions].push(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine provider from authProvider field
|
||||
let provider: 'local' | 'ldap' | 'oidc' = 'local';
|
||||
if (dbUser.authProvider?.startsWith('ldap')) provider = 'ldap';
|
||||
else if (dbUser.authProvider?.startsWith('oidc')) provider = 'oidc';
|
||||
|
||||
return {
|
||||
id: dbUser.id,
|
||||
username: dbUser.username,
|
||||
email: dbUser.email ?? undefined,
|
||||
displayName: dbUser.displayName ?? undefined,
|
||||
avatar: dbUser.avatar ?? undefined,
|
||||
isAdmin,
|
||||
provider,
|
||||
permissions
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tokens for a user (no hashes returned).
|
||||
*/
|
||||
export async function listUserTokens(userId: number) {
|
||||
const table = await getApiTokensTable();
|
||||
return db
|
||||
.select({
|
||||
id: table.id,
|
||||
name: table.name,
|
||||
tokenPrefix: table.tokenPrefix,
|
||||
lastUsed: table.lastUsed,
|
||||
expiresAt: table.expiresAt,
|
||||
createdAt: table.createdAt
|
||||
})
|
||||
.from(table)
|
||||
.where(eq(table.userId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (delete) a token. Owner or admin can revoke.
|
||||
*/
|
||||
export async function revokeApiToken(tokenId: number, requestingUserId: number, isAdmin: boolean): Promise<boolean> {
|
||||
const table = await getApiTokensTable();
|
||||
|
||||
// Find the token
|
||||
const [token] = await db.select().from(table).where(eq(table.id, tokenId));
|
||||
if (!token) return false;
|
||||
|
||||
// Check ownership or admin
|
||||
if (token.userId !== requestingUserId && !isAdmin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hard-delete
|
||||
await db.delete(table).where(eq(table.id, tokenId));
|
||||
|
||||
// Clear cache — we can't map prefix to SHA-256 cache keys, so clear all
|
||||
clearTokenCache();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { isEnterprise } from './license';
|
||||
import { logAuditEvent, type AuditAction, type AuditEntityType, type AuditLogCreateData } from './db';
|
||||
import { authorize } from './authorize';
|
||||
import { getRequestContext } from './request-context';
|
||||
|
||||
export interface AuditContext {
|
||||
userId?: number | null;
|
||||
@@ -21,7 +22,8 @@ export interface AuditContext {
|
||||
* Extract audit context from a request event
|
||||
*/
|
||||
export async function getAuditContext(event: RequestEvent): Promise<AuditContext> {
|
||||
const auth = await authorize(event.cookies);
|
||||
const ctx = getRequestContext();
|
||||
const user = ctx?.user ?? (await authorize(event.cookies)).user;
|
||||
|
||||
// Get IP address from various headers (proxied requests)
|
||||
const forwardedFor = event.request.headers.get('x-forwarded-for');
|
||||
@@ -40,8 +42,8 @@ export async function getAuditContext(event: RequestEvent): Promise<AuditContext
|
||||
const userAgent = event.request.headers.get('user-agent') || null;
|
||||
|
||||
return {
|
||||
userId: auth.user?.id ?? null,
|
||||
username: auth.user?.username ?? 'anonymous',
|
||||
userId: user?.id ?? null,
|
||||
username: user?.username ?? 'anonymous',
|
||||
ipAddress,
|
||||
userAgent
|
||||
};
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
import { Client as LdapClient } from 'ldapts';
|
||||
import { isEnterprise } from './license';
|
||||
import { secureRandomBytes } from './crypto-fallback';
|
||||
import { invalidateTokenCacheForUser } from './token-cache';
|
||||
|
||||
// Session cookie name
|
||||
const SESSION_COOKIE_NAME = 'dockhand_session';
|
||||
@@ -136,6 +137,14 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
|
||||
}
|
||||
}
|
||||
|
||||
let dummyAuthHashCache: Promise<string> | null = null;
|
||||
export function getDummyAuthHash(): Promise<string> {
|
||||
if (!dummyAuthHashCache) {
|
||||
dummyAuthHashCache = hashPassword(`dummy-${Math.random()}-${Date.now()}`);
|
||||
}
|
||||
return dummyAuthHashCache;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Session Management
|
||||
// ============================================
|
||||
@@ -222,7 +231,7 @@ function setSessionCookie(cookies: Cookies, sessionId: string, maxAge: number, r
|
||||
path: '/',
|
||||
httpOnly: true, // Prevents XSS attacks from reading cookie
|
||||
secure: isSecureContext(request), // Protocol-aware: checks x-forwarded-proto or NODE_ENV
|
||||
sameSite: 'strict', // CSRF protection
|
||||
sameSite: 'lax', // Lax required for OIDC/SSO cross-site redirects
|
||||
maxAge: maxAge // Session timeout in seconds
|
||||
});
|
||||
}
|
||||
@@ -240,11 +249,22 @@ function getSessionIdFromCookies(cookies: Cookies): string | null {
|
||||
export async function validateSession(cookies: Cookies): Promise<AuthenticatedUser | null> {
|
||||
const sessionId = getSessionIdFromCookies(cookies);
|
||||
if (!sessionId) return null;
|
||||
return validateSessionById(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a session by raw session ID (without the SvelteKit Cookies object).
|
||||
*
|
||||
* Used by WebSocket upgrade handlers in server.js / vite.config.ts that only
|
||||
* have a raw Cookie header string. Mirrors validateSession() semantics:
|
||||
* returns the AuthenticatedUser on success, null on missing/expired/disabled.
|
||||
*/
|
||||
export async function validateSessionById(sessionId: string): Promise<AuthenticatedUser | null> {
|
||||
if (!sessionId) return null;
|
||||
|
||||
const session = await dbGetSession(sessionId);
|
||||
if (!session) return null;
|
||||
|
||||
// Check if session is expired
|
||||
const expiresAt = new Date(session.expiresAt);
|
||||
if (expiresAt < new Date()) {
|
||||
await dbDeleteSession(sessionId);
|
||||
@@ -257,6 +277,13 @@ export async function validateSession(cookies: Cookies): Promise<AuthenticatedUs
|
||||
return await buildAuthenticatedUser(user, session.provider as 'local' | 'ldap' | 'oidc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie name used for browser session auth. Exported so raw header parsers
|
||||
* (WebSocket upgrade handlers) can look it up without re-encoding the
|
||||
* constant.
|
||||
*/
|
||||
export const SESSION_COOKIE = SESSION_COOKIE_NAME;
|
||||
|
||||
/**
|
||||
* Destroy a session (logout)
|
||||
*/
|
||||
@@ -460,13 +487,14 @@ export async function authenticateLocal(
|
||||
const user = await getUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
// Use constant time to prevent timing attacks
|
||||
await hashPassword('dummy');
|
||||
await verifyPassword(password, await getDummyAuthHash());
|
||||
return { success: false, error: 'Invalid username or password' };
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
return { success: false, error: 'Account is disabled' };
|
||||
await verifyPassword(password, await getDummyAuthHash());
|
||||
console.warn(`[Auth] Login attempt for disabled account: user=${username}`);
|
||||
return { success: false, error: 'Invalid username or password' };
|
||||
}
|
||||
|
||||
const validPassword = await verifyPassword(password, user.passwordHash);
|
||||
@@ -735,6 +763,9 @@ async function tryLdapAuth(
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cached token permissions after role sync
|
||||
invalidateTokenCacheForUser(user.id);
|
||||
|
||||
if (!user.isActive) {
|
||||
return { success: false, error: 'Account is disabled' };
|
||||
}
|
||||
@@ -1449,6 +1480,9 @@ export async function handleOidcCallback(
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cached token permissions after role sync
|
||||
invalidateTokenCacheForUser(user.id);
|
||||
|
||||
if (!user.isActive) {
|
||||
return { success: false, error: 'Account is disabled' };
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import type { Permissions } from './db';
|
||||
import { getUserAccessibleEnvironments, userCanAccessEnvironment, userHasAdminRole } from './db';
|
||||
import { validateSession, isAuthEnabled, checkPermission, type AuthenticatedUser } from './auth';
|
||||
import { isEnterprise } from './license';
|
||||
import { getRequestContext } from './request-context';
|
||||
|
||||
export interface AuthorizationContext {
|
||||
/** Whether authentication is enabled globally */
|
||||
@@ -113,7 +114,10 @@ export interface AuthorizationContext {
|
||||
export async function authorize(cookies: Cookies): Promise<AuthorizationContext> {
|
||||
const authEnabled = await isAuthEnabled();
|
||||
const enterprise = await isEnterprise();
|
||||
const user = authEnabled ? await validateSession(cookies) : null;
|
||||
|
||||
// Try request context first (set by hook — handles both cookie and Bearer)
|
||||
const reqCtx = getRequestContext();
|
||||
const user = reqCtx?.user ?? (authEnabled ? await validateSession(cookies) : null);
|
||||
|
||||
// Determine admin status:
|
||||
// - Free edition: all authenticated users are effectively admins (full access)
|
||||
@@ -155,8 +159,8 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
|
||||
// Must be authenticated
|
||||
if (!user) return false;
|
||||
|
||||
// Admins can access all environments
|
||||
if (user.isAdmin) return true;
|
||||
// Admins can access all environments (use fresh isAdmin, not cached user.isAdmin)
|
||||
if (isAdmin) return true;
|
||||
|
||||
// In free edition, all authenticated users have full access
|
||||
if (!enterprise) return true;
|
||||
@@ -172,8 +176,8 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
|
||||
// Must be authenticated
|
||||
if (!user) return [];
|
||||
|
||||
// Admins can access all environments
|
||||
if (user.isAdmin) return null;
|
||||
// Admins can access all environments (use fresh isAdmin, not cached user.isAdmin)
|
||||
if (isAdmin) return null;
|
||||
|
||||
// In free edition, all authenticated users have full access
|
||||
if (!enterprise) return null;
|
||||
@@ -189,8 +193,8 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
|
||||
// Must be authenticated
|
||||
if (!user) return false;
|
||||
|
||||
// Admins can always manage users
|
||||
if (user.isAdmin) return true;
|
||||
// Admins can always manage users (use fresh isAdmin, not cached user.isAdmin)
|
||||
if (isAdmin) return true;
|
||||
|
||||
// In free edition, all authenticated users have full access
|
||||
if (!enterprise) return true;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Resolve the client IP for rate limiting, logging, and audit.
|
||||
*
|
||||
* Defaults to the socket-level IP via getClientAddress(). X-Forwarded-For
|
||||
* is consulted only when TRUST_FORWARDED_HEADERS=true is set explicitly —
|
||||
* intended for deployments behind a reverse proxy (Traefik, nginx, Caddy)
|
||||
* that controls XFF. In that mode the right-most XFF entry (closest to the
|
||||
* trusted proxy) is returned; earlier entries in the chain are ignored.
|
||||
*/
|
||||
|
||||
type IpEventLike = {
|
||||
request: Request;
|
||||
getClientAddress?: () => string;
|
||||
};
|
||||
|
||||
function normalize(ip: string | null | undefined): string {
|
||||
if (!ip) return 'unknown';
|
||||
if (ip === '::1' || ip === '::ffff:127.0.0.1') return '127.0.0.1';
|
||||
if (ip.startsWith('::ffff:')) return ip.substring(7);
|
||||
return ip;
|
||||
}
|
||||
|
||||
export function getClientIp(event: IpEventLike): string {
|
||||
if (process.env.TRUST_FORWARDED_HEADERS === 'true') {
|
||||
const xff = event.request.headers.get('x-forwarded-for');
|
||||
if (xff) {
|
||||
const parts = xff.split(',').map((p) => p.trim()).filter(Boolean);
|
||||
if (parts.length > 0) return normalize(parts[parts.length - 1]);
|
||||
}
|
||||
const realIp = event.request.headers.get('x-real-ip');
|
||||
if (realIp) return normalize(realIp.trim());
|
||||
}
|
||||
|
||||
try {
|
||||
const addr = event.getClientAddress?.();
|
||||
if (addr) return normalize(addr);
|
||||
} catch {
|
||||
// getClientAddress may throw if unavailable (test contexts, raw upgrades)
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Helpers for surfacing env/label divergence between a running
|
||||
* container and its image. Pure read-only — never used to mutate
|
||||
* the container; used only to power UI hints.
|
||||
*
|
||||
* Background: as of #1135 / commit 0f989bd7 revert, Dockhand no
|
||||
* longer "merges" image-baked env or labels into a container during
|
||||
* auto-update. The container's Config.Env and Config.Labels are
|
||||
* preserved verbatim (so a user's runtime `-e` / `-l` override is
|
||||
* never silently wiped). The trade-off, originally raised by #1061,
|
||||
* is that an image's updated default env/label values do not
|
||||
* automatically propagate to running containers.
|
||||
*
|
||||
* These helpers let the UI surface "this container's value differs
|
||||
* from the image's current value" so users can decide whether to
|
||||
* Remove & Deploy. We do NOT try to classify "user-set vs
|
||||
* image-baked" — that information isn't recoverable from Docker.
|
||||
*/
|
||||
|
||||
/** Parse a Docker env list (`KEY=value` strings) into a Map. */
|
||||
function parseEnv(entries: string[]): Map<string, string> {
|
||||
const m = new Map<string, string>();
|
||||
for (const e of entries) {
|
||||
const i = e.indexOf('=');
|
||||
if (i === -1) {
|
||||
m.set(e, '');
|
||||
} else {
|
||||
m.set(e.slice(0, i), e.slice(i + 1));
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys where the container's env value differs from the image's
|
||||
* CURRENT env value. Keys present in only one side are excluded —
|
||||
* they're either user-only or image-only, neither of which is
|
||||
* "divergence" we can usefully act on.
|
||||
*/
|
||||
export function detectImageEnvDivergence(
|
||||
containerEnv: string[],
|
||||
imageEnv: string[]
|
||||
): string[] {
|
||||
const cont = parseEnv(containerEnv);
|
||||
const img = parseEnv(imageEnv);
|
||||
const diff: string[] = [];
|
||||
for (const [k, v] of cont) {
|
||||
if (img.has(k) && img.get(k) !== v) {
|
||||
diff.push(k);
|
||||
}
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys where the container's label value differs from the image's
|
||||
* CURRENT label value. Same semantics as detectImageEnvDivergence.
|
||||
*/
|
||||
export function detectImageLabelDivergence(
|
||||
containerLabels: Record<string, string> | null | undefined,
|
||||
imageLabels: Record<string, string> | null | undefined
|
||||
): string[] {
|
||||
const cont = containerLabels || {};
|
||||
const img = imageLabels || {};
|
||||
const diff: string[] = [];
|
||||
for (const [k, v] of Object.entries(cont)) {
|
||||
if (k in img && img[k] !== v) {
|
||||
diff.push(k);
|
||||
}
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Dockhand Container Label Controls
|
||||
*
|
||||
* Docker container labels that control Dockhand behavior:
|
||||
* - dockhand.update=false — Skip this container during auto-updates and batch updates
|
||||
* - dockhand.hidden=true — Hide this container from the Dockhand UI
|
||||
* - dockhand.notify=false — Suppress notifications for this container's events
|
||||
* - dockhand.url=<url> — Custom clickable URL displayed alongside container ports
|
||||
* - dockhand.port.<hostPort>.url=<url> — Override the click URL for a specific published port
|
||||
* - dockhand.order=<int> — Controls display order within a stack (lower = first, default 0)
|
||||
*
|
||||
* All label values are case-insensitive and accept: true/yes/1 and false/no/0.
|
||||
* The opt-out model means labels override DB settings (label wins).
|
||||
*/
|
||||
|
||||
/** Recognized Dockhand label keys */
|
||||
export const DOCKHAND_LABELS = {
|
||||
UPDATE: 'dockhand.update',
|
||||
HIDDEN: 'dockhand.hidden',
|
||||
NOTIFY: 'dockhand.notify',
|
||||
URL: 'dockhand.url',
|
||||
ORDER: 'dockhand.order',
|
||||
} as const;
|
||||
|
||||
const TRUTHY_VALUES = new Set(['true', 'yes', '1']);
|
||||
const FALSY_VALUES = new Set(['false', 'no', '0']);
|
||||
|
||||
/**
|
||||
* Parse a label value as a boolean.
|
||||
* Returns true for: true, TRUE, yes, YES, 1
|
||||
* Returns false for: false, FALSE, no, NO, 0
|
||||
* Returns undefined for missing or unrecognized values.
|
||||
*/
|
||||
function parseLabelBool(value: string | undefined | null): boolean | undefined {
|
||||
if (value == null) return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (TRUTHY_VALUES.has(normalized)) return true;
|
||||
if (FALSY_VALUES.has(normalized)) return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a label value from a Docker labels object.
|
||||
*/
|
||||
function getLabel(labels: Record<string, string> | undefined | null, key: string): string | undefined {
|
||||
if (!labels) return undefined;
|
||||
return labels[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a container should be skipped during auto-updates.
|
||||
* Returns true if dockhand.update is explicitly set to false/no/0.
|
||||
* Default (no label): allow updates (opt-out model).
|
||||
*/
|
||||
export function isUpdateDisabledByLabel(labels: Record<string, string> | undefined | null): boolean {
|
||||
const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.UPDATE));
|
||||
return value === false; // explicitly disabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a container should be hidden from the UI.
|
||||
* Returns true if dockhand.hidden is explicitly set to true/yes/1.
|
||||
* Default (no label): visible (opt-out model).
|
||||
*/
|
||||
export function isHiddenByLabel(labels: Record<string, string> | undefined | null): boolean {
|
||||
const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.HIDDEN));
|
||||
return value === true; // explicitly hidden
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if notifications should be suppressed for this container.
|
||||
* Returns true if dockhand.notify is explicitly set to false/no/0.
|
||||
* Default (no label): send notifications (opt-out model).
|
||||
*/
|
||||
export function isNotifyDisabledByLabel(labels: Record<string, string> | undefined | null): boolean {
|
||||
const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.NOTIFY));
|
||||
return value === false; // explicitly disabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the custom URL from dockhand.url label.
|
||||
* Returns the URL string if set, or undefined.
|
||||
*/
|
||||
export function getCustomUrl(labels: Record<string, string> | undefined | null): string | undefined {
|
||||
const value = getLabel(labels, DOCKHAND_LABELS.URL);
|
||||
return value?.trim() || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sort order value from dockhand.order label.
|
||||
* Returns the parsed integer, or 0 for missing/invalid values.
|
||||
*/
|
||||
export function getOrderValue(labels: Record<string, string> | undefined | null): number {
|
||||
const value = getLabel(labels, DOCKHAND_LABELS.ORDER);
|
||||
if (value == null) return 0;
|
||||
const parsed = parseInt(value.trim(), 10);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all Dockhand label states from a container's labels.
|
||||
* Useful for including in API responses so the frontend knows about label overrides.
|
||||
*/
|
||||
export function getDockhandLabels(labels: Record<string, string> | undefined | null): {
|
||||
updateDisabled: boolean;
|
||||
hidden: boolean;
|
||||
notifyDisabled: boolean;
|
||||
customUrl?: string;
|
||||
} {
|
||||
return {
|
||||
updateDisabled: isUpdateDisabledByLabel(labels),
|
||||
hidden: isHiddenByLabel(labels),
|
||||
notifyDisabled: isNotifyDisabledByLabel(labels),
|
||||
customUrl: getCustomUrl(labels),
|
||||
};
|
||||
}
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
|
||||
import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types';
|
||||
import { encrypt, decrypt } from './encryption.js';
|
||||
import { parseEnvInterpolation } from './env-interpolation';
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export { db, isPostgres, isSqlite };
|
||||
@@ -112,7 +113,7 @@ export function initDatabase() {
|
||||
// =============================================================================
|
||||
|
||||
export async function getEnvironments(): Promise<Environment[]> {
|
||||
const results = await db.select().from(environments).orderBy(asc(environments.name));
|
||||
const results = await db.select().from(environments).orderBy(sql`lower(${environments.name})`);
|
||||
return results.map((e: Environment) => ({
|
||||
...e,
|
||||
tlsKey: decrypt(e.tlsKey),
|
||||
@@ -387,15 +388,17 @@ export async function getUserThemePreferences(userId: number): Promise<{
|
||||
gridFontSize: string;
|
||||
terminalFont: string;
|
||||
editorFont: string;
|
||||
animateIcons: boolean;
|
||||
}> {
|
||||
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont] = await Promise.all([
|
||||
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons] = await Promise.all([
|
||||
getUserSetting(userId, 'light_theme'),
|
||||
getUserSetting(userId, 'dark_theme'),
|
||||
getUserSetting(userId, 'font'),
|
||||
getUserSetting(userId, 'font_size'),
|
||||
getUserSetting(userId, 'grid_font_size'),
|
||||
getUserSetting(userId, 'terminal_font'),
|
||||
getUserSetting(userId, 'editor_font')
|
||||
getUserSetting(userId, 'editor_font'),
|
||||
getUserSetting(userId, 'animate_icons')
|
||||
]);
|
||||
return {
|
||||
lightTheme: lightTheme || 'default',
|
||||
@@ -404,13 +407,15 @@ export async function getUserThemePreferences(userId: number): Promise<{
|
||||
fontSize: fontSize || 'normal',
|
||||
gridFontSize: gridFontSize || 'normal',
|
||||
terminalFont: terminalFont || 'system-mono',
|
||||
editorFont: editorFont || 'system-mono'
|
||||
editorFont: editorFont || 'system-mono',
|
||||
// Default ON — only false when explicitly stored
|
||||
animateIcons: animateIcons === 'false' ? false : true
|
||||
};
|
||||
}
|
||||
|
||||
export async function setUserThemePreferences(
|
||||
userId: number,
|
||||
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string }
|
||||
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean }
|
||||
): Promise<void> {
|
||||
const updates: Promise<void>[] = [];
|
||||
if (prefs.lightTheme !== undefined) {
|
||||
@@ -434,6 +439,9 @@ export async function setUserThemePreferences(
|
||||
if (prefs.editorFont !== undefined) {
|
||||
updates.push(setUserSetting(userId, 'editor_font', prefs.editorFont));
|
||||
}
|
||||
if (prefs.animateIcons !== undefined) {
|
||||
updates.push(setUserSetting(userId, 'animate_icons', prefs.animateIcons ? 'true' : 'false'));
|
||||
}
|
||||
await Promise.all(updates);
|
||||
}
|
||||
|
||||
@@ -819,6 +827,13 @@ export const ENVIRONMENT_NOTIFICATION_EVENTS = NOTIFICATION_EVENT_TYPES.filter(e
|
||||
|
||||
export type NotificationEventType = typeof NOTIFICATION_EVENT_TYPES[number]['id'];
|
||||
|
||||
const environmentEventIds = new Set(ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id));
|
||||
|
||||
/** Strip system-scoped events (e.g. license_expiring) from environment notification records */
|
||||
function filterEnvironmentEventTypes(eventTypes: string[]): string[] {
|
||||
return eventTypes.filter(id => environmentEventIds.has(id));
|
||||
}
|
||||
|
||||
export interface NotificationSettingData {
|
||||
id: number;
|
||||
type: 'smtp' | 'apprise';
|
||||
@@ -982,7 +997,7 @@ export async function getEnvironmentNotifications(environmentId: number): Promis
|
||||
|
||||
return rows.map((row: any) => ({
|
||||
...row,
|
||||
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
|
||||
eventTypes: filterEnvironmentEventTypes(row.eventTypes ? JSON.parse(row.eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id))
|
||||
})) as EnvironmentNotificationData[];
|
||||
}
|
||||
|
||||
@@ -1009,7 +1024,7 @@ export async function getEnvironmentNotification(environmentId: number, notifica
|
||||
if (!rows[0]) return null;
|
||||
return {
|
||||
...rows[0],
|
||||
eventTypes: rows[0].eventTypes ? JSON.parse(rows[0].eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
|
||||
eventTypes: filterEnvironmentEventTypes(rows[0].eventTypes ? JSON.parse(rows[0].eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id))
|
||||
} as EnvironmentNotificationData;
|
||||
}
|
||||
|
||||
@@ -1019,7 +1034,7 @@ export async function createEnvironmentNotification(data: {
|
||||
enabled?: boolean;
|
||||
eventTypes?: NotificationEventType[];
|
||||
}): Promise<EnvironmentNotificationData> {
|
||||
const eventTypes = data.eventTypes || NOTIFICATION_EVENT_TYPES.map(e => e.id);
|
||||
const eventTypes = data.eventTypes || ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id);
|
||||
await db.insert(environmentNotifications).values({
|
||||
environmentId: data.environmentId,
|
||||
notificationId: data.notificationId,
|
||||
@@ -1087,7 +1102,7 @@ export async function getEnabledEnvironmentNotifications(
|
||||
return rows
|
||||
.map(row => ({
|
||||
...row,
|
||||
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id),
|
||||
eventTypes: filterEnvironmentEventTypes(row.eventTypes ? JSON.parse(row.eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id)),
|
||||
config: decryptNotificationConfig(row.channelType ?? 'apprise', row.config)
|
||||
}))
|
||||
.filter(row => !eventType || row.eventTypes.includes(eventType)) as (EnvironmentNotificationData & { config: any })[];
|
||||
@@ -1178,6 +1193,37 @@ export async function getUser(id: number): Promise<UserData | null> {
|
||||
return results[0] as UserData || null;
|
||||
}
|
||||
|
||||
export interface SafeUserData {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string | null;
|
||||
displayName: string | null;
|
||||
avatar: string | null;
|
||||
authProvider: string | null;
|
||||
mfaEnabled: boolean;
|
||||
isActive: boolean;
|
||||
lastLogin: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export async function getUserWithoutPassword(id: number): Promise<SafeUserData | null> {
|
||||
const results = await db.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
displayName: users.displayName,
|
||||
avatar: users.avatar,
|
||||
authProvider: users.authProvider,
|
||||
mfaEnabled: users.mfaEnabled,
|
||||
isActive: users.isActive,
|
||||
lastLogin: users.lastLogin,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt
|
||||
}).from(users).where(eq(users.id, id));
|
||||
return results[0] as SafeUserData || null;
|
||||
}
|
||||
|
||||
export async function hasAdminUser(): Promise<boolean> {
|
||||
// Check if any user has the Admin role assigned
|
||||
const adminRole = await db.select().from(roles).where(eq(roles.name, 'Admin')).limit(1);
|
||||
@@ -2019,7 +2065,16 @@ export async function updateGitRepository(id: number, data: Partial<GitRepositor
|
||||
return getGitRepository(id);
|
||||
}
|
||||
|
||||
export async function getGitStacksByRepositoryId(repositoryId: number): Promise<Array<{ id: number; stackName: string; environmentId: number | null }>> {
|
||||
return db.select({
|
||||
id: gitStacks.id,
|
||||
stackName: gitStacks.stackName,
|
||||
environmentId: gitStacks.environmentId
|
||||
}).from(gitStacks).where(eq(gitStacks.repositoryId, repositoryId));
|
||||
}
|
||||
|
||||
export async function deleteGitRepository(id: number): Promise<boolean> {
|
||||
console.log(`[GitStack] Deleting git repository id=${id} (will cascade-delete git_stacks, set null on stack_sources FKs)`);
|
||||
await db.delete(gitRepositories).where(eq(gitRepositories.id, id));
|
||||
return true;
|
||||
}
|
||||
@@ -2040,10 +2095,16 @@ export interface GitStackData {
|
||||
autoUpdateCron: string;
|
||||
webhookEnabled: boolean;
|
||||
webhookSecret: string | null;
|
||||
contextDir: string | null;
|
||||
buildOnDeploy: boolean;
|
||||
noBuildCache: boolean;
|
||||
repullImages: boolean;
|
||||
forceRedeploy: boolean;
|
||||
lastSync: string | null;
|
||||
lastCommit: string | null;
|
||||
syncStatus: GitSyncStatus;
|
||||
syncError: string | null;
|
||||
syncedFiles?: string | null; // JSON manifest { commit, files: { relPath: sha256 } } from last successful deploy
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -2073,6 +2134,11 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
|
||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||
webhookEnabled: gitStacks.webhookEnabled,
|
||||
webhookSecret: gitStacks.webhookSecret,
|
||||
contextDir: gitStacks.contextDir,
|
||||
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||
noBuildCache: gitStacks.noBuildCache,
|
||||
repullImages: gitStacks.repullImages,
|
||||
forceRedeploy: gitStacks.forceRedeploy,
|
||||
lastSync: gitStacks.lastSync,
|
||||
lastCommit: gitStacks.lastCommit,
|
||||
syncStatus: gitStacks.syncStatus,
|
||||
@@ -2101,6 +2167,11 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
|
||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||
webhookEnabled: gitStacks.webhookEnabled,
|
||||
webhookSecret: gitStacks.webhookSecret,
|
||||
contextDir: gitStacks.contextDir,
|
||||
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||
noBuildCache: gitStacks.noBuildCache,
|
||||
repullImages: gitStacks.repullImages,
|
||||
forceRedeploy: gitStacks.forceRedeploy,
|
||||
lastSync: gitStacks.lastSync,
|
||||
lastCommit: gitStacks.lastCommit,
|
||||
syncStatus: gitStacks.syncStatus,
|
||||
@@ -2129,6 +2200,11 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
|
||||
autoUpdateCron: row.autoUpdateCron,
|
||||
webhookEnabled: row.webhookEnabled,
|
||||
webhookSecret: row.webhookSecret,
|
||||
contextDir: row.contextDir ?? null,
|
||||
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||
noBuildCache: row.noBuildCache ?? false,
|
||||
repullImages: row.repullImages ?? false,
|
||||
forceRedeploy: row.forceRedeploy ?? false,
|
||||
lastSync: row.lastSync,
|
||||
lastCommit: row.lastCommit,
|
||||
syncStatus: row.syncStatus,
|
||||
@@ -2159,6 +2235,11 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
|
||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||
webhookEnabled: gitStacks.webhookEnabled,
|
||||
webhookSecret: gitStacks.webhookSecret,
|
||||
contextDir: gitStacks.contextDir,
|
||||
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||
noBuildCache: gitStacks.noBuildCache,
|
||||
repullImages: gitStacks.repullImages,
|
||||
forceRedeploy: gitStacks.forceRedeploy,
|
||||
lastSync: gitStacks.lastSync,
|
||||
lastCommit: gitStacks.lastCommit,
|
||||
syncStatus: gitStacks.syncStatus,
|
||||
@@ -2187,6 +2268,11 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
|
||||
autoUpdateCron: row.autoUpdateCron,
|
||||
webhookEnabled: row.webhookEnabled,
|
||||
webhookSecret: row.webhookSecret,
|
||||
contextDir: row.contextDir ?? null,
|
||||
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||
noBuildCache: row.noBuildCache ?? false,
|
||||
repullImages: row.repullImages ?? false,
|
||||
forceRedeploy: row.forceRedeploy ?? false,
|
||||
lastSync: row.lastSync,
|
||||
lastCommit: row.lastCommit,
|
||||
syncStatus: row.syncStatus,
|
||||
@@ -2216,10 +2302,16 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
|
||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||
webhookEnabled: gitStacks.webhookEnabled,
|
||||
webhookSecret: gitStacks.webhookSecret,
|
||||
contextDir: gitStacks.contextDir,
|
||||
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||
noBuildCache: gitStacks.noBuildCache,
|
||||
repullImages: gitStacks.repullImages,
|
||||
forceRedeploy: gitStacks.forceRedeploy,
|
||||
lastSync: gitStacks.lastSync,
|
||||
lastCommit: gitStacks.lastCommit,
|
||||
syncStatus: gitStacks.syncStatus,
|
||||
syncError: gitStacks.syncError,
|
||||
syncedFiles: gitStacks.syncedFiles,
|
||||
createdAt: gitStacks.createdAt,
|
||||
updatedAt: gitStacks.updatedAt,
|
||||
repoName: gitRepositories.name,
|
||||
@@ -2245,10 +2337,16 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
|
||||
autoUpdateCron: row.autoUpdateCron,
|
||||
webhookEnabled: row.webhookEnabled,
|
||||
webhookSecret: row.webhookSecret,
|
||||
contextDir: row.contextDir ?? null,
|
||||
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||
noBuildCache: row.noBuildCache ?? false,
|
||||
repullImages: row.repullImages ?? false,
|
||||
forceRedeploy: row.forceRedeploy ?? false,
|
||||
lastSync: row.lastSync,
|
||||
lastCommit: row.lastCommit,
|
||||
syncStatus: row.syncStatus,
|
||||
syncError: row.syncError,
|
||||
syncedFiles: row.syncedFiles ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
repository: {
|
||||
@@ -2274,6 +2372,11 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
|
||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||
webhookEnabled: gitStacks.webhookEnabled,
|
||||
webhookSecret: gitStacks.webhookSecret,
|
||||
contextDir: gitStacks.contextDir,
|
||||
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||
noBuildCache: gitStacks.noBuildCache,
|
||||
repullImages: gitStacks.repullImages,
|
||||
forceRedeploy: gitStacks.forceRedeploy,
|
||||
lastSync: gitStacks.lastSync,
|
||||
lastCommit: gitStacks.lastCommit,
|
||||
syncStatus: gitStacks.syncStatus,
|
||||
@@ -2308,6 +2411,11 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
|
||||
autoUpdateCron: row.autoUpdateCron,
|
||||
webhookEnabled: row.webhookEnabled,
|
||||
webhookSecret: row.webhookSecret,
|
||||
contextDir: row.contextDir ?? null,
|
||||
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||
noBuildCache: row.noBuildCache ?? false,
|
||||
repullImages: row.repullImages ?? false,
|
||||
forceRedeploy: row.forceRedeploy ?? false,
|
||||
lastSync: row.lastSync,
|
||||
lastCommit: row.lastCommit,
|
||||
syncStatus: row.syncStatus,
|
||||
@@ -2337,6 +2445,11 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
|
||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||
webhookEnabled: gitStacks.webhookEnabled,
|
||||
webhookSecret: gitStacks.webhookSecret,
|
||||
contextDir: gitStacks.contextDir,
|
||||
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||
noBuildCache: gitStacks.noBuildCache,
|
||||
repullImages: gitStacks.repullImages,
|
||||
forceRedeploy: gitStacks.forceRedeploy,
|
||||
lastSync: gitStacks.lastSync,
|
||||
lastCommit: gitStacks.lastCommit,
|
||||
syncStatus: gitStacks.syncStatus,
|
||||
@@ -2366,6 +2479,11 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
|
||||
autoUpdateCron: row.autoUpdateCron,
|
||||
webhookEnabled: row.webhookEnabled,
|
||||
webhookSecret: row.webhookSecret,
|
||||
contextDir: row.contextDir ?? null,
|
||||
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||
noBuildCache: row.noBuildCache ?? false,
|
||||
repullImages: row.repullImages ?? false,
|
||||
forceRedeploy: row.forceRedeploy ?? false,
|
||||
lastSync: row.lastSync,
|
||||
lastCommit: row.lastCommit,
|
||||
syncStatus: row.syncStatus,
|
||||
@@ -2393,6 +2511,11 @@ export async function createGitStack(data: {
|
||||
autoUpdateCron?: string;
|
||||
webhookEnabled?: boolean;
|
||||
webhookSecret?: string | null;
|
||||
contextDir?: string | null;
|
||||
buildOnDeploy?: boolean;
|
||||
noBuildCache?: boolean;
|
||||
repullImages?: boolean;
|
||||
forceRedeploy?: boolean;
|
||||
}): Promise<GitStackWithRepo> {
|
||||
const result = await db.insert(gitStacks).values({
|
||||
stackName: data.stackName,
|
||||
@@ -2400,11 +2523,16 @@ export async function createGitStack(data: {
|
||||
repositoryId: data.repositoryId,
|
||||
composePath: data.composePath || 'compose.yaml',
|
||||
envFilePath: data.envFilePath || null,
|
||||
contextDir: data.contextDir || null,
|
||||
autoUpdate: data.autoUpdate || false,
|
||||
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
|
||||
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
|
||||
webhookEnabled: data.webhookEnabled || false,
|
||||
webhookSecret: data.webhookSecret || null
|
||||
webhookSecret: data.webhookSecret || null,
|
||||
buildOnDeploy: data.buildOnDeploy ?? false,
|
||||
noBuildCache: data.noBuildCache ?? false,
|
||||
repullImages: data.repullImages ?? false,
|
||||
forceRedeploy: data.forceRedeploy ?? false
|
||||
}).returning();
|
||||
return getGitStack(result[0].id) as Promise<GitStackWithRepo>;
|
||||
}
|
||||
@@ -2421,16 +2549,23 @@ export async function updateGitStack(id: number, data: Partial<GitStackData>): P
|
||||
if (data.autoUpdateCron !== undefined) updateData.autoUpdateCron = data.autoUpdateCron;
|
||||
if (data.webhookEnabled !== undefined) updateData.webhookEnabled = data.webhookEnabled;
|
||||
if (data.webhookSecret !== undefined) updateData.webhookSecret = data.webhookSecret;
|
||||
if (data.contextDir !== undefined) updateData.contextDir = data.contextDir;
|
||||
if (data.buildOnDeploy !== undefined) updateData.buildOnDeploy = data.buildOnDeploy;
|
||||
if (data.noBuildCache !== undefined) updateData.noBuildCache = data.noBuildCache;
|
||||
if (data.repullImages !== undefined) updateData.repullImages = data.repullImages;
|
||||
if (data.forceRedeploy !== undefined) updateData.forceRedeploy = data.forceRedeploy;
|
||||
if (data.lastSync !== undefined) updateData.lastSync = data.lastSync;
|
||||
if (data.lastCommit !== undefined) updateData.lastCommit = data.lastCommit;
|
||||
if (data.syncStatus !== undefined) updateData.syncStatus = data.syncStatus;
|
||||
if (data.syncError !== undefined) updateData.syncError = data.syncError;
|
||||
if (data.syncedFiles !== undefined) updateData.syncedFiles = data.syncedFiles;
|
||||
|
||||
await db.update(gitStacks).set(updateData).where(eq(gitStacks.id, id));
|
||||
return getGitStack(id);
|
||||
}
|
||||
|
||||
export async function deleteGitStack(id: number): Promise<boolean> {
|
||||
console.log(`[GitStack] Deleting git_stacks row id=${id}`);
|
||||
await db.delete(gitStacks).where(eq(gitStacks.id, id));
|
||||
return true;
|
||||
}
|
||||
@@ -2455,6 +2590,11 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
|
||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||
webhookEnabled: gitStacks.webhookEnabled,
|
||||
webhookSecret: gitStacks.webhookSecret,
|
||||
contextDir: gitStacks.contextDir,
|
||||
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||
noBuildCache: gitStacks.noBuildCache,
|
||||
repullImages: gitStacks.repullImages,
|
||||
forceRedeploy: gitStacks.forceRedeploy,
|
||||
lastSync: gitStacks.lastSync,
|
||||
lastCommit: gitStacks.lastCommit,
|
||||
syncStatus: gitStacks.syncStatus,
|
||||
@@ -2482,6 +2622,11 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
|
||||
autoUpdateCron: row.autoUpdateCron,
|
||||
webhookEnabled: row.webhookEnabled,
|
||||
webhookSecret: row.webhookSecret,
|
||||
contextDir: row.contextDir ?? null,
|
||||
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||
noBuildCache: row.noBuildCache ?? false,
|
||||
repullImages: row.repullImages ?? false,
|
||||
forceRedeploy: row.forceRedeploy ?? false,
|
||||
lastSync: row.lastSync,
|
||||
lastCommit: row.lastCommit,
|
||||
syncStatus: row.syncStatus,
|
||||
@@ -2510,6 +2655,11 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
|
||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||
webhookEnabled: gitStacks.webhookEnabled,
|
||||
webhookSecret: gitStacks.webhookSecret,
|
||||
contextDir: gitStacks.contextDir,
|
||||
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||
noBuildCache: gitStacks.noBuildCache,
|
||||
repullImages: gitStacks.repullImages,
|
||||
forceRedeploy: gitStacks.forceRedeploy,
|
||||
lastSync: gitStacks.lastSync,
|
||||
lastCommit: gitStacks.lastCommit,
|
||||
syncStatus: gitStacks.syncStatus,
|
||||
@@ -2536,6 +2686,11 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
|
||||
autoUpdateCron: row.autoUpdateCron,
|
||||
webhookEnabled: row.webhookEnabled,
|
||||
webhookSecret: row.webhookSecret,
|
||||
contextDir: row.contextDir ?? null,
|
||||
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||
noBuildCache: row.noBuildCache ?? false,
|
||||
repullImages: row.repullImages ?? false,
|
||||
forceRedeploy: row.forceRedeploy ?? false,
|
||||
lastSync: row.lastSync,
|
||||
lastCommit: row.lastCommit,
|
||||
syncStatus: row.syncStatus,
|
||||
@@ -2678,11 +2833,21 @@ export async function upsertStackSource(data: {
|
||||
const existing = await getStackSource(data.stackName, data.environmentId);
|
||||
|
||||
if (existing) {
|
||||
const newRepoId = data.gitRepositoryId || null;
|
||||
const newStackId = data.gitStackId || null;
|
||||
const changes: string[] = [];
|
||||
if (data.sourceType !== existing.sourceType) changes.push(`sourceType: ${existing.sourceType} → ${data.sourceType}`);
|
||||
if (newRepoId !== existing.gitRepositoryId) changes.push(`gitRepoId: ${existing.gitRepositoryId} → ${newRepoId}`);
|
||||
if (newStackId !== existing.gitStackId) changes.push(`gitStackId: ${existing.gitStackId} → ${newStackId}`);
|
||||
if (changes.length > 0) {
|
||||
console.log(`[GitStack] Updating stack_sources "${data.stackName}" env=${data.environmentId}: ${changes.join(', ')}`);
|
||||
}
|
||||
|
||||
await db.update(stackSources)
|
||||
.set({
|
||||
sourceType: data.sourceType,
|
||||
gitRepositoryId: data.gitRepositoryId || null,
|
||||
gitStackId: data.gitStackId || null,
|
||||
gitRepositoryId: newRepoId,
|
||||
gitStackId: newStackId,
|
||||
composePath: data.composePath ?? null,
|
||||
envPath: data.envPath ?? null,
|
||||
updatedAt: new Date().toISOString()
|
||||
@@ -2690,6 +2855,7 @@ export async function upsertStackSource(data: {
|
||||
.where(eq(stackSources.id, existing.id));
|
||||
return getStackSource(data.stackName, data.environmentId) as Promise<StackSourceData>;
|
||||
} else {
|
||||
console.log(`[GitStack] Creating stack_sources "${data.stackName}" env=${data.environmentId} type=${data.sourceType} repoId=${data.gitRepositoryId || null} stackId=${data.gitStackId || null}`);
|
||||
await db.insert(stackSources).values({
|
||||
stackName: data.stackName,
|
||||
environmentId: data.environmentId ?? null,
|
||||
@@ -2723,6 +2889,7 @@ export async function updateStackSource(
|
||||
}
|
||||
|
||||
export async function deleteStackSource(stackName: string, environmentId?: number | null): Promise<boolean> {
|
||||
console.log(`[GitStack] Deleting stack_sources "${stackName}" env=${environmentId}`);
|
||||
// Delete matching record (either with specific envId or NULL)
|
||||
await db.delete(stackSources)
|
||||
.where(and(
|
||||
@@ -2974,7 +3141,7 @@ export type AuditAction =
|
||||
export type AuditEntityType =
|
||||
| 'container' | 'image' | 'stack' | 'volume' | 'network'
|
||||
| 'user' | 'role' | 'settings' | 'environment' | 'registry' | 'git_repository' | 'git_credential'
|
||||
| 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack';
|
||||
| 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack' | 'api_token';
|
||||
|
||||
export interface AuditLogData {
|
||||
id: number;
|
||||
@@ -3090,14 +3257,16 @@ export async function getAuditLogs(filters: AuditLogFilters = {}): Promise<Audit
|
||||
// Labels filter - find environments with matching labels first
|
||||
let labelFilteredEnvIds: number[] | undefined;
|
||||
if (filters.labels && filters.labels.length > 0) {
|
||||
// Get environments that have ANY of the specified labels
|
||||
const labelFilterMode = await getSetting('label_filter_mode') ?? 'any';
|
||||
const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments);
|
||||
labelFilteredEnvIds = allEnvs
|
||||
.filter(env => {
|
||||
if (!env.labels) return false;
|
||||
try {
|
||||
const envLabels = JSON.parse(env.labels) as string[];
|
||||
return filters.labels!.some(label => envLabels.includes(label));
|
||||
return labelFilterMode === 'all'
|
||||
? filters.labels!.every(label => envLabels.includes(label))
|
||||
: filters.labels!.some(label => envLabels.includes(label));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -3305,14 +3474,16 @@ export async function getContainerEvents(filters: ContainerEventFilters = {}): P
|
||||
// Labels filter - find environments with matching labels first
|
||||
let labelFilteredEnvIds: number[] | undefined;
|
||||
if (filters.labels && filters.labels.length > 0) {
|
||||
// Get environments that have ANY of the specified labels
|
||||
const labelFilterMode = await getSetting('label_filter_mode') ?? 'any';
|
||||
const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments);
|
||||
labelFilteredEnvIds = allEnvs
|
||||
.filter(env => {
|
||||
if (!env.labels) return false;
|
||||
try {
|
||||
const envLabels = JSON.parse(env.labels) as string[];
|
||||
return filters.labels!.some(label => envLabels.includes(label));
|
||||
return labelFilterMode === 'all'
|
||||
? filters.labels!.every(label => envLabels.includes(label))
|
||||
: filters.labels!.some(label => envLabels.includes(label));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -3432,9 +3603,15 @@ export async function getContainerEventActions(): Promise<string[]> {
|
||||
|
||||
export async function deleteOldContainerEvents(keepDays = 30): Promise<number> {
|
||||
const cutoffDate = new Date(Date.now() - keepDays * 24 * 60 * 60 * 1000).toISOString();
|
||||
await db.delete(containerEvents)
|
||||
const countResult = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(containerEvents)
|
||||
.where(sql`timestamp < ${cutoffDate}`);
|
||||
return 0;
|
||||
const count = Number(countResult[0]?.count ?? 0);
|
||||
if (count > 0) {
|
||||
await db.delete(containerEvents)
|
||||
.where(sql`timestamp < ${cutoffDate}`);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3922,9 +4099,15 @@ export async function getRecentExecutionsForSchedule(
|
||||
|
||||
export async function cleanupOldExecutions(retentionDays: number): Promise<number> {
|
||||
const cutoffDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
|
||||
const result = await db.delete(scheduleExecutions)
|
||||
const countResult = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(scheduleExecutions)
|
||||
.where(sql`triggered_at < ${cutoffDate}`);
|
||||
return 0; // SQLite/PG don't return count consistently
|
||||
const count = Number(countResult[0]?.count ?? 0);
|
||||
if (count > 0) {
|
||||
await db.delete(scheduleExecutions)
|
||||
.where(sql`triggered_at < ${cutoffDate}`);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// Settings helpers for retention
|
||||
@@ -3935,8 +4118,11 @@ const SCHEDULE_CLEANUP_CRON_KEY = 'schedule_cleanup_cron';
|
||||
const EVENT_CLEANUP_CRON_KEY = 'event_cleanup_cron';
|
||||
const SCHEDULE_CLEANUP_ENABLED_KEY = 'schedule_cleanup_enabled';
|
||||
const EVENT_CLEANUP_ENABLED_KEY = 'event_cleanup_enabled';
|
||||
const SCANNER_CLEANUP_CRON_KEY = 'scanner_cleanup_cron';
|
||||
const SCANNER_CLEANUP_ENABLED_KEY = 'scanner_cleanup_enabled';
|
||||
const DEFAULT_SCHEDULE_CLEANUP_CRON = '0 3 * * *'; // Daily at 3 AM
|
||||
const DEFAULT_EVENT_CLEANUP_CRON = '30 3 * * *'; // Daily at 3:30 AM
|
||||
const DEFAULT_SCANNER_CLEANUP_CRON = '0 3 * * 0'; // Weekly Sunday at 3 AM
|
||||
|
||||
export async function getScheduleRetentionDays(): Promise<number> {
|
||||
const result = await db.select().from(settings).where(eq(settings.key, SCHEDULE_RETENTION_KEY));
|
||||
@@ -4070,6 +4256,50 @@ export async function setEventCleanupEnabled(enabled: boolean): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getScannerCleanupCron(): Promise<string> {
|
||||
const result = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
|
||||
if (result[0]) {
|
||||
return result[0].value || DEFAULT_SCANNER_CLEANUP_CRON;
|
||||
}
|
||||
return DEFAULT_SCANNER_CLEANUP_CRON;
|
||||
}
|
||||
|
||||
export async function setScannerCleanupCron(cron: string): Promise<void> {
|
||||
const existing = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
|
||||
if (existing.length > 0) {
|
||||
await db.update(settings)
|
||||
.set({ value: cron, updatedAt: new Date().toISOString() })
|
||||
.where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
|
||||
} else {
|
||||
await db.insert(settings).values({
|
||||
key: SCANNER_CLEANUP_CRON_KEY,
|
||||
value: cron
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function getScannerCleanupEnabled(): Promise<boolean> {
|
||||
const result = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
|
||||
if (result[0]) {
|
||||
return result[0].value === 'true';
|
||||
}
|
||||
return true; // Enabled by default
|
||||
}
|
||||
|
||||
export async function setScannerCleanupEnabled(enabled: boolean): Promise<void> {
|
||||
const existing = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
|
||||
if (existing.length > 0) {
|
||||
await db.update(settings)
|
||||
.set({ value: enabled ? 'true' : 'false', updatedAt: new Date().toISOString() })
|
||||
.where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
|
||||
} else {
|
||||
await db.insert(settings).values({
|
||||
key: SCANNER_CLEANUP_ENABLED_KEY,
|
||||
value: enabled ? 'true' : 'false'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EXTERNAL STACK PATHS
|
||||
// =============================================================================
|
||||
@@ -4104,6 +4334,17 @@ export async function setExternalStackPaths(paths: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently add a directory to the external stack paths allowlist.
|
||||
* Returns true if the path was newly added (false if already present).
|
||||
*/
|
||||
export async function addExternalStackPath(dir: string): Promise<boolean> {
|
||||
const current = await getExternalStackPaths();
|
||||
if (current.includes(dir)) return false;
|
||||
await setExternalStackPaths([...current, dir]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PRIMARY STACK LOCATION
|
||||
// =============================================================================
|
||||
@@ -4514,6 +4755,55 @@ export async function setStackEnvVars(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the set of secret key names for a stack.
|
||||
* Used to mask secret values in container inspect responses.
|
||||
*/
|
||||
export async function getSecretKeyNames(
|
||||
stackName: string,
|
||||
environmentId?: number | null
|
||||
): Promise<Set<string>> {
|
||||
const vars = await getStackEnvVars(stackName, environmentId, true);
|
||||
return new Set(vars.filter(v => v.isSecret).map(v => v.key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the set of env var keys that should be masked in container inspect responses.
|
||||
* Handles two cases:
|
||||
* 1. Direct match: env var key == secret key in DB (e.g., DB_PASS=${DB_PASS})
|
||||
* 2. Interpolation: env var key differs from secret key (e.g., MYSQL_PASSWORD=${db_secret})
|
||||
* Detected by parsing the compose file for ${variable} references in environment: sections.
|
||||
*
|
||||
* @param composeContent - Optional compose file content. If provided, interpolation
|
||||
* references are parsed to detect secrets injected under different key names.
|
||||
*/
|
||||
export async function getSecretKeysToMask(
|
||||
stackName: string,
|
||||
environmentId?: number | null,
|
||||
composeContent?: string | null
|
||||
): Promise<Set<string>> {
|
||||
const vars = await getStackEnvVars(stackName, environmentId, true);
|
||||
const secretKeyNames = new Set(vars.filter(v => v.isSecret).map(v => v.key));
|
||||
|
||||
if (secretKeyNames.size === 0) return secretKeyNames;
|
||||
|
||||
// If we have compose content, parse interpolation references to find
|
||||
// container env keys that map to secret interpolation variables.
|
||||
// e.g., "MYSQL_PASSWORD=${db_secret}" → if db_secret is a secret, mask MYSQL_PASSWORD too.
|
||||
if (composeContent) {
|
||||
const interpolated = parseEnvInterpolation(composeContent);
|
||||
for (const [containerKey, varName] of interpolated) {
|
||||
if (secretKeyNames.has(varName)) {
|
||||
secretKeyNames.add(containerKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return secretKeyNames;
|
||||
}
|
||||
|
||||
export { parseEnvInterpolation } from './env-interpolation';
|
||||
|
||||
/**
|
||||
* Get count of environment variables for a stack.
|
||||
* @param stackName - Name of the stack
|
||||
|
||||
@@ -335,7 +335,8 @@ const REQUIRED_TABLES = [
|
||||
'audit_logs',
|
||||
'container_events',
|
||||
'schedule_executions',
|
||||
'user_preferences'
|
||||
'user_preferences',
|
||||
'api_tokens'
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -768,7 +769,8 @@ async function seedDatabase(): Promise<void> {
|
||||
license: ['manage'],
|
||||
audit_logs: ['view'],
|
||||
activity: ['view'],
|
||||
schedules: ['view']
|
||||
schedules: ['view', 'edit', 'run'],
|
||||
templates: ['view', 'deploy', 'manage']
|
||||
});
|
||||
|
||||
const operatorPermissions = JSON.stringify({
|
||||
@@ -787,7 +789,8 @@ async function seedDatabase(): Promise<void> {
|
||||
license: [],
|
||||
audit_logs: [],
|
||||
activity: ['view'],
|
||||
schedules: ['view']
|
||||
schedules: ['view', 'edit', 'run'],
|
||||
templates: ['view', 'deploy']
|
||||
});
|
||||
|
||||
const viewerPermissions = JSON.stringify({
|
||||
@@ -806,9 +809,31 @@ async function seedDatabase(): Promise<void> {
|
||||
license: [],
|
||||
audit_logs: [],
|
||||
activity: ['view'],
|
||||
schedules: ['view']
|
||||
schedules: ['view'],
|
||||
templates: ['view']
|
||||
});
|
||||
|
||||
// Seed template sources if table is empty
|
||||
const existingTemplateSources = await db.select().from(schema.templateSources);
|
||||
if (existingTemplateSources.length === 0) {
|
||||
// Inline defaults to avoid circular dependency (library.ts imports db/drizzle)
|
||||
const defaultSources = [
|
||||
{ sourceId: 'portainer-lissy93', name: 'Portainer templates (Lissy93)', url: 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json', enabled: true, builtin: true, sortOrder: 0 },
|
||||
{ sourceId: 'ntv-one', name: 'NTV-One (consolidated)', url: 'https://raw.githubusercontent.com/ntv-one/portainer/main/template.json', enabled: false, builtin: true, sortOrder: 1 },
|
||||
{ sourceId: 'mlva', name: 'MLVA (TheLustriVA)', url: 'https://raw.githubusercontent.com/TheLustriVA/portainer-templates-Nov-2022-collection/main/templates_2_2_rc_2_2.json', enabled: false, builtin: true, sortOrder: 2 },
|
||||
{ sourceId: 'selfhostedpro', name: 'SelfHostedPro', url: 'https://raw.githubusercontent.com/SelfhostedPro/selfhosted_templates/master/Template/portainer-v2.json', enabled: false, builtin: true, sortOrder: 3 },
|
||||
{ sourceId: 'portainer-qballjos', name: 'Qballjos (homelab)', url: 'https://raw.githubusercontent.com/Qballjos/portainer_templates/master/Template/template.json', enabled: false, builtin: true, sortOrder: 4 },
|
||||
{ sourceId: 'lsio-technorabilia', name: 'LinuxServer.io (Technorabilia)', url: 'https://raw.githubusercontent.com/technorabilia/portainer-templates/main/lsio/templates/templates.json', enabled: true, builtin: true, sortOrder: 5 },
|
||||
{ sourceId: 'mikestraney', name: 'MikeStraney', url: 'https://raw.githubusercontent.com/mikestraney/portainer-templates/master/templates.json', enabled: false, builtin: true, sortOrder: 6 },
|
||||
{ sourceId: 'pi-hosted-amd64', name: 'Pi-Hosted (amd64)', url: 'https://raw.githubusercontent.com/pi-hosted/pi-hosted/master/template/portainer-v2-amd64.json', enabled: false, builtin: true, sortOrder: 7 },
|
||||
{ sourceId: 'pi-hosted-arm64', name: 'Pi-Hosted (arm64)', url: 'https://raw.githubusercontent.com/pi-hosted/pi-hosted/master/template/portainer-v2-arm64.json', enabled: false, builtin: true, sortOrder: 8 },
|
||||
];
|
||||
for (const source of defaultSources) {
|
||||
await db.insert(schema.templateSources).values(source);
|
||||
}
|
||||
logStep('Created default template sources');
|
||||
}
|
||||
|
||||
const existingRoles = await db.select().from(schema.roles);
|
||||
if (existingRoles.length === 0) {
|
||||
await db.insert(schema.roles).values([
|
||||
@@ -898,6 +923,7 @@ export const userPreferences = schemaProxy.userPreferences;
|
||||
export const scheduleExecutions = schemaProxy.scheduleExecutions;
|
||||
export const stackEnvironmentVariables = schemaProxy.stackEnvironmentVariables;
|
||||
export const pendingContainerUpdates = schemaProxy.pendingContainerUpdates;
|
||||
export const apiTokens = schemaProxy.apiTokens;
|
||||
|
||||
// Re-export types from SQLite schema (they're compatible with PostgreSQL)
|
||||
export type {
|
||||
@@ -956,7 +982,9 @@ export type {
|
||||
StackEnvironmentVariable,
|
||||
NewStackEnvironmentVariable,
|
||||
PendingContainerUpdate,
|
||||
NewPendingContainerUpdate
|
||||
NewPendingContainerUpdate,
|
||||
ApiToken,
|
||||
NewApiToken
|
||||
} from './schema/index.js';
|
||||
|
||||
export { eq, and, or, desc, asc, like, sql, inArray, isNull, isNotNull } from 'drizzle-orm';
|
||||
|
||||
@@ -288,7 +288,7 @@ export const gitRepositories = sqliteTable('git_repositories', {
|
||||
url: text('url').notNull(),
|
||||
branch: text('branch').default('main'),
|
||||
credentialId: integer('credential_id').references(() => gitCredentials.id, { onDelete: 'set null' }),
|
||||
composePath: text('compose_path').default('compose.yaml'),
|
||||
composePath: text('compose_path').default('docker-compose.yml'), // Reverted to original value (#1110)
|
||||
environmentId: integer('environment_id'),
|
||||
autoUpdate: integer('auto_update', { mode: 'boolean' }).default(false),
|
||||
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
|
||||
@@ -308,17 +308,23 @@ export const gitStacks = sqliteTable('git_stacks', {
|
||||
stackName: text('stack_name').notNull(),
|
||||
environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }),
|
||||
repositoryId: integer('repository_id').notNull().references(() => gitRepositories.id, { onDelete: 'cascade' }),
|
||||
composePath: text('compose_path').default('compose.yaml'),
|
||||
composePath: text('compose_path').default('docker-compose.yml'), // Reverted to original value (#1110)
|
||||
envFilePath: text('env_file_path'), // Path to .env file in repository (e.g., ".env", "config/.env.prod")
|
||||
autoUpdate: integer('auto_update', { mode: 'boolean' }).default(false),
|
||||
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
|
||||
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
|
||||
webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).default(false),
|
||||
webhookSecret: text('webhook_secret'),
|
||||
contextDir: text('context_dir'), // Working directory relative to repo root (null = compose file's directory)
|
||||
buildOnDeploy: integer('build_on_deploy', { mode: 'boolean' }).default(false),
|
||||
noBuildCache: integer('no_build_cache', { mode: 'boolean' }).default(false),
|
||||
repullImages: integer('repull_images', { mode: 'boolean' }).default(false),
|
||||
forceRedeploy: integer('force_redeploy', { mode: 'boolean' }).default(false),
|
||||
lastSync: text('last_sync'),
|
||||
lastCommit: text('last_commit'),
|
||||
syncStatus: text('sync_status').default('pending'),
|
||||
syncError: text('sync_error'),
|
||||
syncedFiles: text('synced_files'), // JSON manifest { relativePath: sha256hex } of files written on last sync
|
||||
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
|
||||
}, (table) => ({
|
||||
@@ -464,6 +470,25 @@ export const pendingContainerUpdates = sqliteTable('pending_container_updates',
|
||||
envContainerUnique: unique().on(table.environmentId, table.containerId)
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// API TOKENS TABLE
|
||||
// =============================================================================
|
||||
|
||||
export const apiTokens = sqliteTable('api_tokens', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
tokenHash: text('token_hash').notNull().unique(),
|
||||
tokenPrefix: text('token_prefix').notNull(),
|
||||
lastUsed: text('last_used'),
|
||||
expiresAt: text('expires_at'),
|
||||
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
|
||||
}, (table) => ({
|
||||
userIdIdx: index('api_tokens_user_id_idx').on(table.userId),
|
||||
tokenPrefixIdx: index('api_tokens_token_prefix_idx').on(table.tokenPrefix)
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// USER PREFERENCES TABLE (unified key-value store)
|
||||
// =============================================================================
|
||||
@@ -480,6 +505,19 @@ export const userPreferences = sqliteTable('user_preferences', {
|
||||
unique().on(table.userId, table.environmentId, table.key)
|
||||
]);
|
||||
|
||||
// Template sources
|
||||
export const templateSources = sqliteTable('template_sources', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
sourceId: text('source_id').notNull().unique(), // stable identifier (e.g., 'portainer-lissy93')
|
||||
name: text('name').notNull(),
|
||||
url: text('url').notNull(),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).default(true),
|
||||
builtin: integer('builtin', { mode: 'boolean' }).default(false),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// TYPE EXPORTS
|
||||
// =============================================================================
|
||||
@@ -567,3 +605,6 @@ export type NewStackEnvironmentVariable = typeof stackEnvironmentVariables.$infe
|
||||
|
||||
export type PendingContainerUpdate = typeof pendingContainerUpdates.$inferSelect;
|
||||
export type NewPendingContainerUpdate = typeof pendingContainerUpdates.$inferInsert;
|
||||
|
||||
export type ApiToken = typeof apiTokens.$inferSelect;
|
||||
export type NewApiToken = typeof apiTokens.$inferInsert;
|
||||
|
||||
@@ -291,7 +291,7 @@ export const gitRepositories = pgTable('git_repositories', {
|
||||
url: text('url').notNull(),
|
||||
branch: text('branch').default('main'),
|
||||
credentialId: integer('credential_id').references(() => gitCredentials.id, { onDelete: 'set null' }),
|
||||
composePath: text('compose_path').default('compose.yaml'),
|
||||
composePath: text('compose_path').default('docker-compose.yml'), // Reverted to original value (#1110)
|
||||
environmentId: integer('environment_id'),
|
||||
autoUpdate: boolean('auto_update').default(false),
|
||||
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
|
||||
@@ -311,17 +311,23 @@ export const gitStacks = pgTable('git_stacks', {
|
||||
stackName: text('stack_name').notNull(),
|
||||
environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }),
|
||||
repositoryId: integer('repository_id').notNull().references(() => gitRepositories.id, { onDelete: 'cascade' }),
|
||||
composePath: text('compose_path').default('compose.yaml'),
|
||||
composePath: text('compose_path').default('docker-compose.yml'), // Reverted to original value (#1110)
|
||||
envFilePath: text('env_file_path'), // Path to .env file in repository (e.g., ".env", "config/.env.prod")
|
||||
autoUpdate: boolean('auto_update').default(false),
|
||||
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
|
||||
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
|
||||
webhookEnabled: boolean('webhook_enabled').default(false),
|
||||
webhookSecret: text('webhook_secret'),
|
||||
contextDir: text('context_dir'), // Working directory relative to repo root (null = compose file's directory)
|
||||
buildOnDeploy: boolean('build_on_deploy').default(false),
|
||||
noBuildCache: boolean('no_build_cache').default(false),
|
||||
repullImages: boolean('repull_images').default(false),
|
||||
forceRedeploy: boolean('force_redeploy').default(false),
|
||||
lastSync: timestamp('last_sync', { mode: 'string' }),
|
||||
lastCommit: text('last_commit'),
|
||||
syncStatus: text('sync_status').default('pending'),
|
||||
syncError: text('sync_error'),
|
||||
syncedFiles: text('synced_files'), // JSON manifest { relativePath: sha256hex } of files written on last sync
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
|
||||
}, (table) => ({
|
||||
@@ -467,6 +473,25 @@ export const pendingContainerUpdates = pgTable('pending_container_updates', {
|
||||
envContainerUnique: unique().on(table.environmentId, table.containerId)
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// API TOKENS TABLE
|
||||
// =============================================================================
|
||||
|
||||
export const apiTokens = pgTable('api_tokens', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
tokenHash: text('token_hash').notNull().unique(),
|
||||
tokenPrefix: text('token_prefix').notNull(),
|
||||
lastUsed: timestamp('last_used', { mode: 'string' }),
|
||||
expiresAt: timestamp('expires_at', { mode: 'string' }),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
|
||||
}, (table) => ({
|
||||
userIdIdx: index('api_tokens_user_id_idx').on(table.userId),
|
||||
tokenPrefixIdx: index('api_tokens_token_prefix_idx').on(table.tokenPrefix)
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// USER PREFERENCES TABLE (unified key-value store)
|
||||
// =============================================================================
|
||||
@@ -482,3 +507,16 @@ export const userPreferences = pgTable('user_preferences', {
|
||||
}, (table) => [
|
||||
unique().on(table.userId, table.environmentId, table.key)
|
||||
]);
|
||||
|
||||
// Template sources
|
||||
export const templateSources = pgTable('template_sources', {
|
||||
id: serial('id').primaryKey(),
|
||||
sourceId: text('source_id').notNull().unique(), // stable identifier (e.g., 'portainer-lissy93')
|
||||
name: text('name').notNull(),
|
||||
url: text('url').notNull(),
|
||||
enabled: boolean('enabled').default(true),
|
||||
builtin: boolean('builtin').default(false),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
|
||||
});
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { setGlobalDispatcher, Agent, EnvHttpProxyAgent } from 'undici';
|
||||
import dns from 'node:dns';
|
||||
import net from 'node:net';
|
||||
|
||||
const origLookup = dns.lookup.bind(dns);
|
||||
|
||||
// DNS cache: hostname → { address, family, expiresAt } (positive)
|
||||
// DNS negative cache: hostname → { error, expiresAt } (failed lookups)
|
||||
const dnsCache = new Map<string, { address: string; family: number; expiresAt: number }>();
|
||||
const dnsNegCache = new Map<string, { error: Error; expiresAt: number }>();
|
||||
const DNS_TTL_MS = 30_000;
|
||||
const DNS_NEG_TTL_MS = 10_000; // Cache failures for 10s to prevent DNS server storms
|
||||
|
||||
// In-flight deduplication: hostname → pending Promise<{address, family}>
|
||||
const inFlight = new Map<string, Promise<{ address: string; family: number }>>();
|
||||
|
||||
function lookupWithCache(hostname: string): Promise<{ address: string; family: number }> {
|
||||
// Positive cache hit
|
||||
const cached = dnsCache.get(hostname);
|
||||
if (cached) {
|
||||
if (cached.expiresAt > Date.now()) {
|
||||
return Promise.resolve({ address: cached.address, family: cached.family });
|
||||
}
|
||||
dnsCache.delete(hostname); // evict stale entry
|
||||
}
|
||||
|
||||
// Negative cache hit — don't hammer DNS for recently-failed hostnames
|
||||
const negCached = dnsNegCache.get(hostname);
|
||||
if (negCached) {
|
||||
if (negCached.expiresAt > Date.now()) {
|
||||
return Promise.reject(negCached.error);
|
||||
}
|
||||
dnsNegCache.delete(hostname);
|
||||
}
|
||||
|
||||
// In-flight deduplication
|
||||
const pending = inFlight.get(hostname);
|
||||
if (pending) return pending;
|
||||
|
||||
// Use getaddrinfo (libc) as primary — works through Docker's embedded DNS (127.0.0.11)
|
||||
// and respects --dns-result-order=ipv4first from entrypoint. This matches Bun's native
|
||||
// behavior which worked reliably on NAS environments where c-ares failed (#676).
|
||||
const promise = new Promise<{ address: string; family: number }>((resolve, reject) => {
|
||||
origLookup(hostname, { all: false }, (err, address, family) => {
|
||||
if (err) {
|
||||
// Cache the failure so parallel/subsequent requests don't all hammer DNS
|
||||
dnsNegCache.set(hostname, { error: err, expiresAt: Date.now() + DNS_NEG_TTL_MS });
|
||||
reject(err);
|
||||
} else {
|
||||
const result = { address: address as string, family: family as number };
|
||||
dnsCache.set(hostname, { ...result, expiresAt: Date.now() + DNS_TTL_MS });
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
}).finally(() => {
|
||||
inFlight.delete(hostname);
|
||||
});
|
||||
|
||||
inFlight.set(hostname, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
// Shared connect options for DNS lookup
|
||||
const connectOptions = {
|
||||
// Undici default is 10s. Increase to 30s for NAS environments with slow NAT/firewalls (#676).
|
||||
timeout: 30_000,
|
||||
lookup(hostname: string, opts: any, cb: any) {
|
||||
if (typeof opts === 'function') {
|
||||
cb = opts;
|
||||
opts = {};
|
||||
}
|
||||
|
||||
// IP addresses / localhost → no DNS needed
|
||||
if (net.isIP(hostname) || hostname === 'localhost') {
|
||||
return origLookup(hostname, opts, cb);
|
||||
}
|
||||
|
||||
lookupWithCache(hostname)
|
||||
.then(({ address, family }) => {
|
||||
if (opts.all) {
|
||||
cb(null, [{ address, family }]);
|
||||
} else {
|
||||
cb(null, address, family);
|
||||
}
|
||||
})
|
||||
.catch((err) => cb(err));
|
||||
}
|
||||
};
|
||||
|
||||
// Use EnvHttpProxyAgent when HTTP(S)_PROXY env vars are set, otherwise plain Agent.
|
||||
// Node.js fetch/undici does NOT respect proxy env vars by default — EnvHttpProxyAgent
|
||||
// reads HTTP_PROXY, HTTPS_PROXY, and NO_PROXY automatically.
|
||||
const hasProxy = process.env.HTTP_PROXY || process.env.HTTPS_PROXY ||
|
||||
process.env.http_proxy || process.env.https_proxy;
|
||||
|
||||
if (hasProxy) {
|
||||
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy ||
|
||||
process.env.HTTP_PROXY || process.env.http_proxy;
|
||||
console.log(`[DNS] HTTP proxy detected (${proxyUrl}), using EnvHttpProxyAgent`);
|
||||
setGlobalDispatcher(new EnvHttpProxyAgent({ connect: connectOptions }));
|
||||
} else {
|
||||
setGlobalDispatcher(new Agent({ connect: connectOptions }));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* Checks if a value contains path traversal or injection characters.
|
||||
* Rejects: .., /, \, null bytes, % (catches double-encoding).
|
||||
*/
|
||||
function containsPathTraversal(value: string): boolean {
|
||||
return value.includes('..') || value.includes('/') || value.includes('\\') || value.includes('\0') || value.includes('%');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Docker resource ID/name from URL params.
|
||||
* Returns a 400 Response if invalid, null if valid.
|
||||
*/
|
||||
export function validateDockerIdParam(id: string, resourceType = 'resource'): Response | null {
|
||||
if (!id || containsPathTraversal(id)) {
|
||||
return json({ error: `Invalid ${resourceType} ID` }, { status: 400 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { resolve } from 'path';
|
||||
import { existsSync, mkdirSync, writeFileSync, unlinkSync, readFileSync } from 'fs';
|
||||
|
||||
function getIconsDir(): string {
|
||||
const dataDir = process.env.DATA_DIR || './data';
|
||||
const dir = resolve(dataDir, 'icons');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function saveEnvironmentIcon(envId: number, base64Data: string): void {
|
||||
const dir = getIconsDir();
|
||||
// Strip data URL prefix if present
|
||||
const base64 = base64Data.replace(/^data:image\/\w+;base64,/, '');
|
||||
const buffer = Buffer.from(base64, 'base64');
|
||||
writeFileSync(resolve(dir, `env-${envId}.webp`), buffer);
|
||||
}
|
||||
|
||||
export function deleteEnvironmentIcon(envId: number): void {
|
||||
const dir = getIconsDir();
|
||||
const path = resolve(dir, `env-${envId}.webp`);
|
||||
if (existsSync(path)) {
|
||||
unlinkSync(path);
|
||||
}
|
||||
}
|
||||
|
||||
export function getEnvironmentIconBuffer(envId: number): Buffer | null {
|
||||
const dir = getIconsDir();
|
||||
const path = resolve(dir, `env-${envId}.webp`);
|
||||
if (!existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
return readFileSync(path);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Parse compose YAML to extract environment variable interpolation mappings.
|
||||
* Returns pairs of [containerEnvKey, interpolationVariable].
|
||||
*
|
||||
* Handles patterns:
|
||||
* - VAR=${ref}
|
||||
* - VAR=${ref:-default}
|
||||
* - VAR=${ref:+alt}
|
||||
* - VAR=${ref?error}
|
||||
*
|
||||
* Only extracts from `environment:` sections (list format: `- KEY=value`).
|
||||
*/
|
||||
export function parseEnvInterpolation(composeContent: string): Array<[string, string]> {
|
||||
const results: Array<[string, string]> = [];
|
||||
|
||||
// Step 1: Find lines matching `- ENV_KEY=...${...}...`
|
||||
const linePattern = /^\s*-\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)/gm;
|
||||
let lineMatch;
|
||||
while ((lineMatch = linePattern.exec(composeContent)) !== null) {
|
||||
const containerKey = lineMatch[1];
|
||||
const valueStr = lineMatch[2];
|
||||
|
||||
// Step 2: Extract all ${VAR} references from the value
|
||||
const varPattern = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?:[:\-\+\?][^}]*)?\}/g;
|
||||
let varMatch;
|
||||
while ((varMatch = varPattern.exec(valueStr)) !== null) {
|
||||
const varName = varMatch[1];
|
||||
// Only add if names differ — same-name case handled by direct key matching
|
||||
if (containerKey !== varName) {
|
||||
results.push([containerKey, varName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Parse .env file content into key-value pairs.
|
||||
* Preserves values exactly as written — no quote stripping.
|
||||
* Docker Compose handles its own quote interpretation at runtime.
|
||||
*/
|
||||
export function parseEnvVars(content: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex === -1) continue;
|
||||
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
const value = trimmed.substring(eqIndex + 1).trim();
|
||||
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||