Compare commits

..

42 Commits

Author SHA1 Message Date
Lokowitz
873408270e removed unused gomod code 2026-01-28 15:06:23 -08:00
Lokowitz
8fec8f35bc removed unused code 2026-01-28 15:06:23 -08:00
Owen
141c846fe2 Properly insert PANGOLIN_SETUP_TOKEN into db
Fixes #2361
2026-01-28 15:04:17 -08:00
Lokowitz
1497469016 revert format:write 2026-01-28 14:50:42 -08:00
Lokowitz
e356a6d33b fix lable error and make dockerfile readable 2026-01-28 14:50:42 -08:00
miloschwartz
12aea2901d fix depreated zod warning 2026-01-26 14:11:03 -08:00
miloschwartz
5ff56467ea error response improvements to logo url 2026-01-26 14:00:22 -08:00
miloschwartz
3a8718a4b0 remove archive confirmtion on account devices dialog 2026-01-26 13:36:25 -08:00
Owen
37c4a7b690 Retry verify 2026-01-24 11:55:32 -08:00
Owen
b735e7c34d Fix #2314 2026-01-24 11:47:17 -08:00
Owen
5f85c3b3b8 Remove extra rebuild command 2026-01-24 11:35:45 -08:00
miloschwartz
5d9cb9fa21 fix clear olmId from client on archive 2026-01-24 11:11:25 -08:00
miloschwartz
643d56958d fix saas private import 2026-01-23 10:07:05 -08:00
miloschwartz
f378d6f040 fix input border 2026-01-22 21:24:28 -08:00
Milo Schwartz
bb57794388 Merge pull request #2306 from Fredkiss3/fix/tab-from-host-port
fix: tab between host & port in resource target address column
2026-01-22 21:14:20 -08:00
miloschwartz
a9ca49b8a2 Merge branch 'main' into dev 2026-01-22 21:10:40 -08:00
Fred KISSIE
c1b473294e 🔥 remove useless useEffect 2026-01-23 04:54:24 +01:00
Fred KISSIE
e3e4bdfe09 🚸 fix target item tabbing by memoizing the getColumns (and its dependencies) 2026-01-23 04:40:19 +01:00
miloschwartz
bfbeace2e2 fix import in list approvals 2026-01-22 17:54:53 -08:00
miloschwartz
efcf46ce8a fix policy check on olm register 2026-01-22 16:28:15 -08:00
miloschwartz
2085715965 fix wrong redirect url when idp login with custom auth domain 2026-01-22 15:46:48 -08:00
Owen
d227db7b7b Show the source in the UI 2026-01-22 15:18:27 -08:00
Owen
2af67ad355 Fix the source of the cli blueprint 2026-01-22 15:18:27 -08:00
miloschwartz
f100854423 add ios and android to readme 2026-01-22 15:18:27 -08:00
miloschwartz
92331d7a33 clean up paid features check 2026-01-22 15:18:27 -08:00
Owen
9a5bcb9099 Hiring 2026-01-22 15:18:27 -08:00
miloschwartz
8eb6bb2a95 dont include posture in repsonse if not licensed or subscribed 2026-01-22 15:18:27 -08:00
miloschwartz
2aa65ccab3 add mobile links to download banner 2026-01-22 15:18:27 -08:00
miloschwartz
be1577a3e7 remove biometric support from ios 2026-01-22 15:18:27 -08:00
miloschwartz
c8e1b3bf29 rename windowsDefenderEnabled 2026-01-22 15:18:27 -08:00
Owen
e17b986628 Dont show bio info on android 2026-01-22 15:18:27 -08:00
Owen
5f19918ca0 Show the source in the UI 2026-01-22 15:16:41 -08:00
Owen
2959ad0e70 Fix the source of the cli blueprint 2026-01-22 15:03:04 -08:00
miloschwartz
a76eec7bb7 add ios and android to readme 2026-01-22 11:27:24 -08:00
miloschwartz
068b2a0dcd clean up paid features check 2026-01-22 11:16:27 -08:00
Owen
316b7e5653 Hiring 2026-01-22 10:38:32 -08:00
miloschwartz
00fc1da33c dont include posture in repsonse if not licensed or subscribed 2026-01-22 10:36:52 -08:00
miloschwartz
9ef93df54f add mobile links to download banner 2026-01-21 18:16:16 -08:00
miloschwartz
fd9fdf6399 remove biometric support from ios 2026-01-21 18:13:12 -08:00
miloschwartz
8fa1701e06 rename windowsDefenderEnabled 2026-01-21 17:57:20 -08:00
Owen
4abe83f8a9 Dont show bio info on android 2026-01-21 16:36:35 -08:00
Owen
0a7564acb6 Fix not detecting rc release in sign and package 2026-01-21 16:14:00 -08:00
62 changed files with 928 additions and 3442 deletions

View File

@@ -44,19 +44,9 @@ updates:
schedule:
interval: "daily"
groups:
dev-patch-updates:
dependency-type: "development"
patch-updates:
update-types:
- "patch"
dev-minor-updates:
dependency-type: "development"
minor-updates:
update-types:
- "minor"
prod-patch-updates:
dependency-type: "production"
update-types:
- "patch"
prod-minor-updates:
dependency-type: "production"
update-types:
- "minor"

View File

@@ -339,37 +339,37 @@ jobs:
TAG=${{ env.TAG }}
MAJOR_TAG=$(echo $TAG | cut -d. -f1)
MINOR_TAG=$(echo $TAG | cut -d. -f1,2)
echo "Waiting for multi-arch manifests to be ready..."
sleep 30
# Determine if this is an RC release
IS_RC="false"
if echo "$TAG" | grep -qE "rc[0-9]+$"; then
if [[ "$TAG" == *"-rc."* ]]; then
IS_RC="true"
fi
if [ "$IS_RC" = "true" ]; then
echo "RC release detected - copying version-specific tags only"
# SQLite OSS
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}"
skopeo copy --all --retry-times 3 \
docker://$DOCKERHUB_IMAGE:$TAG \
docker://$GHCR_IMAGE:$TAG
# PostgreSQL OSS
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:postgresql-${TAG} -> ${{ env.GHCR_IMAGE }}:postgresql-${TAG}"
skopeo copy --all --retry-times 3 \
docker://$DOCKERHUB_IMAGE:postgresql-$TAG \
docker://$GHCR_IMAGE:postgresql-$TAG
# SQLite Enterprise
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-${TAG} -> ${{ env.GHCR_IMAGE }}:ee-${TAG}"
skopeo copy --all --retry-times 3 \
docker://$DOCKERHUB_IMAGE:ee-$TAG \
docker://$GHCR_IMAGE:ee-$TAG
# PostgreSQL Enterprise
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-postgresql-${TAG} -> ${{ env.GHCR_IMAGE }}:ee-postgresql-${TAG}"
skopeo copy --all --retry-times 3 \
@@ -377,7 +377,7 @@ jobs:
docker://$GHCR_IMAGE:ee-postgresql-$TAG
else
echo "Regular release detected - copying all tags (latest, major, minor, full version)"
# SQLite OSS - all tags
for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:${TAG_SUFFIX}"
@@ -385,7 +385,7 @@ jobs:
docker://$DOCKERHUB_IMAGE:$TAG_SUFFIX \
docker://$GHCR_IMAGE:$TAG_SUFFIX
done
# PostgreSQL OSS - all tags
for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:postgresql-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:postgresql-${TAG_SUFFIX}"
@@ -393,7 +393,7 @@ jobs:
docker://$DOCKERHUB_IMAGE:postgresql-$TAG_SUFFIX \
docker://$GHCR_IMAGE:postgresql-$TAG_SUFFIX
done
# SQLite Enterprise - all tags
for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:ee-${TAG_SUFFIX}"
@@ -401,7 +401,7 @@ jobs:
docker://$DOCKERHUB_IMAGE:ee-$TAG_SUFFIX \
docker://$GHCR_IMAGE:ee-$TAG_SUFFIX
done
# PostgreSQL Enterprise - all tags
for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-postgresql-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:ee-postgresql-${TAG_SUFFIX}"
@@ -410,7 +410,7 @@ jobs:
docker://$GHCR_IMAGE:ee-postgresql-$TAG_SUFFIX
done
fi
echo "All images copied successfully to GHCR!"
shell: bash
@@ -442,7 +442,7 @@ jobs:
# Determine if this is an RC release
IS_RC="false"
if echo "$TAG" | grep -qE "rc[0-9]+$"; then
if [[ "$TAG" == *"-rc."* ]]; then
IS_RC="true"
fi
@@ -482,19 +482,82 @@ jobs:
echo "==> cosign sign (key) --recursive ${REF}"
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
# Retry wrapper for verification to handle registry propagation delays
retry_verify() {
local cmd="$1"
local attempts=6
local delay=5
local i=1
until eval "$cmd"; do
if [ $i -ge $attempts ]; then
echo "Verification failed after $attempts attempts"
return 1
fi
echo "Verification not yet available. Retry $i/$attempts after ${delay}s..."
sleep $delay
i=$((i+1))
delay=$((delay*2))
# Cap the delay to avoid very long waits
if [ $delay -gt 60 ]; then delay=60; fi
done
return 0
}
echo "==> cosign verify (public key) ${REF}"
cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then
VERIFIED_INDEX=true
else
VERIFIED_INDEX=false
fi
echo "==> cosign verify (keyless policy) ${REF}"
cosign verify \
--certificate-oidc-issuer "${issuer}" \
--certificate-identity-regexp "${id_regex}" \
"${REF}" -o text
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then
VERIFIED_INDEX_KEYLESS=true
else
VERIFIED_INDEX_KEYLESS=false
fi
# If index verification fails, attempt to verify child platform manifests
if [ "${VERIFIED_INDEX}" != "true" ] || [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
echo "Index verification not available; attempting child manifest verification for ${BASE_IMAGE}:${IMAGE_TAG}"
CHILD_VERIFIED=false
for ARCH in arm64 amd64; do
CHILD_TAG="${IMAGE_TAG}-${ARCH}"
echo "Resolving child digest for ${BASE_IMAGE}:${CHILD_TAG}"
CHILD_DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${CHILD_TAG} | jq -r '.Digest' || true)"
if [ -n "${CHILD_DIGEST}" ] && [ "${CHILD_DIGEST}" != "null" ]; then
CHILD_REF="${BASE_IMAGE}@${CHILD_DIGEST}"
echo "==> cosign verify (public key) child ${CHILD_REF}"
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${CHILD_REF}' -o text"; then
CHILD_VERIFIED=true
echo "Public key verification succeeded for child ${CHILD_REF}"
else
echo "Public key verification failed for child ${CHILD_REF}"
fi
echo "==> cosign verify (keyless policy) child ${CHILD_REF}"
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${CHILD_REF}' -o text"; then
CHILD_VERIFIED=true
echo "Keyless verification succeeded for child ${CHILD_REF}"
else
echo "Keyless verification failed for child ${CHILD_REF}"
fi
else
echo "No child digest found for ${BASE_IMAGE}:${CHILD_TAG}; skipping"
fi
done
if [ "${CHILD_VERIFIED}" != "true" ]; then
echo "Failed to verify index and no child manifests verified for ${BASE_IMAGE}:${IMAGE_TAG}"
exit 10
fi
fi
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
done
done
echo "All images signed and verified successfully!"
shell: bash

View File

