diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 18d629a8..06915346 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,3 +31,9 @@ repos: language: script files: '\.go$' pass_filenames: false + - id: frontend-type-check + name: Frontend TypeScript Check + entry: bash -c 'cd frontend && npm run type-check' + language: system + files: '^frontend/.*\.(ts|tsx)$' + pass_filenames: false diff --git a/PROJECT_PLANNING.md b/PROJECT_PLANNING.md index 336bc21d..73a8528b 100644 --- a/PROJECT_PLANNING.md +++ b/PROJECT_PLANNING.md @@ -132,12 +132,14 @@ Implement the core proxy host creation and management. - [ ] Add WebSocket support toggle - [ ] Implement custom locations/paths - [ ] Add advanced options (headers, caching) +- [ ] Implement Docker/Podman container auto-discovery (via socket) **Acceptance Criteria**: - Can create basic proxy hosts - Hosts appear in list immediately - Changes reflect in Caddy config - Can proxy HTTP/HTTPS services successfully +- Can select local containers from a list --- @@ -790,6 +792,29 @@ Implement theme system beyond basic dark/light. --- +### 🔌 CONNECTIVITY & REMOTE ACCESS (Beta - Phase 6) + +#### Issue #41: Remote Server & VPN Integrations +**Priority**: `high` +**Labels**: `beta`, `feature`, `high`, `connectivity` +**Description**: +Integrate VPN and tunnel providers to securely proxy services from remote networks. + +**Tasks**: +- [ ] Implement Remote Server management system +- [ ] Add Tailscale integration (with Headscale support) +- [ ] Add ZeroTier integration +- [ ] Add Cloudflare Tunnel integration +- [ ] Implement connection health monitoring +- [ ] Create UI for managing remote providers +- [ ] Add "Use Custom Control Server" option for Headscale + +**Acceptance Criteria**: +- Can connect to remote networks via VPN/Tunnel +- Remote hosts available as proxy targets +- Headscale supported as Tailscale alternative +- Connection status visible in UI + ### 🔧 ADVANCED FEATURES (Post-Beta) #### Issue #33: API & CLI Tools @@ -1033,7 +1058,7 @@ Ensure CaddyProxyManager+ performs well under load. - Docker deployment - User authentication -### Beta (Issues #11-32) +### Beta (Issues #11-32, #41) **Goal**: Full security suite and monitoring **Target**: 4-6 months **Key Features**: @@ -1045,6 +1070,7 @@ Ensure CaddyProxyManager+ performs well under load. - DNS challenge (wildcard certs) - Enhanced logging & monitoring - GoAccess integration +- Remote Access (Tailscale/Headscale, ZeroTier) ### Post-Beta (Issues #33-36) **Goal**: Advanced features and enterprise capabilities diff --git a/README.md b/README.md index caff2fd9..ff35592c 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,22 @@ Go to: **http://localhost:8080** For more details, check out the [Docker Deployment Guide](DOCKER.md). +### 🔌 Connecting to Remote Servers (Optional) + +**Want to see containers on OTHER servers?** + +If you have apps running on a different computer (like a Raspberry Pi or a VPS) and want CPMP to see them automatically: + +1. **Copy** the `docker-compose.remote.yml` file to that *other* computer. +2. **Run it** there: `docker compose -f docker-compose.remote.yml up -d` +3. **Connect** in CPMP: + * Go to "Add Proxy Host" + * Click "Remote Docker?" + * Type the address: `tcp://:2375` + +**⚠️ IMPORTANT SECURITY WARNING:** +Think of this like leaving your front door unlocked. **ONLY** do this if your computers are connected via a secure VPN (like **Tailscale** or **WireGuard**) or are on a private home network that strangers can't access. Never do this on a public server without a VPN! + --- ## 🛠️ The Developer Way (If You Like Code) diff --git a/backend/go.mod b/backend/go.mod index ca175448..015afb93 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,6 +3,7 @@ module github.com/Wikid82/CaddyProxyManagerPlus/backend go 1.25.4 require ( + github.com/docker/docker v28.5.2+incompatible github.com/gin-gonic/gin v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 @@ -15,12 +16,22 @@ require ( ) require ( + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect @@ -33,14 +44,27 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/mod v0.29.0 // indirect @@ -48,7 +72,9 @@ require ( golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.38.0 // indirect google.golang.org/protobuf v1.36.9 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 80928459..18abba20 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,20 +1,45 @@ +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= -github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= -github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -34,6 +59,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -42,25 +69,51 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -74,6 +127,26 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= @@ -91,12 +164,21 @@ golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -106,3 +188,5 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/backend/internal/api/handlers/docker_handler.go b/backend/internal/api/handlers/docker_handler.go new file mode 100644 index 00000000..95db8cb7 --- /dev/null +++ b/backend/internal/api/handlers/docker_handler.go @@ -0,0 +1,31 @@ +package handlers + +import ( + "net/http" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services" + "github.com/gin-gonic/gin" +) + +type DockerHandler struct { + dockerService *services.DockerService +} + +func NewDockerHandler(dockerService *services.DockerService) *DockerHandler { + return &DockerHandler{dockerService: dockerService} +} + +func (h *DockerHandler) RegisterRoutes(r *gin.RouterGroup) { + r.GET("/docker/containers", h.ListContainers) +} + +func (h *DockerHandler) ListContainers(c *gin.Context) { + host := c.Query("host") + containers, err := h.dockerService.ListContainers(c.Request.Context(), host) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list containers: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, containers) +} diff --git a/backend/internal/api/handlers/docker_handler_test.go b/backend/internal/api/handlers/docker_handler_test.go new file mode 100644 index 00000000..36b2f5bd --- /dev/null +++ b/backend/internal/api/handlers/docker_handler_test.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestDockerHandler_ListContainers(t *testing.T) { + // We can't easily mock the DockerService without an interface, + // and the DockerService depends on the real Docker client. + // So we'll just test that the handler is wired up correctly, + // even if it returns an error because Docker isn't running in the test env. + + svc, _ := services.NewDockerService() + // svc might be nil if docker is not available, but NewDockerHandler handles nil? + // Actually NewDockerHandler just stores it. + // If svc is nil, ListContainers will panic. + // So we only run this if svc is not nil. + + if svc == nil { + t.Skip("Docker not available") + } + + h := NewDockerHandler(svc) + gin.SetMode(gin.TestMode) + r := gin.New() + h.RegisterRoutes(r.Group("/")) + + req, _ := http.NewRequest("GET", "/docker/containers", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // It might return 200 or 500 depending on if ListContainers succeeds + assert.Contains(t, []int{http.StatusOK, http.StatusInternalServerError}, w.Code) +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 216ad0cf..c4c8d7b2 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -93,6 +93,15 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { protected.POST("/notifications/:id/read", notificationHandler.MarkAsRead) protected.POST("/notifications/read-all", notificationHandler.MarkAllAsRead) + // Docker + dockerService, err := services.NewDockerService() + if err == nil { // Only register if Docker is available + dockerHandler := handlers.NewDockerHandler(dockerService) + dockerHandler.RegisterRoutes(protected) + } else { + fmt.Printf("Warning: Docker service unavailable: %v\n", err) + } + // Uptime Service uptimeService := services.NewUptimeService(db, notificationService) diff --git a/backend/internal/services/docker_service.go b/backend/internal/services/docker_service.go new file mode 100644 index 00000000..5a27cdb5 --- /dev/null +++ b/backend/internal/services/docker_service.go @@ -0,0 +1,102 @@ +package services + +import ( + "context" + "fmt" + "strings" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" +) + +type DockerPort struct { + PrivatePort uint16 `json:"private_port"` + PublicPort uint16 `json:"public_port"` + Type string `json:"type"` +} + +type DockerContainer struct { + ID string `json:"id"` + Names []string `json:"names"` + Image string `json:"image"` + State string `json:"state"` + Status string `json:"status"` + Network string `json:"network"` + IP string `json:"ip"` + Ports []DockerPort `json:"ports"` +} + +type DockerService struct { + client *client.Client +} + +func NewDockerService() (*DockerService, error) { + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return nil, fmt.Errorf("failed to create docker client: %w", err) + } + return &DockerService{client: cli}, nil +} + +func (s *DockerService) ListContainers(ctx context.Context, host string) ([]DockerContainer, error) { + var cli *client.Client + var err error + + if host == "" || host == "local" { + cli = s.client + } else { + cli, err = client.NewClientWithOpts(client.WithHost(host), client.WithAPIVersionNegotiation()) + if err != nil { + return nil, fmt.Errorf("failed to create remote client: %w", err) + } + defer cli.Close() + } + + containers, err := cli.ContainerList(ctx, container.ListOptions{All: false}) + if err != nil { + return nil, fmt.Errorf("failed to list containers: %w", err) + } + + var result []DockerContainer + for _, c := range containers { + // Get the first network's IP address if available + networkName := "" + ipAddress := "" + if c.NetworkSettings != nil && len(c.NetworkSettings.Networks) > 0 { + for name, net := range c.NetworkSettings.Networks { + networkName = name + ipAddress = net.IPAddress + break // Just take the first one for now + } + } + + // Clean up names (remove leading slash) + names := make([]string, len(c.Names)) + for i, name := range c.Names { + names[i] = strings.TrimPrefix(name, "/") + } + + // Map ports + var ports []DockerPort + for _, p := range c.Ports { + ports = append(ports, DockerPort{ + PrivatePort: p.PrivatePort, + PublicPort: p.PublicPort, + Type: p.Type, + }) + } + + result = append(result, DockerContainer{ + ID: c.ID[:12], // Short ID + Names: names, + Image: c.Image, + State: c.State, + Status: c.Status, + Network: networkName, + IP: ipAddress, + Ports: ports, + }) + } + + return result, nil +} diff --git a/backend/internal/services/docker_service_test.go b/backend/internal/services/docker_service_test.go new file mode 100644 index 00000000..4bc749f1 --- /dev/null +++ b/backend/internal/services/docker_service_test.go @@ -0,0 +1,38 @@ +package services + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDockerService_New(t *testing.T) { + // This test might fail if docker socket is not available in the build environment + // So we just check if it returns error or not, but don't fail the test if it's just "socket not found" + // In a real CI environment with Docker-in-Docker, this would work. + svc, err := NewDockerService() + if err != nil { + t.Logf("Skipping DockerService test: %v", err) + return + } + assert.NotNil(t, svc) +} + +func TestDockerService_ListContainers(t *testing.T) { + svc, err := NewDockerService() + if err != nil { + t.Logf("Skipping DockerService test: %v", err) + return + } + + // Test local listing + containers, err := svc.ListContainers(context.Background(), "") + // If we can't connect to docker daemon, this will fail. + // We should probably mock the client, but the docker client is an interface? + // The official client struct is concrete. + // For now, we just assert that if err is nil, containers is a slice. + if err == nil { + assert.IsType(t, []DockerContainer{}, containers) + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 7f582eb9..f423c57f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -19,3 +19,5 @@ services: - CPM_FRONTEND_DIR=/app/frontend/dist - CPM_CADDY_ADMIN_API=http://localhost:2019 - CPM_CADDY_CONFIG_DIR=/app/data/caddy + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro # For local container discovery diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 02b1de54..f6872b45 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -24,6 +24,7 @@ services: - cpm_data_local:/app/data - caddy_data_local:/data - caddy_config_local:/config + - /var/run/docker.sock:/var/run/docker.sock:ro # For local container discovery healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/v1/health"] interval: 30s diff --git a/docker-compose.remote.yml b/docker-compose.remote.yml new file mode 100644 index 00000000..ad91937e --- /dev/null +++ b/docker-compose.remote.yml @@ -0,0 +1,19 @@ +version: '3.9' + +services: + # Run this service on your REMOTE servers (not the one running CPMP) + # to allow CPMP to discover containers running there. + docker-socket-proxy: + image: alpine/socat + container_name: docker-socket-proxy + restart: unless-stopped + ports: + # Expose port 2375. + # ⚠️ SECURITY WARNING: Ensure this port is NOT accessible from the public internet! + # Use a VPN (Tailscale, WireGuard) or a private local network (LAN). + - "2375:2375" + volumes: + # Give the proxy access to the host's Docker socket + - /var/run/docker.sock:/var/run/docker.sock + # Forward TCP traffic from port 2375 to the internal Docker socket + command: tcp-listen:2375,fork,reuseaddr unix-connect:/var/run/docker.sock diff --git a/docker-compose.yml b/docker-compose.yml index 8e7a70f5..a365fc10 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,7 @@ services: - cpm_data:/app/data - caddy_data:/data - caddy_config:/config + - /var/run/docker.sock:/var/run/docker.sock:ro # For local container discovery # Mount your existing Caddyfile for automatic import (optional) # - ./my-existing-Caddyfile:/import/Caddyfile:ro healthcheck: diff --git a/frontend/package.json b/frontend/package.json index dd671bee..da0ea7da 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "build": "tsc -p tsconfig.build.json && vite build", + "type-check": "tsc --noEmit", "lint": "eslint . --report-unused-disable-directives", "preview": "vite preview", "test": "vitest", diff --git a/frontend/src/api/docker.ts b/frontend/src/api/docker.ts new file mode 100644 index 00000000..cf5e8634 --- /dev/null +++ b/frontend/src/api/docker.ts @@ -0,0 +1,26 @@ +import client from './client' + +export interface DockerPort { + private_port: number + public_port: number + type: string +} + +export interface DockerContainer { + id: string + names: string[] + image: string + state: string + status: string + network: string + ip: string + ports: DockerPort[] +} + +export const dockerApi = { + listContainers: async (host?: string): Promise => { + const params = host ? { host } : undefined + const response = await client.get('/docker/containers', { params }) + return response.data + }, +} diff --git a/frontend/src/api/import.ts b/frontend/src/api/import.ts index 6671b5a7..98c6a7da 100644 --- a/frontend/src/api/import.ts +++ b/frontend/src/api/import.ts @@ -11,7 +11,7 @@ export interface ImportPreview { session: ImportSession; preview: { hosts: Array<{ domain_names: string; [key: string]: unknown }>; - conflicts: Record; + conflicts: string[]; errors: string[]; }; } diff --git a/frontend/src/components/ProxyHostForm.tsx b/frontend/src/components/ProxyHostForm.tsx index 45c0974f..9b5e0551 100644 --- a/frontend/src/components/ProxyHostForm.tsx +++ b/frontend/src/components/ProxyHostForm.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import type { ProxyHost } from '../api/proxyHosts' import { useRemoteServers } from '../hooks/useRemoteServers' +import { useDocker } from '../hooks/useDocker' interface ProxyHostFormProps { host?: ProxyHost @@ -25,6 +26,9 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor }) const { servers: remoteServers } = useRemoteServers() + const [dockerHost, setDockerHost] = useState('') + const [showDockerHost, setShowDockerHost] = useState(false) + const { containers: dockerContainers, isLoading: dockerLoading, error: dockerError } = useDocker(dockerHost) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -54,6 +58,23 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor } } + const handleContainerSelect = (containerId: string) => { + const container = dockerContainers.find(c => c.id === containerId) + if (container) { + // Prefer internal IP if available, otherwise use container name + const host = container.ip || container.names[0] + // Use the first exposed port if available, otherwise default to 80 + const port = container.ports && container.ports.length > 0 ? container.ports[0].private_port : 80 + + setFormData({ + ...formData, + forward_host: host, + forward_port: port, + forward_scheme: 'http', + }) + } + } + return (
@@ -85,26 +106,75 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor />
- {/* Remote Server Quick Select */} - {remoteServers.length > 0 && ( +
+ {/* Remote Server Quick Select */} + {remoteServers.length > 0 && ( +
+ + +
+ )} + + {/* Docker Container Quick Select */}
- +
+ + +
+ + {showDockerHost && ( + setDockerHost(e.target.value)} + className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white text-sm mb-2 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + )} + + {dockerError && ( +

+ Failed to connect: {(dockerError as Error).message} +

+ )}
- )} +
{/* Forward Details */}
diff --git a/frontend/src/components/__tests__/ProxyHostForm.test.tsx b/frontend/src/components/__tests__/ProxyHostForm.test.tsx index 2396a349..3ad9322c 100644 --- a/frontend/src/components/__tests__/ProxyHostForm.test.tsx +++ b/frontend/src/components/__tests__/ProxyHostForm.test.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import ProxyHostForm from '../ProxyHostForm' import { mockRemoteServers } from '../../test/mockData' -// Mock the hook +// Mock the hooks vi.mock('../../hooks/useRemoteServers', () => ({ useRemoteServers: vi.fn(() => ({ servers: mockRemoteServers, @@ -16,6 +16,26 @@ vi.mock('../../hooks/useRemoteServers', () => ({ })), })) +vi.mock('../../hooks/useDocker', () => ({ + useDocker: vi.fn(() => ({ + containers: [ + { + id: 'container-123', + names: ['my-app'], + image: 'nginx:latest', + state: 'running', + status: 'Up 2 hours', + network: 'bridge', + ip: '172.17.0.2', + ports: [{ private_port: 80, public_port: 8080, type: 'tcp' }] + } + ], + isLoading: false, + error: null, + refetch: vi.fn(), + })), +})) + const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -65,6 +85,7 @@ describe('ProxyHostForm', () => { block_exploits: true, websocket_support: false, enabled: true, + locations: [], created_at: '2025-11-18T10:00:00Z', updated_at: '2025-11-18T10:00:00Z', } @@ -159,13 +180,29 @@ describe('ProxyHostForm', () => { expect(screen.getByText(/Local Docker Registry/)).toBeInTheDocument() }) - const select = screen.getByRole('combobox', { name: /quick select/i }) + const select = screen.getByLabelText('Quick Select: Remote Server') fireEvent.change(select, { target: { value: mockRemoteServers[0].uuid } }) expect(screen.getByDisplayValue(mockRemoteServers[0].host)).toBeInTheDocument() expect(screen.getByDisplayValue(mockRemoteServers[0].port)).toBeInTheDocument() }) + it('populates fields when a docker container is selected', async () => { + renderWithClient( + + ) + + await waitFor(() => { + expect(screen.getByLabelText('Quick Select: Container')).toBeInTheDocument() + }) + + const select = screen.getByLabelText('Quick Select: Container') + fireEvent.change(select, { target: { value: 'container-123' } }) + + expect(screen.getByDisplayValue('172.17.0.2')).toBeInTheDocument() // IP + expect(screen.getByDisplayValue('80')).toBeInTheDocument() // Port + }) + it('displays error message on submission failure', async () => { const mockErrorSubmit = vi.fn(() => Promise.reject(new Error('Submission failed'))) renderWithClient( @@ -199,4 +236,19 @@ describe('ProxyHostForm', () => { expect(advancedInput).toHaveValue('header_up X-Test "True"') }) + + it('allows entering a remote docker host', async () => { + renderWithClient( + + ) + + const toggle = screen.getByText('Remote Docker?') + fireEvent.click(toggle) + + const input = screen.getByPlaceholderText('tcp://100.x.y.z:2375') + expect(input).toBeInTheDocument() + + fireEvent.change(input, { target: { value: 'tcp://remote:2375' } }) + expect(input).toHaveValue('tcp://remote:2375') + }) }) diff --git a/frontend/src/hooks/__tests__/useImport.test.tsx b/frontend/src/hooks/__tests__/useImport.test.tsx index bd61ac17..55663f17 100644 --- a/frontend/src/hooks/__tests__/useImport.test.tsx +++ b/frontend/src/hooks/__tests__/useImport.test.tsx @@ -49,22 +49,26 @@ describe('useImport', () => { it('uploads content and creates session', async () => { const mockSession = { - uuid: 'session-1', - filename: 'Caddyfile', - state: 'reviewing', + id: 'session-1', + state: 'reviewing' as const, created_at: '2025-01-18T10:00:00Z', updated_at: '2025-01-18T10:00:00Z', } - const mockPreview = { - hosts: [{ domain: 'test.com' }], + const mockPreviewData = { + hosts: [{ domain_names: 'test.com' }], conflicts: [], errors: [], } - vi.mocked(api.uploadCaddyfile).mockResolvedValue({ session: mockSession }) + const mockResponse = { + session: mockSession, + preview: mockPreviewData, + } + + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession }) - vi.mocked(api.getImportPreview).mockResolvedValue(mockPreview) + vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) const { result } = renderHook(() => useImport(), { wrapper: createWrapper() }) @@ -103,20 +107,24 @@ describe('useImport', () => { it('commits import with resolutions', async () => { const mockSession = { - uuid: 'session-2', - filename: 'Caddyfile', - state: 'reviewing', + id: 'session-2', + state: 'reviewing' as const, created_at: '2025-01-18T10:00:00Z', updated_at: '2025-01-18T10:00:00Z', } + const mockResponse = { + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + } + let isCommitted = false - vi.mocked(api.uploadCaddyfile).mockResolvedValue({ session: mockSession }) + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) vi.mocked(api.getImportStatus).mockImplementation(async () => { if (isCommitted) return { has_pending: false } return { has_pending: true, session: mockSession } }) - vi.mocked(api.getImportPreview).mockResolvedValue({ hosts: [], conflicts: [], errors: [] }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) vi.mocked(api.commitImport).mockImplementation(async () => { isCommitted = true }) @@ -144,20 +152,24 @@ describe('useImport', () => { it('cancels active import session', async () => { const mockSession = { - uuid: 'session-3', - filename: 'Caddyfile', - state: 'reviewing', + id: 'session-3', + state: 'reviewing' as const, created_at: '2025-01-18T10:00:00Z', updated_at: '2025-01-18T10:00:00Z', } + const mockResponse = { + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + } + let isCancelled = false - vi.mocked(api.uploadCaddyfile).mockResolvedValue({ session: mockSession }) + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) vi.mocked(api.getImportStatus).mockImplementation(async () => { if (isCancelled) return { has_pending: false } return { has_pending: true, session: mockSession } }) - vi.mocked(api.getImportPreview).mockResolvedValue({ hosts: [], conflicts: [], errors: [] }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) vi.mocked(api.cancelImport).mockImplementation(async () => { isCancelled = true }) @@ -184,16 +196,20 @@ describe('useImport', () => { it('handles commit errors', async () => { const mockSession = { - uuid: 'session-4', - filename: 'Caddyfile', - state: 'reviewing', + id: 'session-4', + state: 'reviewing' as const, created_at: '2025-01-18T10:00:00Z', updated_at: '2025-01-18T10:00:00Z', } - vi.mocked(api.uploadCaddyfile).mockResolvedValue({ session: mockSession }) + const mockResponse = { + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + } + + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession }) - vi.mocked(api.getImportPreview).mockResolvedValue({ hosts: [], conflicts: [], errors: [] }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) const mockError = new Error('Commit failed') vi.mocked(api.commitImport).mockRejectedValue(mockError) diff --git a/frontend/src/hooks/__tests__/useProxyHosts.test.tsx b/frontend/src/hooks/__tests__/useProxyHosts.test.tsx index f453c4bd..5808a79a 100644 --- a/frontend/src/hooks/__tests__/useProxyHosts.test.tsx +++ b/frontend/src/hooks/__tests__/useProxyHosts.test.tsx @@ -13,6 +13,25 @@ vi.mock('../../api/proxyHosts', () => ({ deleteProxyHost: vi.fn(), })) +const createMockHost = (overrides: Partial = {}): api.ProxyHost => ({ + uuid: '1', + domain_names: 'test.com', + forward_scheme: 'http', + forward_host: 'localhost', + forward_port: 8080, + ssl_forced: false, + http2_support: false, + hsts_enabled: false, + hsts_subdomains: false, + block_exploits: false, + websocket_support: false, + locations: [], + enabled: true, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + ...overrides, +}) + const createWrapper = () => { const queryClient = new QueryClient({ defaultOptions: { @@ -37,8 +56,8 @@ describe('useProxyHosts', () => { it('loads proxy hosts on mount', async () => { const mockHosts = [ - { uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 }, - { uuid: '2', domain_names: 'app.com', enabled: true, forward_host: 'localhost', forward_port: 3000 }, + createMockHost({ uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 }), + createMockHost({ uuid: '2', domain_names: 'app.com', enabled: true, forward_host: 'localhost', forward_port: 3000 }), ] vi.mocked(api.getProxyHosts).mockResolvedValue(mockHosts) @@ -74,7 +93,7 @@ describe('useProxyHosts', () => { it('creates a new proxy host', async () => { vi.mocked(api.getProxyHosts).mockResolvedValue([]) const newHost = { domain_names: 'new.com', forward_host: 'localhost', forward_port: 9000 } - const createdHost = { uuid: '3', ...newHost, enabled: true } + const createdHost = createMockHost({ uuid: '3', ...newHost, enabled: true }) vi.mocked(api.createProxyHost).mockImplementation(async () => { vi.mocked(api.getProxyHosts).mockResolvedValue([createdHost]) @@ -98,12 +117,11 @@ describe('useProxyHosts', () => { }) it('updates an existing proxy host', async () => { - const existingHost = { uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 } + const existingHost = createMockHost({ uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 }) let hosts = [existingHost] vi.mocked(api.getProxyHosts).mockImplementation(() => Promise.resolve(hosts)) - const updatedHost = { ...existingHost, domain_names: 'updated.com' } - vi.mocked(api.updateProxyHost).mockImplementation(async (uuid, data) => { + vi.mocked(api.updateProxyHost).mockImplementation(async (_, data) => { hosts = [{ ...existingHost, ...data }] return hosts[0] }) @@ -126,8 +144,8 @@ describe('useProxyHosts', () => { it('deletes a proxy host', async () => { const hosts = [ - { uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 }, - { uuid: '2', domain_names: 'app.com', enabled: true, forward_host: 'localhost', forward_port: 3000 }, + createMockHost({ uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 }), + createMockHost({ uuid: '2', domain_names: 'app.com', enabled: true, forward_host: 'localhost', forward_port: 3000 }), ] vi.mocked(api.getProxyHosts).mockResolvedValue(hosts) vi.mocked(api.deleteProxyHost).mockImplementation(async (uuid) => { @@ -167,7 +185,7 @@ describe('useProxyHosts', () => { }) it('handles update errors', async () => { - const host = { uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 } + const host = createMockHost({ uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 }) vi.mocked(api.getProxyHosts).mockResolvedValue([host]) const mockError = new Error('Failed to update') vi.mocked(api.updateProxyHost).mockRejectedValue(mockError) @@ -182,7 +200,7 @@ describe('useProxyHosts', () => { }) it('handles delete errors', async () => { - const host = { uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 } + const host = createMockHost({ uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 }) vi.mocked(api.getProxyHosts).mockResolvedValue([host]) const mockError = new Error('Failed to delete') vi.mocked(api.deleteProxyHost).mockRejectedValue(mockError) diff --git a/frontend/src/hooks/__tests__/useRemoteServers.test.tsx b/frontend/src/hooks/__tests__/useRemoteServers.test.tsx index d1e4a08a..74ec43fa 100644 --- a/frontend/src/hooks/__tests__/useRemoteServers.test.tsx +++ b/frontend/src/hooks/__tests__/useRemoteServers.test.tsx @@ -14,6 +14,19 @@ vi.mock('../../api/remoteServers', () => ({ testRemoteServerConnection: vi.fn(), })) +const createMockServer = (overrides: Partial = {}): api.RemoteServer => ({ + uuid: '1', + name: 'Server 1', + provider: 'generic', + host: 'localhost', + port: 8080, + enabled: true, + reachable: true, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + ...overrides, +}) + const createWrapper = () => { const queryClient = new QueryClient({ defaultOptions: { @@ -38,8 +51,8 @@ describe('useRemoteServers', () => { it('loads all remote servers on mount', async () => { const mockServers = [ - { uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true }, - { uuid: '2', name: 'Server 2', host: '192.168.1.100', port: 3000, enabled: false }, + createMockServer({ uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true }), + createMockServer({ uuid: '2', name: 'Server 2', host: '192.168.1.100', port: 3000, enabled: false }), ] vi.mocked(api.getRemoteServers).mockResolvedValue(mockServers) @@ -75,7 +88,7 @@ describe('useRemoteServers', () => { it('creates a new remote server', async () => { vi.mocked(api.getRemoteServers).mockResolvedValue([]) const newServer = { name: 'New Server', host: 'new.local', port: 5000, provider: 'generic' } - const createdServer = { uuid: '4', ...newServer, enabled: true } + const createdServer = createMockServer({ uuid: '4', ...newServer, enabled: true }) vi.mocked(api.createRemoteServer).mockImplementation(async () => { vi.mocked(api.getRemoteServers).mockResolvedValue([createdServer]) @@ -99,12 +112,11 @@ describe('useRemoteServers', () => { }) it('updates an existing remote server', async () => { - const existingServer = { uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true } + const existingServer = createMockServer({ uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true }) let servers = [existingServer] vi.mocked(api.getRemoteServers).mockImplementation(() => Promise.resolve(servers)) - const updatedServer = { ...existingServer, name: 'Updated Server' } - vi.mocked(api.updateRemoteServer).mockImplementation(async (uuid, data) => { + vi.mocked(api.updateRemoteServer).mockImplementation(async (_, data) => { servers = [{ ...existingServer, ...data }] return servers[0] }) @@ -127,8 +139,8 @@ describe('useRemoteServers', () => { it('deletes a remote server', async () => { const servers = [ - { uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true }, - { uuid: '2', name: 'Server 2', host: '192.168.1.100', port: 3000, enabled: false }, + createMockServer({ uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true }), + createMockServer({ uuid: '2', name: 'Server 2', host: '192.168.1.100', port: 3000, enabled: false }), ] vi.mocked(api.getRemoteServers).mockResolvedValue(servers) vi.mocked(api.deleteRemoteServer).mockImplementation(async (uuid) => { @@ -185,7 +197,7 @@ describe('useRemoteServers', () => { }) it('handles update errors', async () => { - const server = { uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true } + const server = createMockServer({ uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true }) vi.mocked(api.getRemoteServers).mockResolvedValue([server]) const mockError = new Error('Failed to update') vi.mocked(api.updateRemoteServer).mockRejectedValue(mockError) @@ -196,11 +208,11 @@ describe('useRemoteServers', () => { expect(result.current.loading).toBe(false) }) - await expect(result.current.updateServer('1', { name: 'Updated' })).rejects.toThrow('Failed to update') + await expect(result.current.updateServer('1', { name: 'Updated Server' })).rejects.toThrow('Failed to update') }) it('handles delete errors', async () => { - const server = { uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true } + const server = createMockServer({ uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true }) vi.mocked(api.getRemoteServers).mockResolvedValue([server]) const mockError = new Error('Failed to delete') vi.mocked(api.deleteRemoteServer).mockRejectedValue(mockError) diff --git a/frontend/src/hooks/useDocker.ts b/frontend/src/hooks/useDocker.ts new file mode 100644 index 00000000..eb315ee3 --- /dev/null +++ b/frontend/src/hooks/useDocker.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query' +import { dockerApi } from '../api/docker' + +export function useDocker(host?: string) { + const { + data: containers = [], + isLoading, + error, + refetch, + } = useQuery({ + queryKey: ['docker-containers', host], + queryFn: () => dockerApi.listContainers(host), + retry: 1, // Don't retry too much if docker is not available + }) + + return { + containers, + isLoading, + error, + refetch, + } +} diff --git a/frontend/src/pages/ImportCaddy.tsx b/frontend/src/pages/ImportCaddy.tsx index 29ac144b..b29a4d4d 100644 --- a/frontend/src/pages/ImportCaddy.tsx +++ b/frontend/src/pages/ImportCaddy.tsx @@ -134,7 +134,7 @@ api.example.com { {showReview && preview && preview.preview && ( setShowReview(false)} diff --git a/frontend/src/test/mockData.ts b/frontend/src/test/mockData.ts index 8f305a01..82a399f2 100644 --- a/frontend/src/test/mockData.ts +++ b/frontend/src/test/mockData.ts @@ -8,14 +8,13 @@ export const mockProxyHosts: ProxyHost[] = [ forward_scheme: 'http', forward_host: 'localhost', forward_port: 3000, - access_list_id: undefined, - certificate_id: undefined, ssl_forced: false, http2_support: true, hsts_enabled: false, hsts_subdomains: false, block_exploits: true, websocket_support: true, + locations: [], advanced_config: undefined, enabled: true, created_at: '2025-11-18T10:00:00Z', @@ -27,14 +26,13 @@ export const mockProxyHosts: ProxyHost[] = [ forward_scheme: 'http', forward_host: '192.168.1.100', forward_port: 8080, - access_list_id: undefined, - certificate_id: undefined, ssl_forced: false, http2_support: true, hsts_enabled: false, hsts_subdomains: false, block_exploits: true, websocket_support: false, + locations: [], advanced_config: undefined, enabled: true, created_at: '2025-11-18T10:00:00Z', diff --git a/frontend/tsconfig.build.json b/frontend/tsconfig.build.json new file mode 100644 index 00000000..65f8b106 --- /dev/null +++ b/frontend/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index c6c32081..13e84906 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -18,9 +18,9 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "types": ["vitest/globals", "@testing-library/jest-dom"] }, "include": ["src"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"], "references": [{ "path": "./tsconfig.node.json" }] }