@@ -4,13 +4,13 @@
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "vscode.json-language-features"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
@@ -19,4 +19,4 @@
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.formatOnSave": true
}
}

View File

@@ -1,21 +1,11 @@
FROM node:24-alpine AS builder
# OCI Image Labels - Build Args for dynamic values
ARG VERSION="dev"
ARG REVISION=""
ARG CREATED=""
ARG LICENSE="AGPL-3.0"
WORKDIR /app
ARG BUILD=oss
ARG DATABASE=sqlite
# Derive title and description based on BUILD type
ARG IMAGE_TITLE="Pangolin"
ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere"
RUN apk add --no-cache curl tzdata python3 make g++
RUN apk add --no-cache python3 make g++
# COPY package.json package-lock.json ./
COPY package*.json ./
@@ -23,41 +13,31 @@ RUN npm ci
COPY . .
RUN echo "export * from \"./$DATABASE\";" > server/db/index.ts
RUN echo "export const driver: \"pg\" | \"sqlite\" = \"$DATABASE\";" >> server/db/index.ts
RUN echo "export const build = \"$BUILD\" as \"saas\" | \"enterprise\" | \"oss\";" > server/build.ts
# Copy the appropriate TypeScript configuration based on build type
RUN if [ "$BUILD" = "oss" ]; then cp tsconfig.oss.json tsconfig.json; \
elif [ "$BUILD" = "saas" ]; then cp tsconfig.saas.json tsconfig.json; \
elif [ "$BUILD" = "enterprise" ]; then cp tsconfig.enterprise.json tsconfig.json; \
fi
# if the build is oss then remove the server/private directory
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi
RUN if [ "$DATABASE" = "pg" ]; then npx drizzle-kit generate --dialect postgresql --schema ./server/db/pg/schema --out init; else npx drizzle-kit generate --dialect $DATABASE --schema ./server/db/$DATABASE/schema --out init; fi
RUN mkdir -p dist
RUN npm run next:build
RUN node esbuild.mjs -e server/index.ts -o dist/server.mjs -b $BUILD
RUN if [ "$DATABASE" = "pg" ]; then \
node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs; \
else \
node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs; \
fi
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \
npm run set:$DATABASE && \
npm run set:$BUILD && \
npm run db:$DATABASE:generate && \
npm run build:$DATABASE && \
npm run build:cli
# test to make sure the build output is there and error if not
RUN test -f dist/server.mjs
RUN npm run build:cli
# Prune dev dependencies and clean up to prepare for copy to runner
RUN npm prune --omit=dev && npm cache clean --force
FROM node:24-alpine AS runner
# OCI Image Labels - Build Args for dynamic values
ARG VERSION="dev"
ARG REVISION=""
ARG CREATED=""
ARG LICENSE="AGPL-3.0"
# Derive title and description based on BUILD type
ARG IMAGE_TITLE="Pangolin"
ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere"
WORKDIR /app
# Only curl and tzdata needed at runtime - no build tools!
@@ -66,11 +46,10 @@ RUN apk add --no-cache curl tzdata
# Copy pre-built node_modules from builder (already pruned to production only)
# This includes the compiled native modules like better-sqlite3
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/init ./dist/init
COPY --from=builder /app/server/migrations ./dist/init
COPY --from=builder /app/package.json ./package.json
COPY ./cli/wrapper.sh /usr/local/bin/pangctl

View File

@@ -35,6 +35,12 @@
</div>
<p align="center">
<a href="https://docs.pangolin.net/careers/join-us">
<img src="https://img.shields.io/badge/🚀_We're_Hiring!-Join_Our_Team-brightgreen?style=for-the-badge" alt="We're Hiring!" />
</a>
</p>
<p align="center">
<strong>
Start testing Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
@@ -74,6 +80,8 @@ Download the Pangolin client for your platform:
- [Mac](https://pangolin.net/downloads/mac)
- [Windows](https://pangolin.net/downloads/windows)
- [Linux](https://pangolin.net/downloads/linux)
- [iOS](https://pangolin.net/downloads/ios)
- [Android](https://pangolin.net/downloads/android)
## Get Started

View File

@@ -1,72 +0,0 @@
import requests
import yaml
import json
import base64
# The file path for the YAML file to be read
# You can change this to the path of your YAML file
YAML_FILE_PATH = 'blueprint.yaml'
# The API endpoint and headers from the curl request
API_URL = 'http://api.pangolin.net/v1/org/test/blueprint'
HEADERS = {
'accept': '*/*',
'Authorization': 'Bearer <your_token_here>',
'Content-Type': 'application/json'
}
def convert_and_send(file_path, url, headers):
"""
Reads a YAML file, converts its content to a JSON payload,
and sends it via a PUT request to a specified URL.
"""
try:
# Read the YAML file content
with open(file_path, 'r') as file:
yaml_content = file.read()
# Parse the YAML string to a Python dictionary
# This will be used to ensure the YAML is valid before sending
parsed_yaml = yaml.safe_load(yaml_content)
# convert the parsed YAML to a JSON string
json_payload = json.dumps(parsed_yaml)
print("Converted JSON payload:")
print(json_payload)
# Encode the JSON string to Base64
encoded_json = base64.b64encode(json_payload.encode('utf-8')).decode('utf-8')
# Create the final payload with the base64 encoded data
final_payload = {
"blueprint": encoded_json
}
print("Sending the following Base64 encoded JSON payload:")
print(final_payload)
print("-" * 20)
# Make the PUT request with the base64 encoded payload
response = requests.put(url, headers=headers, json=final_payload)
# Print the API response for debugging
print(f"API Response Status Code: {response.status_code}")
print("API Response Content:")
print(response.text)
# Raise an exception for bad status codes (4xx or 5xx)
response.raise_for_status()
except FileNotFoundError:
print(f"Error: The file '{file_path}' was not found.")
except yaml.YAMLError as e:
print(f"Error parsing YAML file: {e}")
except requests.exceptions.RequestException as e:
print(f"An error occurred during the API request: {e}")
except Exception as e:
print(f"An unexpected error occurred: {e}")
# Run the function
if __name__ == "__main__":
convert_and_send(YAML_FILE_PATH, API_URL, HEADERS)

View File

@@ -1,70 +0,0 @@
client-resources:
client-resource-nice-id-uno:
name: this is my resource
protocol: tcp
proxy-port: 3001
hostname: localhost
internal-port: 3000
site: lively-yosemite-toad
client-resource-nice-id-duce:
name: this is my resource
protocol: udp
proxy-port: 3000
hostname: localhost
internal-port: 3000
site: lively-yosemite-toad
proxy-resources:
resource-nice-id-uno:
name: this is my resource
protocol: http
full-domain: duce.test.example.com
host-header: example.com
tls-server-name: example.com
# auth:
# pincode: 123456
# password: sadfasdfadsf
# sso-enabled: true
# sso-roles:
# - Member
# sso-users:
# - owen@pangolin.net
# whitelist-users:
# - owen@pangolin.net
# auto-login-idp: 1
headers:
- name: X-Example-Header
value: example-value
- name: X-Another-Header
value: another-value
rules:
- action: allow
match: ip
value: 1.1.1.1
- action: deny
match: cidr
value: 2.2.2.2/32
- action: pass
match: path
value: /admin
targets:
- site: lively-yosemite-toad
path: /path
pathMatchType: prefix
hostname: localhost
method: http
port: 8000
- site: slim-alpine-chipmunk
hostname: localhost
path: /yoman
pathMatchType: exact
method: http
port: 8001
resource-nice-id-duce:
name: this is other resource
protocol: tcp
proxy-port: 3000
targets:
- site: lively-yosemite-toad
hostname: localhost
port: 3000

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Активирана защитна стена.",
"autoUpdatesEnabled": "Активирани автоматични актуализации.",
"tpmAvailable": "TPM е на разположение.",
"windowsAntivirusEnabled": "Antivirus Enabled",
"macosSipEnabled": "Protection на системната цялост (SIP).",
"macosGatekeeperEnabled": "Gatekeeper.",
"macosFirewallStealthMode": "Скрит режим на защитната стена.",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Firewall povolen",
"autoUpdatesEnabled": "Automatické aktualizace povoleny",
"tpmAvailable": "TPM k dispozici",
"windowsAntivirusEnabled": "Antivirus Enabled",
"macosSipEnabled": "Ochrana systémové integrity (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Režim neviditelnosti firewallu",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Firewall aktiviert",
"autoUpdatesEnabled": "Automatische Updates aktiviert",
"tpmAvailable": "TPM verfügbar",
"windowsAntivirusEnabled": "Antivirus Enabled",
"macosSipEnabled": "Schutz der Systemintegrität (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Firewall Stealth-Modus",

View File

@@ -2510,7 +2510,7 @@
"firewallEnabled": "Firewall Enabled",
"autoUpdatesEnabled": "Auto Updates Enabled",
"tpmAvailable": "TPM Available",
"windowsDefenderEnabled": "Windows Defender Enabled",
"windowsAntivirusEnabled": "Antivirus Enabled",
"macosSipEnabled": "System Integrity Protection (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Firewall Stealth Mode",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Cortafuegos activado",
"autoUpdatesEnabled": "Actualizaciones automáticas habilitadas",
"tpmAvailable": "TPM disponible",
"windowsAntivirusEnabled": "Antivirus Enabled",
"macosSipEnabled": "Protección de integridad del sistema (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Modo Sigilo Firewall",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Pare-feu activé",
"autoUpdatesEnabled": "Mises à jour automatiques activées",
"tpmAvailable": "TPM disponible",
"windowsAntivirusEnabled": "Antivirus Enabled",
"macosSipEnabled": "Protection contre l'intégrité du système (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Mode furtif du pare-feu",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Firewall Abilitato",
"autoUpdatesEnabled": "Aggiornamenti Automatici Abilitati",
"tpmAvailable": "TPM Disponibile",
"windowsAntivirusEnabled": "Antivirus Enabled",
"macosSipEnabled": "Protezione Dell'Integrità Del Sistema (Sip)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Modo Furtivo Del Firewall",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "방화벽 활성화",
"autoUpdatesEnabled": "자동 업데이트 활성화",
"tpmAvailable": "TPM 사용 가능",
"windowsAntivirusEnabled": "Antivirus Enabled",
"macosSipEnabled": "시스템 무결성 보호 (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "방화벽 스텔스 모드",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Brannmur aktivert",
"autoUpdatesEnabled": "Automatiske oppdateringer aktivert",
"tpmAvailable": "TPM tilgjengelig",
"windowsAntivirusEnabled": "Antivirus Enabled",
"macosSipEnabled": "System Integritetsbeskyttelse (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Brannmur Usynlig Modus",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Firewall ingeschakeld",
"autoUpdatesEnabled": "Auto Updates Ingeschakeld",
"tpmAvailable": "TPM beschikbaar",
"windowsAntivirusEnabled": "Antivirus Enabled",
"macosSipEnabled": "Systeemintegriteitsbescherming (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Firewall Verberg Modus",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Zapora włączona",
"autoUpdatesEnabled": "Automatyczne aktualizacje włączone",
"tpmAvailable": "TPM dostępne",
"windowsAntivirusEnabled": "Antivirus Enabled",
"macosSipEnabled": "Ochrona integralności systemu (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Tryb Stealth zapory",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Firewall habilitado",
"autoUpdatesEnabled": "Atualizações Automáticas Habilitadas",
"tpmAvailable": "TPM disponível",
"windowsAntivirusEnabled": "Antivirus Enabled",
"macosSipEnabled": "Proteção da Integridade do Sistema (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Modo Furtivo do Firewall",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Брандмауэр включен",
"autoUpdatesEnabled": "Автоматические обновления включены",
"tpmAvailable": "Доступно TPM",
"windowsAntivirusEnabled": "Antivirus Enabled",
"macosSipEnabled": "Защита целостности системы (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Стилс-режим брандмауэра",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Güvenlik Duvarı Etkin",
"autoUpdatesEnabled": "Otomatik Güncellemeler Etkin",
"tpmAvailable": "TPM Mevcut",
"windowsAntivirusEnabled": "Antivirus Enabled",
"macosSipEnabled": "Sistem Bütünlüğü Koruması (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Güvenlik Duvarı Gizlilik Modu",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "防火墙已启用",
"autoUpdatesEnabled": "启用自动更新",
"tpmAvailable": "TPM 可用",
"windowsAntivirusEnabled": "Antivirus Enabled",
"macosSipEnabled": "系统完整性保护 (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "防火墙隐形模式",

2194
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,8 @@
"license": "SEE LICENSE IN LICENSE AND README.md",
"scripts": {
"dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
"dev:check": "npx tsc --noEmit && npm run format:check",
"dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:sqlite:generate && npm run db:sqlite:push",
"db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts",
"db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts",
"db:pg:push": "npx tsx server/db/pg/migrate.ts",
@@ -24,12 +26,13 @@
"set:enterprise": "echo 'export const build = \"enterprise\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json",
"set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts",
"set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts",
"next:build": "next build",
"build:next": "next build",
"build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs",
"build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs",
"start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod NODE_ENV=development node --enable-source-maps dist/server.mjs",
"email": "email dev --dir server/emails/templates --port 3005",
"build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs",
"format:check": "prettier --check .",
"format": "prettier --write ."
},
"dependencies": {
@@ -75,9 +78,7 @@
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"cookie": "1.1.1",
"cookie-parser": "1.4.7",
"cookies": "0.9.1",
"cors": "2.8.5",
"crypto-js": "4.2.0",
"d3": "7.9.0",
@@ -90,7 +91,6 @@
"glob": "13.0.0",
"helmet": "8.1.0",
"http-errors": "2.0.1",
"i": "0.3.7",
"input-otp": "1.4.2",
"ioredis": "5.9.2",
"jmespath": "0.16.0",
@@ -104,10 +104,7 @@
"next-themes": "0.4.6",
"nextjs-toploader": "3.9.17",
"node-cache": "5.1.2",
"node-fetch": "3.3.2",
"nodemailer": "7.0.11",
"npm": "11.7.0",
"nprogress": "0.2.0",
"oslo": "1.2.1",
"pg": "8.17.1",
"posthog-node": "5.23.0",
@@ -118,7 +115,6 @@
"react-easy-sort": "1.8.0",
"react-hook-form": "7.71.1",
"react-icons": "5.5.0",
"rebuild": "0.1.2",
"recharts": "2.15.4",
"reodotdev": "1.0.0",
"resend": "6.8.0",

View File

@@ -778,7 +778,7 @@ export const currentFingerprint = pgTable("currentFingerprint", {
// Windows-specific posture check information
windowsDefenderEnabled: boolean("windowsDefenderEnabled")
windowsAntivirusEnabled: boolean("windowsAntivirusEnabled")
.notNull()
.default(false),
@@ -830,7 +830,7 @@ export const fingerprintSnapshots = pgTable("fingerprintSnapshots", {
// Windows-specific posture check information
windowsDefenderEnabled: boolean("windowsDefenderEnabled")
windowsAntivirusEnabled: boolean("windowsAntivirusEnabled")
.notNull()
.default(false),

View File

@@ -475,7 +475,7 @@ export const currentFingerprint = sqliteTable("currentFingerprint", {
// Windows-specific posture check information
windowsDefenderEnabled: integer("windowsDefenderEnabled", {
windowsAntivirusEnabled: integer("windowsAntivirusEnabled", {
mode: "boolean"
})
.notNull()
@@ -549,7 +549,7 @@ export const fingerprintSnapshots = sqliteTable("fingerprintSnapshots", {
// Windows-specific posture check information
windowsDefenderEnabled: integer("windowsDefenderEnabled", {
windowsAntivirusEnabled: integer("windowsAntivirusEnabled", {
mode: "boolean"
})
.notNull()

View File

@@ -31,7 +31,7 @@ import { pickPort } from "@server/routers/target/helpers";
import { resourcePassword } from "@server/db";
import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isLicensedOrSubscribed } from "../isLicencedOrSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { build } from "@server/build";
export type ProxyResourcesResults = {
@@ -213,11 +213,7 @@ export async function updateProxyResources(
// Update existing resource
const isLicensed = await isLicensedOrSubscribed(orgId);
if (build == "enterprise" && !isLicensed) {
logger.warn(
"Server is not licensed! Clearing set maintenance screen values"
);
// null the maintenance mode fields if not licensed
if (!isLicensed) {
resourceData.maintenance = undefined;
}
@@ -594,7 +590,7 @@ export async function updateProxyResources(
existingRule.action !== getRuleAction(rule.action) ||
existingRule.match !== rule.match.toUpperCase() ||
existingRule.value !==
getRuleValue(rule.match.toUpperCase(), rule.value) ||
getRuleValue(rule.match.toUpperCase(), rule.value) ||
existingRule.priority !== intendedPriority
) {
validateRule(rule);
@@ -653,11 +649,7 @@ export async function updateProxyResources(
}
const isLicensed = await isLicensedOrSubscribed(orgId);
if (build == "enterprise" && !isLicensed) {
logger.warn(
"Server is not licensed! Clearing set maintenance screen values"
);
// null the maintenance mode fields if not licensed
if (!isLicensed) {
resourceData.maintenance = undefined;
}

View File

@@ -14,7 +14,7 @@ import {
} from "@server/db";
import { getUniqueClientName } from "@server/db/names";
import { getNextAvailableClientSubnet } from "@server/lib/ip";
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import logger from "@server/logger";
import { sendTerminateClient } from "@server/routers/client/terminate";
import { and, eq, notInArray, type InferInsertModel } from "drizzle-orm";

View File

@@ -1,17 +1,3 @@
import { build } from "@server/build";
import license from "#dynamic/license/license";
import { getOrgTierData } from "#dynamic/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
export async function isLicensedOrSubscribed(orgId: string): Promise<boolean> {
if (build === "enterprise") {
return await license.isUnlocked();
}
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
return tier === TierId.STANDARD;
}
return true;
}
return false;
}

View File

@@ -0,0 +1,30 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { build } from "@server/build";
import license from "#private/license/license";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
export async function isLicensedOrSubscribed(orgId: string): Promise<boolean> {
if (build === "enterprise") {
return await license.isUnlocked();
}
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
return tier === TierId.STANDARD;
}
return false;
}

View File

@@ -19,7 +19,7 @@ import { fromError } from "zod-validation-error";
import type { Request, Response, NextFunction } from "express";
import { build } from "@server/build";
import { getOrgTierData } from "@server/lib/billing";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import {
approvals,

View File

@@ -19,7 +19,7 @@ import { fromError } from "zod-validation-error";
import { build } from "@server/build";
import { approvals, clients, db, orgs, type Approval } from "@server/db";
import { getOrgTierData } from "@server/lib/billing";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import response from "@server/lib/response";
import { and, eq, type InferInsertModel } from "drizzle-orm";

View File

@@ -37,27 +37,55 @@ const paramsSchema = z.strictObject({
const bodySchema = z.strictObject({
logoUrl: z
.union([
z.string().length(0),
z.url().refine(
async (url) => {
z.literal(""),
z
.url("Must be a valid URL")
.superRefine(async (url, ctx) => {
try {
const response = await fetch(url);
return (
response.status === 200 &&
(
response.headers.get("content-type") ?? ""
).startsWith("image/")
);
const response = await fetch(url, {
method: "HEAD"
}).catch(() => {
// If HEAD fails (CORS or method not allowed), try GET
return fetch(url, { method: "GET" });
});
if (response.status !== 200) {
ctx.addIssue({
code: "custom",
message: `Failed to load image. Please check that the URL is accessible.`
});
return;
}
const contentType =
response.headers.get("content-type") ?? "";
if (!contentType.startsWith("image/")) {
ctx.addIssue({
code: "custom",
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
});
return;
}
} catch (error) {
return false;
let errorMessage =
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
if (error instanceof TypeError && error.message.includes("fetch")) {
errorMessage =
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
} else if (error instanceof Error) {
errorMessage = `Error verifying URL: ${error.message}`;
}
ctx.addIssue({
code: "custom",
message: errorMessage
});
}
},
{
error: "Invalid logo URL, must be a valid image URL"
}
)
})
])
.optional(),
.transform((val) => (val === "" ? null : val))
.nullish(),
logoWidth: z.coerce.number<number>().min(1),
logoHeight: z.coerce.number<number>().min(1),
resourceTitle: z.string(),
@@ -78,7 +106,7 @@ export async function upsertLoginPageBranding(
next: NextFunction
): Promise<any> {
try {
const parsedBody = bodySchema.safeParse(req.body);
const parsedBody = await bodySchema.safeParseAsync(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
@@ -117,9 +145,8 @@ export async function upsertLoginPageBranding(
typeof loginPageBranding
>;
if ((updateData.logoUrl ?? "").trim().length === 0) {
updateData.logoUrl = undefined;
}
// Empty strings are transformed to null by the schema, which will clear the logo URL in the database
// We keep it as null (not undefined) because undefined fields are omitted from Drizzle updates
if (
build !== "saas" &&

View File

@@ -26,7 +26,8 @@ const applyBlueprintSchema = z
message: `Invalid YAML: ${error instanceof Error ? error.message : "Unknown error"}`
});
}
})
}),
source: z.enum(["API", "UI", "CLI"]).optional()
})
.strict();
@@ -84,7 +85,7 @@ export async function applyYAMLBlueprint(
);
}
const { blueprint: contents, name } = parsedBody.data;
const { blueprint: contents, name, source = "UI" } = parsedBody.data;
logger.debug(`Received blueprint:`, contents);
@@ -107,7 +108,7 @@ export async function applyYAMLBlueprint(
blueprint = await applyBlueprint({
orgId,
name,
source: "UI",
source,
configData: parsedConfig
});
} catch (err) {

View File

@@ -1,6 +1,6 @@
import type { Blueprint } from "@server/db";
export type BlueprintSource = "API" | "UI" | "NEWT";
export type BlueprintSource = "API" | "UI" | "NEWT" | "CLI";
export type BlueprintData = Omit<Blueprint, "source"> & {
source: BlueprintSource;

View File

@@ -9,9 +9,6 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "./terminate";
import { OlmErrorCodes } from "../olm/error";
const archiveClientSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive())
@@ -77,9 +74,6 @@ export async function archiveClient(
.update(clients)
.set({ archived: true })
.where(eq(clients.clientId, clientId));
// Rebuild associations to clean up related data
await rebuildClientAssociationsFromClient(client, trx);
});
return response(res, {

View File

@@ -12,6 +12,7 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { getUserDeviceName } from "@server/db/names";
import { build } from "@server/build";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
const getClientSchema = z.strictObject({
clientId: z
@@ -58,7 +59,7 @@ type PostureData = {
firewallEnabled?: boolean | null;
autoUpdatesEnabled?: boolean | null;
tpmAvailable?: boolean | null;
windowsDefenderEnabled?: boolean | null;
windowsAntivirusEnabled?: boolean | null;
macosSipEnabled?: boolean | null;
macosGatekeeperEnabled?: boolean | null;
macosFirewallStealthMode?: boolean | null;
@@ -75,78 +76,123 @@ function getPlatformPostureData(
const normalizedPlatform = platform?.toLowerCase() || "unknown";
const posture: PostureData = {};
// Windows: Hard drive encryption, Firewall, Auto updates, TPM availability, Windows Defender
// Windows: Hard drive encryption, Firewall, Auto updates, TPM availability, Windows Antivirus status
if (normalizedPlatform === "windows") {
if (fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined) {
if (
fingerprint.diskEncrypted !== null &&
fingerprint.diskEncrypted !== undefined
) {
posture.diskEncrypted = fingerprint.diskEncrypted;
}
if (fingerprint.firewallEnabled !== null && fingerprint.firewallEnabled !== undefined) {
if (
fingerprint.firewallEnabled !== null &&
fingerprint.firewallEnabled !== undefined
) {
posture.firewallEnabled = fingerprint.firewallEnabled;
}
if (fingerprint.autoUpdatesEnabled !== null && fingerprint.autoUpdatesEnabled !== undefined) {
posture.autoUpdatesEnabled = fingerprint.autoUpdatesEnabled;
}
if (fingerprint.tpmAvailable !== null && fingerprint.tpmAvailable !== undefined) {
if (
fingerprint.tpmAvailable !== null &&
fingerprint.tpmAvailable !== undefined
) {
posture.tpmAvailable = fingerprint.tpmAvailable;
}
if (fingerprint.windowsDefenderEnabled !== null && fingerprint.windowsDefenderEnabled !== undefined) {
posture.windowsDefenderEnabled = fingerprint.windowsDefenderEnabled;
if (
fingerprint.windowsAntivirusEnabled !== null &&
fingerprint.windowsAntivirusEnabled !== undefined
) {
posture.windowsAntivirusEnabled =
fingerprint.windowsAntivirusEnabled;
}
}
// macOS: Hard drive encryption, Biometric configuration, Firewall, System Integrity Protection (SIP), Gatekeeper, Firewall stealth mode
else if (normalizedPlatform === "macos") {
if (fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined) {
if (
fingerprint.diskEncrypted !== null &&
fingerprint.diskEncrypted !== undefined
) {
posture.diskEncrypted = fingerprint.diskEncrypted;
}
if (fingerprint.biometricsEnabled !== null && fingerprint.biometricsEnabled !== undefined) {
if (
fingerprint.biometricsEnabled !== null &&
fingerprint.biometricsEnabled !== undefined
) {
posture.biometricsEnabled = fingerprint.biometricsEnabled;
}
if (fingerprint.firewallEnabled !== null && fingerprint.firewallEnabled !== undefined) {
if (
fingerprint.firewallEnabled !== null &&
fingerprint.firewallEnabled !== undefined
) {
posture.firewallEnabled = fingerprint.firewallEnabled;
}
if (fingerprint.macosSipEnabled !== null && fingerprint.macosSipEnabled !== undefined) {
if (
fingerprint.macosSipEnabled !== null &&
fingerprint.macosSipEnabled !== undefined
) {
posture.macosSipEnabled = fingerprint.macosSipEnabled;
}
if (fingerprint.macosGatekeeperEnabled !== null && fingerprint.macosGatekeeperEnabled !== undefined) {
if (
fingerprint.macosGatekeeperEnabled !== null &&
fingerprint.macosGatekeeperEnabled !== undefined
) {
posture.macosGatekeeperEnabled = fingerprint.macosGatekeeperEnabled;
}
if (fingerprint.macosFirewallStealthMode !== null && fingerprint.macosFirewallStealthMode !== undefined) {
posture.macosFirewallStealthMode = fingerprint.macosFirewallStealthMode;
if (
fingerprint.macosFirewallStealthMode !== null &&
fingerprint.macosFirewallStealthMode !== undefined
) {
posture.macosFirewallStealthMode =
fingerprint.macosFirewallStealthMode;
}
if (fingerprint.autoUpdatesEnabled !== null && fingerprint.autoUpdatesEnabled !== undefined) {
if (
fingerprint.autoUpdatesEnabled !== null &&
fingerprint.autoUpdatesEnabled !== undefined
) {
posture.autoUpdatesEnabled = fingerprint.autoUpdatesEnabled;
}
}
// Linux: Hard drive encryption, Firewall, AppArmor, SELinux, TPM availability
else if (normalizedPlatform === "linux") {
if (fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined) {
if (
fingerprint.diskEncrypted !== null &&
fingerprint.diskEncrypted !== undefined
) {
posture.diskEncrypted = fingerprint.diskEncrypted;
}
if (fingerprint.firewallEnabled !== null && fingerprint.firewallEnabled !== undefined) {
if (
fingerprint.firewallEnabled !== null &&
fingerprint.firewallEnabled !== undefined
) {
posture.firewallEnabled = fingerprint.firewallEnabled;
}
if (fingerprint.linuxAppArmorEnabled !== null && fingerprint.linuxAppArmorEnabled !== undefined) {
if (
fingerprint.linuxAppArmorEnabled !== null &&
fingerprint.linuxAppArmorEnabled !== undefined
) {
posture.linuxAppArmorEnabled = fingerprint.linuxAppArmorEnabled;
}
if (fingerprint.linuxSELinuxEnabled !== null && fingerprint.linuxSELinuxEnabled !== undefined) {
if (
fingerprint.linuxSELinuxEnabled !== null &&
fingerprint.linuxSELinuxEnabled !== undefined
) {
posture.linuxSELinuxEnabled = fingerprint.linuxSELinuxEnabled;
}
if (fingerprint.tpmAvailable !== null && fingerprint.tpmAvailable !== undefined) {
if (
fingerprint.tpmAvailable !== null &&
fingerprint.tpmAvailable !== undefined
) {
posture.tpmAvailable = fingerprint.tpmAvailable;
}
}
// iOS: Biometric configuration
else if (normalizedPlatform === "ios") {
if (fingerprint.biometricsEnabled !== null && fingerprint.biometricsEnabled !== undefined) {
posture.biometricsEnabled = fingerprint.biometricsEnabled;
}
// none supported yet
}
// Android: Screen lock, Biometric configuration, Hard drive encryption
else if (normalizedPlatform === "android") {
if (fingerprint.biometricsEnabled !== null && fingerprint.biometricsEnabled !== undefined) {
posture.biometricsEnabled = fingerprint.biometricsEnabled;
}
if (fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined) {
if (
fingerprint.diskEncrypted !== null &&
fingerprint.diskEncrypted !== undefined
) {
posture.diskEncrypted = fingerprint.diskEncrypted;
}
}
@@ -243,23 +289,27 @@ export async function getClient(
// Build fingerprint data if available
const fingerprintData = client.currentFingerprint
? {
username: client.currentFingerprint.username || null,
hostname: client.currentFingerprint.hostname || null,
platform: client.currentFingerprint.platform || null,
osVersion: client.currentFingerprint.osVersion || null,
kernelVersion:
client.currentFingerprint.kernelVersion || null,
arch: client.currentFingerprint.arch || null,
deviceModel: client.currentFingerprint.deviceModel || null,
serialNumber: client.currentFingerprint.serialNumber || null,
firstSeen: client.currentFingerprint.firstSeen || null,
lastSeen: client.currentFingerprint.lastSeen || null
}
username: client.currentFingerprint.username || null,
hostname: client.currentFingerprint.hostname || null,
platform: client.currentFingerprint.platform || null,
osVersion: client.currentFingerprint.osVersion || null,
kernelVersion:
client.currentFingerprint.kernelVersion || null,
arch: client.currentFingerprint.arch || null,
deviceModel: client.currentFingerprint.deviceModel || null,
serialNumber: client.currentFingerprint.serialNumber || null,
firstSeen: client.currentFingerprint.firstSeen || null,
lastSeen: client.currentFingerprint.lastSeen || null
}
: null;
// Build posture data if available (platform-specific)
// Only return posture data if org is licensed/subscribed
let postureData: PostureData | null = null;
if (build !== "oss") {
const isOrgLicensed = await isLicensedOrSubscribed(
client.clients.orgId
);
if (isOrgLicensed) {
postureData = getPlatformPostureData(
client.currentFingerprint?.platform || null,
client.currentFingerprint

View File

@@ -1,6 +1,6 @@
import { NextFunction, Request, Response } from "express";
import { db } from "@server/db";
import { olms, clients } from "@server/db";
import { olms } from "@server/db";
import { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -8,9 +8,6 @@ import response from "@server/lib/response";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "../client/terminate";
import { OlmErrorCodes } from "./error";
const paramsSchema = z
.object({
@@ -37,26 +34,7 @@ export async function archiveUserOlm(
const { olmId } = parsedParams.data;
// Archive the OLM and disconnect associated clients in a transaction
await db.transaction(async (trx) => {
// Find all clients associated with this OLM
const associatedClients = await trx
.select()
.from(clients)
.where(eq(clients.olmId, olmId));
// Disconnect clients from the OLM (set olmId to null)
for (const client of associatedClients) {
await trx
.update(clients)
.set({ olmId: null })
.where(eq(clients.clientId, client.clientId));
await rebuildClientAssociationsFromClient(client, trx);
await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_ARCHIVED, olmId);
}
// Archive the OLM (set archived to true)
await trx
.update(olms)
.set({ archived: true })

View File

@@ -22,7 +22,7 @@ function fingerprintSnapshotHash(fingerprint: any, postures: any): string {
autoUpdatesEnabled: postures.autoUpdatesEnabled ?? false,
tpmAvailable: postures.tpmAvailable ?? false,
windowsDefenderEnabled: postures.windowsDefenderEnabled ?? false,
windowsAntivirusEnabled: postures.windowsAntivirusEnabled ?? false,
macosSipEnabled: postures.macosSipEnabled ?? false,
macosGatekeeperEnabled: postures.macosGatekeeperEnabled ?? false,
@@ -87,7 +87,7 @@ export async function handleFingerprintInsertion(
autoUpdatesEnabled: postures.autoUpdatesEnabled,
tpmAvailable: postures.tpmAvailable,
windowsDefenderEnabled: postures.windowsDefenderEnabled,
windowsAntivirusEnabled: postures.windowsAntivirusEnabled,
macosSipEnabled: postures.macosSipEnabled,
macosGatekeeperEnabled: postures.macosGatekeeperEnabled,
@@ -117,7 +117,7 @@ export async function handleFingerprintInsertion(
autoUpdatesEnabled: postures.autoUpdatesEnabled,
tpmAvailable: postures.tpmAvailable,
windowsDefenderEnabled: postures.windowsDefenderEnabled,
windowsAntivirusEnabled: postures.windowsAntivirusEnabled,
macosSipEnabled: postures.macosSipEnabled,
macosGatekeeperEnabled: postures.macosGatekeeperEnabled,
@@ -162,7 +162,7 @@ export async function handleFingerprintInsertion(
autoUpdatesEnabled: postures.autoUpdatesEnabled,
tpmAvailable: postures.tpmAvailable,
windowsDefenderEnabled: postures.windowsDefenderEnabled,
windowsAntivirusEnabled: postures.windowsAntivirusEnabled,
macosSipEnabled: postures.macosSipEnabled,
macosGatekeeperEnabled: postures.macosGatekeeperEnabled,
@@ -197,7 +197,7 @@ export async function handleFingerprintInsertion(
autoUpdatesEnabled: postures.autoUpdatesEnabled,
tpmAvailable: postures.tpmAvailable,
windowsDefenderEnabled: postures.windowsDefenderEnabled,
windowsAntivirusEnabled: postures.windowsAntivirusEnabled,
macosSipEnabled: postures.macosSipEnabled,
macosGatekeeperEnabled: postures.macosGatekeeperEnabled,

View File

@@ -46,6 +46,12 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return;
}
logger.debug("Handling fingerprint insertion for olm register...", {
olmId: olm.olmId,
fingerprint,
postures
});
await handleFingerprintInsertion(olm, fingerprint, postures);
if (
@@ -143,7 +149,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return;
}
if (!policyCheck.policies?.passwordAge?.compliant === false) {
if (policyCheck.policies?.passwordAge?.compliant === false) {
logger.warn(
`Olm user ${olm.userId} has non-compliant password age for org ${orgId}`
);
@@ -153,7 +159,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
);
return;
} else if (
!policyCheck.policies?.maxSessionLength?.compliant === false
policyCheck.policies?.maxSessionLength?.compliant === false
) {
logger.warn(
`Olm user ${olm.userId} has non-compliant session length for org ${orgId}`

View File

@@ -13,7 +13,7 @@ import { build } from "@server/build";
import { getOrgTierData } from "#dynamic/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { cache } from "@server/lib/cache";
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
const updateOrgParamsSchema = z.strictObject({
orgId: z.string()
@@ -89,7 +89,7 @@ export async function updateOrg(
const { orgId } = parsedParams.data;
const isLicensed = await isLicensedOrSubscribed(orgId);
if (build == "enterprise" && !isLicensed) {
if (!isLicensed) {
parsedBody.data.requireTwoFactor = undefined;
parsedBody.data.maxSessionLengthHours = undefined;
parsedBody.data.passwordExpiryDays = undefined;

View File

@@ -23,7 +23,7 @@ import { OpenAPITags } from "@server/openApi";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { build } from "@server/build";
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
const updateResourceParamsSchema = z.strictObject({
resourceId: z.string().transform(Number).pipe(z.int().positive())
@@ -342,11 +342,7 @@ async function updateHttpResource(
}
const isLicensed = await isLicensedOrSubscribed(resource.orgId);
if (build == "enterprise" && !isLicensed) {
logger.warn(
"Server is not licensed! Clearing set maintenance screen values"
);
// null the maintenance mode fields if not licensed
if (!isLicensed) {
updateData.maintenanceModeEnabled = undefined;
updateData.maintenanceModeType = undefined;
updateData.maintenanceTitle = undefined;

View File

@@ -11,7 +11,7 @@ import { ActionsEnum } from "@server/auth/actions";
import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { build } from "@server/build";
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
const createRoleParamsSchema = z.strictObject({
orgId: z.string()
@@ -101,7 +101,7 @@ export async function createRole(
}
const isLicensed = await isLicensedOrSubscribed(orgId);
if (build === "oss" || !isLicensed) {
if (!isLicensed) {
roleData.requireDeviceApproval = undefined;
}

View File

@@ -8,8 +8,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { build } from "@server/build";
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { OpenAPITags, registry } from "@server/openApi";
const updateRoleParamsSchema = z.strictObject({
@@ -112,7 +111,7 @@ export async function updateRole(
}
const isLicensed = await isLicensedOrSubscribed(orgId);
if (build === "oss" || !isLicensed) {
if (!isLicensed) {
updateData.requireDeviceApproval = undefined;
}

View File

@@ -64,16 +64,20 @@ export async function ensureSetupToken() {
);
}
if (existingToken?.token !== envSetupToken) {
console.warn(
"Overwriting existing token in DB since PANGOLIN_SETUP_TOKEN is set"
);
if (existingToken) {
// Token exists in DB - update it if different
if (existingToken.token !== envSetupToken) {
console.warn(
"Overwriting existing token in DB since PANGOLIN_SETUP_TOKEN is set"
);
await db
.update(setupTokens)
.set({ token: envSetupToken })
.where(eq(setupTokens.tokenId, existingToken.tokenId));
await db
.update(setupTokens)
.set({ token: envSetupToken })
.where(eq(setupTokens.tokenId, existingToken.tokenId));
}
} else {
// No existing token - insert new one
const tokenId = generateId(15);
await db.insert(setupTokens).values({

View File

@@ -49,7 +49,7 @@ export default async function migration() {
"firewallEnabled" boolean DEFAULT false NOT NULL,
"autoUpdatesEnabled" boolean DEFAULT false NOT NULL,
"tpmAvailable" boolean DEFAULT false NOT NULL,
"windowsDefenderEnabled" boolean DEFAULT false NOT NULL,
"windowsAntivirusEnabled" boolean DEFAULT false NOT NULL,
"macosSipEnabled" boolean DEFAULT false NOT NULL,
"macosGatekeeperEnabled" boolean DEFAULT false NOT NULL,
"macosFirewallStealthMode" boolean DEFAULT false NOT NULL,
@@ -75,7 +75,7 @@ export default async function migration() {
"firewallEnabled" boolean DEFAULT false NOT NULL,
"autoUpdatesEnabled" boolean DEFAULT false NOT NULL,
"tpmAvailable" boolean DEFAULT false NOT NULL,
"windowsDefenderEnabled" boolean DEFAULT false NOT NULL,
"windowsAntivirusEnabled" boolean DEFAULT false NOT NULL,
"macosSipEnabled" boolean DEFAULT false NOT NULL,
"macosGatekeeperEnabled" boolean DEFAULT false NOT NULL,
"macosFirewallStealthMode" boolean DEFAULT false NOT NULL,

View File

@@ -53,7 +53,7 @@ CREATE TABLE 'currentFingerprint' (
'firewallEnabled' integer DEFAULT false NOT NULL,
'autoUpdatesEnabled' integer DEFAULT false NOT NULL,
'tpmAvailable' integer DEFAULT false NOT NULL,
'windowsDefenderEnabled' integer DEFAULT false NOT NULL,
'windowsAntivirusEnabled' integer DEFAULT false NOT NULL,
'macosSipEnabled' integer DEFAULT false NOT NULL,
'macosGatekeeperEnabled' integer DEFAULT false NOT NULL,
'macosFirewallStealthMode' integer DEFAULT false NOT NULL,
@@ -83,7 +83,7 @@ CREATE TABLE 'fingerprintSnapshots' (
'firewallEnabled' integer DEFAULT false NOT NULL,
'autoUpdatesEnabled' integer DEFAULT false NOT NULL,
'tpmAvailable' integer DEFAULT false NOT NULL,
'windowsDefenderEnabled' integer DEFAULT false NOT NULL,
'windowsAntivirusEnabled' integer DEFAULT false NOT NULL,
'macosSipEnabled' integer DEFAULT false NOT NULL,
'macosGatekeeperEnabled' integer DEFAULT false NOT NULL,
'macosFirewallStealthMode' integer DEFAULT false NOT NULL,

View File

@@ -19,17 +19,6 @@ export interface ApprovalFeedPageProps {
export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) {
const params = await props.params;
let approvals: ApprovalItem[] = [];
const res = await internal
.get<
AxiosResponse<{ approvals: ApprovalItem[] }>
>(`/org/${params.orgId}/approvals`, await authCookieHeader())
.catch((e) => {});
if (res && res.status === 200) {
approvals = res.data.data.approvals;
}
let org: GetOrgResponse | null = null;
const orgRes = await getCachedOrg(params.orgId);

View File

@@ -656,17 +656,17 @@ export default function GeneralPage() {
</InfoSection>
)}
{client.posture.windowsDefenderEnabled !== null &&
client.posture.windowsDefenderEnabled !== undefined && (
{client.posture.windowsAntivirusEnabled !== null &&
client.posture.windowsAntivirusEnabled !== undefined && (
<InfoSection>
<InfoSectionTitle>
{t("windowsDefenderEnabled")}
{t("windowsAntivirusEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.windowsDefenderEnabled
.windowsAntivirusEnabled
)
: "-"}
</InfoSectionContent>

View File

@@ -11,7 +11,6 @@ import {
SelectValue
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { ContainersSelector } from "@app/components/ContainersSelector";
import { HeadersInput } from "@app/components/HeadersInput";
import {
PathMatchDisplay,
@@ -19,6 +18,7 @@ import {
PathRewriteDisplay,
PathRewriteModal
} from "@app/components/PathMatchRenameModal";
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
import {
SettingsContainer,
SettingsSection,
@@ -30,15 +30,6 @@ import {
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { Badge } from "@app/components/ui/badge";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Form,
FormControl,
@@ -48,11 +39,6 @@ import {
FormLabel,
FormMessage
} from "@app/components/ui/form";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Table,
TableBody,
@@ -73,12 +59,9 @@ import { useResourceContext } from "@app/hooks/useResourceContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { cn } from "@app/lib/cn";
import { DockerManager, DockerState } from "@app/lib/docker";
import { parseHostTarget } from "@app/lib/parseHostTarget";
import { orgQueries, resourceQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import { CaretSortIcon } from "@radix-ui/react-icons";
import { tlsNameSchema } from "@server/lib/schemas";
import { type GetResourceResponse } from "@server/routers/resource";
import type { ListSitesResponse } from "@server/routers/site";
@@ -98,7 +81,6 @@ import {
import { AxiosResponse } from "axios";
import {
AlertTriangle,
CheckIcon,
CircleCheck,
CircleX,
Info,
@@ -107,7 +89,7 @@ import {
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { use, useActionState, useEffect, useState } from "react";
import { use, useActionState, useCallback, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -202,7 +184,7 @@ function ProxyResourceTargetsForm({
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
};
const refreshContainersForSite = async (siteId: number) => {
const refreshContainersForSite = useCallback(async (siteId: number) => {
const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers();
@@ -214,9 +196,9 @@ function ProxyResourceTargetsForm({
}
return newMap;
});
};
}, [api]);
const getDockerStateForSite = (siteId: number): DockerState => {
const getDockerStateForSite = useCallback((siteId: number): DockerState => {
return (
dockerStates.get(siteId) || {
isEnabled: false,
@@ -224,7 +206,7 @@ function ProxyResourceTargetsForm({
containers: []
}
);
};
}, [dockerStates]);
const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
if (typeof window !== "undefined") {
@@ -234,8 +216,40 @@ function ProxyResourceTargetsForm({
return false;
});
const getColumns = (): ColumnDef<LocalTarget>[] => {
const isHttp = resource.http;
const isHttp = resource.http;
const removeTarget = useCallback((targetId: number) => {
setTargets((prevTargets) => {
const targetToRemove = prevTargets.find((target) => target.targetId === targetId);
if (targetToRemove && !targetToRemove.new) {
setTargetsToRemove((prev) => [...prev, targetId]);
}
return prevTargets.filter((target) => target.targetId !== targetId);
});
}, []);
const updateTarget = useCallback((targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => {
const site = sites.find((site) => site.siteId === data.siteId);
return prevTargets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site ? site.type : target.siteType
}
: target
);
});
}, [sites]);
const openHealthCheckDialog = useCallback((target: LocalTarget) => {
setSelectedTargetForHealthCheck(target);
setHealthCheckDialogOpen(true);
}, []);
const columns = useMemo((): ColumnDef<LocalTarget>[] => {
const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority",
@@ -419,213 +433,15 @@ function ProxyResourceTargetsForm({
accessorKey: "address",
header: () => <span className="p-3">{t("address")}</span>,
cell: ({ row }) => {
const selectedSite = sites.find(
(site) => site.siteId === row.original.siteId
);
const handleContainerSelectForTarget = (
hostname: string,
port?: number
) => {
updateTarget(row.original.targetId, {
...row.original,
ip: hostname,
...(port && { port: port })
});
};
return (
<div className="flex items-center w-full">
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input rounded-md">
{selectedSite &&
selectedSite.type === "newt" &&
(() => {
const dockerState = getDockerStateForSite(
selectedSite.siteId
);
return (
<ContainersSelector
site={selectedSite}
containers={dockerState.containers}
isAvailable={
dockerState.isAvailable
}
onContainerSelect={
handleContainerSelectForTarget
}
onRefresh={() =>
refreshContainersForSite(
selectedSite.siteId
)
}
/>
);
})()}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
className={cn(
"w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
!row.original.siteId &&
"text-muted-foreground"
)}
>
<span className="truncate max-w-[150px]">
{row.original.siteId
? selectedSite?.name
: t("siteSelect")}
</span>
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[180px]">
<Command>
<CommandInput
placeholder={t("siteSearch")}
/>
<CommandList>
<CommandEmpty>
{t("siteNotFound")}
</CommandEmpty>
<CommandGroup>
{sites.map((site) => (
<CommandItem
key={site.siteId}
value={`${site.siteId}:${site.name}`}
onSelect={() =>
updateTarget(
row.original
.targetId,
{
siteId: site.siteId
}
)
}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
row.original
.siteId
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{resource.http && (
<Select
defaultValue={row.original.method ?? "http"}
onValueChange={(value) =>
updateTarget(row.original.targetId, {
...row.original,
method: value
})
}
>
<SelectTrigger className="h-8 px-2 w-[70px] text-sm font-normal border-none bg-transparent shadow-none focus:ring-0 focus:outline-none focus-visible:ring-0 data-[state=open]:bg-transparent">
{row.original.method || "http"}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">
http
</SelectItem>
<SelectItem value="https">
https
</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
)}
{resource.http && (
<div className="flex items-center justify-center px-2 h-9">
{"://"}
</div>
)}
<Input
defaultValue={row.original.ip}
placeholder="Host"
className="flex-1 min-w-[120px] pl-0 border-none placeholder-gray-400"
onBlur={(e) => {
const input = e.target.value.trim();
const hasProtocol =
/^(https?|h2c):\/\//.test(input);
const hasPort = /:\d+(?:\/|$)/.test(input);
if (hasProtocol || hasPort) {
const parsed = parseHostTarget(input);
if (parsed) {
updateTarget(
row.original.targetId,
{
...row.original,
method: hasProtocol
? parsed.protocol
: row.original.method,
ip: parsed.host,
port: hasPort
? parsed.port
: row.original.port
}
);
} else {
updateTarget(
row.original.targetId,
{
...row.original,
ip: input
}
);
}
} else {
updateTarget(row.original.targetId, {
...row.original,
ip: input
});
}
}}
/>
<div className="flex items-center justify-center px-2 h-9">
{":"}
</div>
<Input
placeholder="Port"
defaultValue={
row.original.port === 0
? ""
: row.original.port
}
className="w-[75px] pl-0 border-none placeholder-gray-400"
onBlur={(e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value > 0) {
updateTarget(row.original.targetId, {
...row.original,
port: value
});
} else {
updateTarget(row.original.targetId, {
...row.original,
port: 0
});
}
}}
/>
</div>
</div>
<ResourceTargetAddressItem
isHttp={isHttp}
sites={sites}
getDockerStateForSite={getDockerStateForSite}
proxyTarget={row.original}
refreshContainersForSite={refreshContainersForSite}
updateTarget={updateTarget}
/>
);
},
size: 400,
@@ -765,7 +581,7 @@ function ProxyResourceTargetsForm({
actionsColumn
];
}
};
}, [isAdvancedMode, isHttp, sites, updateTarget, getDockerStateForSite, refreshContainersForSite, openHealthCheckDialog, removeTarget, t]);
function addNewTarget() {
const isHttp = resource.http;
@@ -806,32 +622,6 @@ function ProxyResourceTargetsForm({
setTargets((prev) => [...prev, newTarget]);
}
const removeTarget = (targetId: number) => {
setTargets([
...targets.filter((target) => target.targetId !== targetId)
]);
if (!targets.find((target) => target.targetId === targetId)?.new) {
setTargetsToRemove([...targetsToRemove, targetId]);
}
};
async function updateTarget(targetId: number, data: Partial<LocalTarget>) {
const site = sites.find((site) => site.siteId === data.siteId);
setTargets(
targets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site ? site.type : target.siteType
}
: target
)
);
}
function updateTargetHealthCheck(targetId: number, config: any) {
setTargets(
targets.map((target) =>
@@ -846,14 +636,6 @@ function ProxyResourceTargetsForm({
);
}
const openHealthCheckDialog = (target: LocalTarget) => {
console.log(target);
setSelectedTargetForHealthCheck(target);
setHealthCheckDialogOpen(true);
};
const columns = getColumns();
const table = useReactTable({
data: targets,
columns,

View File

@@ -1,5 +1,14 @@
"use client";
import CopyTextBox from "@app/components/CopyTextBox";
import DomainPicker from "@app/components/DomainPicker";
import HealthCheckDialog from "@app/components/HealthCheckDialog";
import {
PathMatchDisplay,
PathMatchModal,
PathRewriteDisplay,
PathRewriteModal
} from "@app/components/PathMatchRenameModal";
import {
SettingsContainer,
SettingsSection,
@@ -9,6 +18,10 @@ import {
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { StrategySelect } from "@app/components/StrategySelect";
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
@@ -18,22 +31,7 @@ import {
FormLabel,
FormMessage
} from "@app/components/ui/form";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { z } from "zod";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
import { Button } from "@app/components/ui/button";
import { useParams, useRouter } from "next/navigation";
import { ListSitesResponse } from "@server/routers/site";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { AxiosResponse } from "axios";
import { Resource } from "@server/db";
import { StrategySelect } from "@app/components/StrategySelect";
import {
Select,
SelectContent,
@@ -41,48 +39,7 @@ import {
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { ListDomainsResponse } from "@server/routers/domain";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { cn } from "@app/lib/cn";
import {
ArrowRight,
CircleCheck,
CircleX,
Info,
MoveRight,
Plus,
Settings,
SquareArrowOutUpRight
} from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox";
import Link from "next/link";
import { useTranslations } from "next-intl";
import DomainPicker from "@app/components/DomainPicker";
import { build } from "@server/build";
import { ContainersSelector } from "@app/components/ContainersSelector";
import {
ColumnDef,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
getCoreRowModel,
useReactTable,
flexRender,
Row
} from "@tanstack/react-table";
import { Switch } from "@app/components/ui/switch";
import {
Table,
TableBody,
@@ -91,30 +48,49 @@ import {
TableHeader,
TableRow
} from "@app/components/ui/table";
import { Switch } from "@app/components/ui/switch";
import { ArrayElement } from "@server/types/ArrayElement";
import { isTargetValid } from "@server/lib/validators";
import { ListTargetsResponse } from "@server/routers/target";
import { DockerManager, DockerState } from "@app/lib/docker";
import { parseHostTarget } from "@app/lib/parseHostTarget";
import { toASCII, toUnicode } from "punycode";
import { DomainRow } from "@app/components/DomainsTable";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { DockerManager, DockerState } from "@app/lib/docker";
import { orgQueries } from "@app/lib/queries";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { Resource } from "@server/db";
import { isTargetValid } from "@server/lib/validators";
import { ListTargetsResponse } from "@server/routers/target";
import { ArrayElement } from "@server/types/ArrayElement";
import { useQuery } from "@tanstack/react-query";
import {
PathMatchDisplay,
PathMatchModal,
PathRewriteDisplay,
PathRewriteModal
} from "@app/components/PathMatchRenameModal";
import { Badge } from "@app/components/ui/badge";
import HealthCheckDialog from "@app/components/HealthCheckDialog";
import { SwitchInput } from "@app/components/SwitchInput";
ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable
} from "@tanstack/react-table";
import { AxiosResponse } from "axios";
import {
CircleCheck,
CircleX,
Info,
Plus,
Settings,
SquareArrowOutUpRight
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { toASCII } from "punycode";
import { useEffect, useMemo, useState, useCallback } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255),
@@ -204,10 +180,6 @@ const addTargetSchema = z
}
);
type BaseResourceFormValues = z.infer<typeof baseResourceFormSchema>;
type HttpResourceFormValues = z.infer<typeof httpResourceFormSchema>;
type TcpUdpResourceFormValues = z.infer<typeof tcpUdpResourceFormSchema>;
type ResourceType = "http" | "raw";
interface ResourceTypeOption {
@@ -217,7 +189,7 @@ interface ResourceTypeOption {
disabled?: boolean;
}
type LocalTarget = Omit<
export type LocalTarget = Omit<
ArrayElement<ListTargetsResponse["targets"]> & {
new?: boolean;
updated?: boolean;
@@ -233,18 +205,16 @@ export default function Page() {
const router = useRouter();
const t = useTranslations();
const [loadingPage, setLoadingPage] = useState(true);
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [baseDomains, setBaseDomains] = useState<
{ domainId: string; baseDomain: string }[]
>([]);
const { data: sites = [], isLoading: loadingPage } = useQuery(
orgQueries.sites({ orgId: orgId as string })
);
const [createLoading, setCreateLoading] = useState(false);
const [showSnippets, setShowSnippets] = useState(false);
const [niceId, setNiceId] = useState<string>("");
// Target management state
const [targets, setTargets] = useState<LocalTarget[]>([]);
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
new Map()
);
@@ -405,102 +375,60 @@ export default function Page() {
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
};
const refreshContainersForSite = async (siteId: number) => {
const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers();
const refreshContainersForSite = useCallback(
async (siteId: number) => {
const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers();
setDockerStates((prev) => {
const newMap = new Map(prev);
const existingState = newMap.get(siteId);
if (existingState) {
newMap.set(siteId, { ...existingState, containers });
}
return newMap;
setDockerStates((prev) => {
const newMap = new Map(prev);
const existingState = newMap.get(siteId);
if (existingState) {
newMap.set(siteId, { ...existingState, containers });
}
return newMap;
});
},
[api]
);
const getDockerStateForSite = useCallback(
(siteId: number): DockerState => {
return (
dockerStates.get(siteId) || {
isEnabled: false,
isAvailable: false,
containers: []
}
);
},
[dockerStates]
);
const removeTarget = useCallback((targetId: number) => {
setTargets((prevTargets) => {
return prevTargets.filter((target) => target.targetId !== targetId);
});
};
}, []);
const getDockerStateForSite = (siteId: number): DockerState => {
return (
dockerStates.get(siteId) || {
isEnabled: false,
isAvailable: false,
containers: []
}
);
};
async function addTarget(data: z.infer<typeof addTargetSchema>) {
const site = sites.find((site) => site.siteId === data.siteId);
const isHttp = baseForm.watch("http");
const newTarget: LocalTarget = {
...data,
path: isHttp ? data.path || null : null,
pathMatchType: isHttp ? data.pathMatchType || null : null,
rewritePath: isHttp ? data.rewritePath || null : null,
rewritePathType: isHttp ? data.rewritePathType || null : null,
siteType: site?.type || null,
enabled: true,
targetId: new Date().getTime(),
new: true,
resourceId: 0, // Will be set when resource is created
priority: isHttp ? data.priority || 100 : 100, // Default priority
hcEnabled: false,
hcPath: null,
hcMethod: null,
hcInterval: null,
hcTimeout: null,
hcHeaders: null,
hcScheme: null,
hcHostname: null,
hcPort: null,
hcFollowRedirects: null,
hcHealth: "unknown",
hcStatus: null,
hcMode: null,
hcUnhealthyInterval: null,
hcTlsServerName: null
};
setTargets([...targets, newTarget]);
addTargetForm.reset({
ip: "",
method: baseForm.watch("http") ? "http" : null,
port: "" as any as number,
path: null,
pathMatchType: null,
rewritePath: null,
rewritePathType: null,
priority: isHttp ? 100 : undefined
});
}
const removeTarget = (targetId: number) => {
setTargets([
...targets.filter((target) => target.targetId !== targetId)
]);
if (!targets.find((target) => target.targetId === targetId)?.new) {
setTargetsToRemove([...targetsToRemove, targetId]);
}
};
async function updateTarget(targetId: number, data: Partial<LocalTarget>) {
const site = sites.find((site) => site.siteId === data.siteId);
setTargets(
targets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site ? site.type : target.siteType
}
: target
)
);
}
const updateTarget = useCallback(
(targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => {
const site = sites.find((site) => site.siteId === data.siteId);
return prevTargets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site ? site.type : target.siteType
}
: target
);
});
},
[sites]
);
async function onSubmit() {
setCreateLoading(true);
@@ -638,82 +566,18 @@ export default function Page() {
}
useEffect(() => {
const load = async () => {
setLoadingPage(true);
// Initialize Docker for newt sites
for (const site of sites) {
if (site.type === "newt") {
initializeDockerForSite(site.siteId);
}
}
const fetchSites = async () => {
const res = await api
.get<
AxiosResponse<ListSitesResponse>
>(`/org/${orgId}/sites/`)
.catch((e) => {
toast({
variant: "destructive",
title: t("sitesErrorFetch"),
description: formatAxiosError(
e,
t("sitesErrorFetchDescription")
)
});
});
if (res?.status === 200) {
setSites(res.data.data.sites);
// Initialize Docker for newt sites
for (const site of res.data.data.sites) {
if (site.type === "newt") {
initializeDockerForSite(site.siteId);
}
}
// If there's only one site, set it as the default in the form
if (res.data.data.sites.length) {
addTargetForm.setValue(
"siteId",
res.data.data.sites[0].siteId
);
}
}
};
const fetchDomains = async () => {
const res = await api
.get<
AxiosResponse<ListDomainsResponse>
>(`/org/${orgId}/domains/`)
.catch((e) => {
toast({
variant: "destructive",
title: t("domainsErrorFetch"),
description: formatAxiosError(
e,
t("domainsErrorFetchDescription")
)
});
});
if (res?.status === 200) {
const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain)
}));
setBaseDomains(domains);
// if (domains.length) {
// httpForm.setValue("domainId", domains[0].domainId);
// }
}
};
await fetchSites();
await fetchDomains();
setLoadingPage(false);
};
load();
}, []);
// If there's at least one site, set it as the default in the form
if (sites.length > 0) {
addTargetForm.setValue("siteId", sites[0].siteId);
}
}, [sites]);
function TargetHealthCheck(targetId: number, config: any) {
setTargets(
@@ -729,16 +593,15 @@ export default function Page() {
);
}
const openHealthCheckDialog = (target: LocalTarget) => {
const openHealthCheckDialog = useCallback((target: LocalTarget) => {
console.log(target);
setSelectedTargetForHealthCheck(target);
setHealthCheckDialogOpen(true);
};
}, []);
const getColumns = (): ColumnDef<LocalTarget>[] => {
const baseColumns: ColumnDef<LocalTarget>[] = [];
const isHttp = baseForm.watch("http");
const isHttp = baseForm.watch("http");
const columns = useMemo((): ColumnDef<LocalTarget>[] => {
const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority",
header: () => (
@@ -875,7 +738,7 @@ export default function Page() {
trigger={
<Button
variant="outline"
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer max-w-[200px]"
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer max-w-50"
>
<PathMatchDisplay
value={{
@@ -899,7 +762,7 @@ export default function Page() {
trigger={
<Button
variant="outline"
className="w-full max-w-[200px]"
className="w-full max-w-50"
>
<Plus className="h-4 w-4 mr-2" />
{t("matchPath")}
@@ -918,216 +781,16 @@ export default function Page() {
const addressColumn: ColumnDef<LocalTarget> = {
accessorKey: "address",
header: () => <span className="p-3">{t("address")}</span>,
cell: ({ row }) => {
const selectedSite = sites.find(
(site) => site.siteId === row.original.siteId
);
const handleContainerSelectForTarget = (
hostname: string,
port?: number
) => {
updateTarget(row.original.targetId, {
...row.original,
ip: hostname,
...(port && { port: port })
});
};
return (
<div className="flex items-center w-full">
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input rounded-md">
{selectedSite &&
selectedSite.type === "newt" &&
(() => {
const dockerState = getDockerStateForSite(
selectedSite.siteId
);
return (
<ContainersSelector
site={selectedSite}
containers={dockerState.containers}
isAvailable={
dockerState.isAvailable
}
onContainerSelect={
handleContainerSelectForTarget
}
onRefresh={() =>
refreshContainersForSite(
selectedSite.siteId
)
}
/>
);
})()}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
className={cn(
"w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
!row.original.siteId &&
"text-muted-foreground"
)}
>
<span className="truncate max-w-[150px]">
{row.original.siteId
? selectedSite?.name
: t("siteSelect")}
</span>
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[180px]">
<Command>
<CommandInput
placeholder={t("siteSearch")}
/>
<CommandList>
<CommandEmpty>
{t("siteNotFound")}
</CommandEmpty>
<CommandGroup>
{sites.map((site) => (
<CommandItem
key={site.siteId}
value={`${site.siteId}:${site.name}`}
onSelect={() =>
updateTarget(
row.original
.targetId,
{
siteId: site.siteId
}
)
}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
row.original
.siteId
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{isHttp && (
<Select
defaultValue={row.original.method ?? "http"}
onValueChange={(value) =>
updateTarget(row.original.targetId, {
...row.original,
method: value
})
}
>
<SelectTrigger className="h-8 px-2 w-[70px] border-none bg-transparent shadow-none focus:ring-0 focus:outline-none focus-visible:ring-0 data-[state=open]:bg-transparent">
{row.original.method || "http"}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">
http
</SelectItem>
<SelectItem value="https">
https
</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
)}
{isHttp && (
<div className="flex items-center justify-center px-2 h-9">
{"://"}
</div>
)}
<Input
defaultValue={row.original.ip}
placeholder="Host"
className="flex-1 min-w-[120px] pl-0 border-none placeholder-gray-400"
onBlur={(e) => {
const input = e.target.value.trim();
const hasProtocol =
/^(https?|h2c):\/\//.test(input);
const hasPort = /:\d+(?:\/|$)/.test(input);
if (hasProtocol || hasPort) {
const parsed = parseHostTarget(input);
if (parsed) {
updateTarget(
row.original.targetId,
{
...row.original,
method: hasProtocol
? parsed.protocol
: row.original.method,
ip: parsed.host,
port: hasPort
? parsed.port
: row.original.port
}
);
} else {
updateTarget(
row.original.targetId,
{
...row.original,
ip: input
}
);
}
} else {
updateTarget(row.original.targetId, {
...row.original,
ip: input
});
}
}}
/>
<div className="flex items-center justify-center px-2 h-9">
{":"}
</div>
<Input
placeholder="Port"
defaultValue={
row.original.port === 0
? ""
: row.original.port
}
className="w-[75px] pl-0 border-none placeholder-gray-400"
onBlur={(e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value > 0) {
updateTarget(row.original.targetId, {
...row.original,
port: value
});
} else {
updateTarget(row.original.targetId, {
...row.original,
port: 0
});
}
}}
/>
</div>
</div>
);
},
cell: ({ row }) => (
<ResourceTargetAddressItem
isHttp={isHttp}
sites={sites}
getDockerStateForSite={getDockerStateForSite}
proxyTarget={row.original}
refreshContainersForSite={refreshContainersForSite}
updateTarget={updateTarget}
/>
),
size: 400,
minSize: 350,
maxSize: 500
@@ -1186,7 +849,7 @@ export default function Page() {
<Button
variant="outline"
disabled={noPathMatch}
className="w-full max-w-[200px]"
className="w-full max-w-50"
>
<Plus className="h-4 w-4 mr-2" />
{t("rewritePath")}
@@ -1265,9 +928,17 @@ export default function Page() {
actionsColumn
];
}
};
const columns = getColumns();
}, [
isAdvancedMode,
isHttp,
sites,
updateTarget,
getDockerStateForSite,
refreshContainersForSite,
openHealthCheckDialog,
removeTarget,
t
]);
const table = useReactTable({
data: targets,
@@ -1649,9 +1320,6 @@ export default function Page() {
</TableRow>
)}
</TableBody>
{/* <TableCaption> */}
{/* {t('targetNoOneDescription')} */}
{/* </TableCaption> */}
</Table>
</div>
<div className="flex items-center justify-between mb-4">

View File

@@ -43,25 +43,52 @@ export type AuthPageCustomizationProps = {
const AuthPageFormSchema = z.object({
logoUrl: z.union([
z.string().length(0),
z.url().refine(
async (url) => {
try {
const response = await fetch(url);
return (
response.status === 200 &&
(response.headers.get("content-type") ?? "").startsWith(
"image/"
)
);
} catch (error) {
return false;
z.literal(""),
z.url("Must be a valid URL").superRefine(async (url, ctx) => {
try {
const response = await fetch(url, {
method: "HEAD"
}).catch(() => {
// If HEAD fails (CORS or method not allowed), try GET
return fetch(url, { method: "GET" });
});
if (response.status !== 200) {
ctx.addIssue({
code: "custom",
message: `Failed to load image. Please check that the URL is accessible.`
});
return;
}
},
{
error: "Invalid logo URL, must be a valid image URL"
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.startsWith("image/")) {
ctx.addIssue({
code: "custom",
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
});
return;
}
} catch (error) {
let errorMessage =
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
if (
error instanceof TypeError &&
error.message.includes("fetch")
) {
errorMessage =
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
} else if (error instanceof Error) {
errorMessage = `Error verifying URL: ${error.message}`;
}
ctx.addIssue({
code: "custom",
message: errorMessage
});
}
)
})
]),
logoWidth: z.coerce.number<number>().min(1),
logoHeight: z.coerce.number<number>().min(1),
@@ -405,9 +432,7 @@ export default function AuthPageBrandingForm({
<Button
variant="destructive"
type="submit"
loading={
isUpdatingBranding || isDeletingBranding
}
loading={isDeletingBranding}
disabled={
isUpdatingBranding ||
isDeletingBranding ||
@@ -422,7 +447,7 @@ export default function AuthPageBrandingForm({
<Button
type="submit"
form="auth-page-branding-form"
loading={isUpdatingBranding || isDeletingBranding}
loading={isUpdatingBranding}
disabled={
isUpdatingBranding ||
isDeletingBranding ||

View File

@@ -110,6 +110,15 @@ export default function BlueprintDetailsForm({
Dashboard
</Badge>
)}{" "}
{blueprint.source === "CLI" && (
<Badge
variant="secondary"
className="inline-flex items-center gap-1 "
>
<Terminal className="w-3 h-3 flex-none" />
CLI
</Badge>
)}{" "}
</InfoSectionContent>
</InfoSection>
<InfoSection>

View File

@@ -128,6 +128,19 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) {
</Badge>
);
}
case "CLI": {
return (
<Badge
variant="secondary"
className="inline-flex items-center gap-1"
>
<span className="inline-flex items-center gap-1 ">
<Terminal className="w-3 h-3" />
CLI
</span>
</Badge>
);
}
}
}
},

View File

@@ -4,6 +4,7 @@ import React from "react";
import { Button } from "@app/components/ui/button";
import { Download } from "lucide-react";
import { FaApple, FaWindows, FaLinux } from "react-icons/fa";
import { SiAndroid } from "react-icons/si";
import { useTranslations } from "next-intl";
import Link from "next/link";
import DismissableBanner from "./DismissableBanner";
@@ -61,6 +62,34 @@ export const ClientDownloadBanner = () => {
Linux
</Button>
</Link>
<Link
href="https://pangolin.net/downloads/ios"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
<FaApple className="w-4 h-4" />
iOS
</Button>
</Link>
<Link
href="https://pangolin.net/downloads/android"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
<SiAndroid className="w-4 h-4" />
Android
</Button>
</Link>
</DismissableBanner>
);
};

View File

@@ -94,12 +94,6 @@ export default function DomainPicker({
const api = createApiClient({ env });
const t = useTranslations();
console.log({
defaultFullDomain,
defaultSubdomain,
defaultDomainId
});
const { data = [], isLoading: loadingDomains } = useQuery(
orgQueries.domains({ orgId })
);
@@ -369,9 +363,6 @@ export default function DomainPicker({
setSelectedProvidedDomain(null);
}
console.log({
setSelectedBaseDomain: option
});
setSelectedBaseDomain(option);
setOpen(false);
@@ -442,9 +433,6 @@ export default function DomainPicker({
0,
providedDomainsShown
);
console.log({
displayedProvidedOptions
});
const selectedDomainNamespaceId =
selectedProvidedDomain?.domainNamespaceId ??

View File

@@ -143,7 +143,6 @@ export default function LoginOrgSelector({
<IdpLoginButtons
idps={idps}
redirect={redirect}
orgId={org.orgId}
/>
</div>
</div>

View File

@@ -1,41 +1,13 @@
"use client";
import * as React from "react";
import * as NProgress from "nprogress";
import NextTopLoader from "nextjs-toploader";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
export function TopLoader() {
return (
<>
<NextTopLoader showSpinner={false} color="var(--color-primary)" />
<FinishingLoader />
</>
);
}
function FinishingLoader() {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
React.useEffect(() => {
NProgress.done();
}, [pathname, router, searchParams]);
React.useEffect(() => {
const linkClickListener = (ev: MouseEvent) => {
const element = ev.target as HTMLElement;
const closestlink = element.closest("a");
const isOpenToNewTabClick =
ev.ctrlKey ||
ev.shiftKey ||
ev.metaKey || // apple
(ev.button && ev.button == 1); // middle click, >IE9 + everyone else
if (closestlink && isOpenToNewTabClick) {
NProgress.done();
}
};
window.addEventListener("click", linkClickListener);
return () => window.removeEventListener("click", linkClickListener);
}, []);
return null;
return (
<NextTopLoader
color="var(--color-primary)"
showSpinner={false}
height={2}
/>
);
}

View File

@@ -28,7 +28,6 @@ import {
TableRow
} from "@app/components/ui/table";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@app/components/ui/tabs";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Loader2, RefreshCw } from "lucide-react";
import moment from "moment";
import { useUserContext } from "@app/hooks/useUserContext";
@@ -59,8 +58,6 @@ export default function ViewDevicesDialog({
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(false);
const [isArchiveModalOpen, setIsArchiveModalOpen] = useState(false);
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
const [activeTab, setActiveTab] = useState<"available" | "archived">("available");
const fetchDevices = async () => {
@@ -108,8 +105,6 @@ export default function ViewDevicesDialog({
d.olmId === olmId ? { ...d, archived: true } : d
)
);
setIsArchiveModalOpen(false);
setSelectedDevice(null);
} catch (error: any) {
console.error("Error archiving device:", error);
toast({
@@ -153,8 +148,6 @@ export default function ViewDevicesDialog({
function reset() {
setDevices([]);
setSelectedDevice(null);
setIsArchiveModalOpen(false);
}
return (
@@ -263,12 +256,7 @@ export default function ViewDevicesDialog({
<Button
variant="outline"
onClick={() => {
setSelectedDevice(
device
);
setIsArchiveModalOpen(
true
);
archiveDevice(device.olmId);
}}
>
{t(
@@ -361,34 +349,6 @@ export default function ViewDevicesDialog({
</CredenzaFooter>
</CredenzaContent>
</Credenza>
{selectedDevice && (
<ConfirmDeleteDialog
open={isArchiveModalOpen}
setOpen={(val) => {
setIsArchiveModalOpen(val);
if (!val) {
setSelectedDevice(null);
}
}}
dialog={
<div className="space-y-2">
<p>
{t("deviceQuestionArchive") ||
"Are you sure you want to archive this device?"}
</p>
<p>
{t("deviceMessageArchive") ||
"The device will be archived and removed from your active devices list."}
</p>
</div>
}
buttonText={t("deviceArchiveConfirm") || "Archive Device"}
onConfirm={async () => archiveDevice(selectedDevice.olmId)}
string={selectedDevice.name || selectedDevice.olmId}
title={t("archiveDevice") || "Archive Device"}
/>
)}
</>
);
}

View File

@@ -0,0 +1,241 @@
import { cn } from "@app/lib/cn";
import type { DockerState } from "@app/lib/docker";
import { parseHostTarget } from "@app/lib/parseHostTarget";
import { CaretSortIcon } from "@radix-ui/react-icons";
import type { ListSitesResponse } from "@server/routers/site";
import { type ListTargetsResponse } from "@server/routers/target";
import type { ArrayElement } from "@server/types/ArrayElement";
import { CheckIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { ContainersSelector } from "./ContainersSelector";
import { Button } from "./ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "./ui/command";
import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
type SiteWithUpdateAvailable = ListSitesResponse["sites"][number];
export type LocalTarget = Omit<
ArrayElement<ListTargetsResponse["targets"]> & {
new?: boolean;
updated?: boolean;
siteType: string | null;
},
"protocol"
>;
export type ResourceTargetAddressItemProps = {
getDockerStateForSite: (siteId: number) => DockerState;
updateTarget: (targetId: number, data: Partial<LocalTarget>) => void;
sites: SiteWithUpdateAvailable[];
proxyTarget: LocalTarget;
isHttp: boolean;
refreshContainersForSite: (siteId: number) => void;
};
export function ResourceTargetAddressItem({
sites,
getDockerStateForSite,
updateTarget,
proxyTarget,
isHttp,
refreshContainersForSite
}: ResourceTargetAddressItemProps) {
const t = useTranslations();
const selectedSite = sites.find(
(site) => site.siteId === proxyTarget.siteId
);
const handleContainerSelectForTarget = (
hostname: string,
port?: number
) => {
updateTarget(proxyTarget.targetId, {
...proxyTarget,
ip: hostname,
...(port && { port: port })
});
};
return (
<div className="flex items-center w-full" key={proxyTarget.targetId}>
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input rounded-md">
{selectedSite &&
selectedSite.type === "newt" &&
(() => {
const dockerState = getDockerStateForSite(
selectedSite.siteId
);
return (
<ContainersSelector
site={selectedSite}
containers={dockerState.containers}
isAvailable={dockerState.isAvailable}
onContainerSelect={
handleContainerSelectForTarget
}
onRefresh={() =>
refreshContainersForSite(
selectedSite.siteId
)
}
/>
);
})()}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
className={cn(
"w-45 justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
"rounded-l-md rounded-r-xs",
!proxyTarget.siteId && "text-muted-foreground"
)}
>
<span className="truncate max-w-37.5">
{proxyTarget.siteId
? selectedSite?.name
: t("siteSelect")}
</span>
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-45">
<Command>
<CommandInput placeholder={t("siteSearch")} />
<CommandList>
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
<CommandGroup>
{sites.map((site) => (
<CommandItem
key={site.siteId}
value={`${site.siteId}:${site.name}`}
onSelect={() =>
updateTarget(
proxyTarget.targetId,
{
siteId: site.siteId
}
)
}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
proxyTarget.siteId
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{isHttp && (
<Select
defaultValue={proxyTarget.method ?? "http"}
onValueChange={(value) =>
updateTarget(proxyTarget.targetId, {
...proxyTarget,
method: value
})
}
>
<SelectTrigger className="h-8 px-2 w-17.5 border-none bg-transparent shadow-none data-[state=open]:bg-transparent rounded-xs">
{proxyTarget.method || "http"}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
)}
{isHttp && (
<div className="flex items-center justify-center px-2 h-9">
{"://"}
</div>
)}
<Input
defaultValue={proxyTarget.ip}
placeholder="Host"
className="flex-1 min-w-30 px-2 border-none placeholder-gray-400 rounded-xs"
onBlur={(e) => {
const input = e.target.value.trim();
const hasProtocol = /^(https?|h2c):\/\//.test(input);
const hasPort = /:\d+(?:\/|$)/.test(input);
if (hasProtocol || hasPort) {
const parsed = parseHostTarget(input);
if (parsed) {
updateTarget(proxyTarget.targetId, {
...proxyTarget,
method: hasProtocol
? parsed.protocol
: proxyTarget.method,
ip: parsed.host,
port: hasPort
? parsed.port
: proxyTarget.port
});
} else {
updateTarget(proxyTarget.targetId, {
...proxyTarget,
ip: input
});
}
} else {
updateTarget(proxyTarget.targetId, {
...proxyTarget,
ip: input
});
}
}}
/>
<div className="flex items-center justify-center px-2 h-9">
{":"}
</div>
<Input
placeholder="Port"
defaultValue={
proxyTarget.port === 0 ? "" : proxyTarget.port
}
className="w-18.75 px-2 border-none placeholder-gray-400 rounded-l-xs"
onBlur={(e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value > 0) {
updateTarget(proxyTarget.targetId, {
...proxyTarget,
port: value
});
} else {
updateTarget(proxyTarget.targetId, {
...proxyTarget,
port: 0
});
}
}}
/>
</div>
</div>
);
}

View File

@@ -44,8 +44,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"focus-visible:outline-none focus-visible:border-ring focus-visible:ring-offset-0",
className
)}
ref={ref}

View File

@@ -36,7 +36,9 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 w-full",
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer flex items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 w-full",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0",
// "focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0",
className
)}
{...props}
@@ -60,7 +62,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-sm",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-sm",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
@@ -73,7 +75,7 @@ function SelectContent({
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1"
)}
>
{children}