mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-24 17:52:33 +00:00
Compare commits
1 Commits
button-to-
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4fd2b656d |
2
.github/workflows/mirror.yaml
vendored
2
.github/workflows/mirror.yaml
vendored
@@ -23,7 +23,7 @@ jobs:
|
|||||||
skopeo --version
|
skopeo --version
|
||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||||
|
|
||||||
- name: Input check
|
- name: Input check
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
39
.github/workflows/restart-runners.yml
vendored
Normal file
39
.github/workflows/restart-runners.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Restart Runners
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 */7 * *'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ec2-maintenance-prod:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions: write-all
|
||||||
|
steps:
|
||||||
|
- name: Configure AWS credentials
|
||||||
|
uses: aws-actions/configure-aws-credentials@v6
|
||||||
|
with:
|
||||||
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||||
|
role-duration-seconds: 3600
|
||||||
|
aws-region: ${{ secrets.AWS_REGION }}
|
||||||
|
|
||||||
|
- name: Verify AWS identity
|
||||||
|
run: aws sts get-caller-identity
|
||||||
|
|
||||||
|
- name: Start EC2 instance
|
||||||
|
run: |
|
||||||
|
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
|
||||||
|
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
|
||||||
|
echo "EC2 instances started"
|
||||||
|
|
||||||
|
- name: Wait
|
||||||
|
run: sleep 600
|
||||||
|
|
||||||
|
- name: Stop EC2 instance
|
||||||
|
run: |
|
||||||
|
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
|
||||||
|
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
|
||||||
|
echo "EC2 instances stopped"
|
||||||
160
.github/workflows/saas.yml
vendored
Normal file
160
.github/workflows/saas.yml
vendored
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
name: SAAS Pipeline
|
||||||
|
|
||||||
|
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
|
||||||
|
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write # for GHCR push
|
||||||
|
id-token: write # for Cosign Keyless (OIDC) Signing
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "[0-9]+.[0-9]+.[0-9]+-s.[0-9]+"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pre-run:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions: write-all
|
||||||
|
steps:
|
||||||
|
- name: Configure AWS credentials
|
||||||
|
uses: aws-actions/configure-aws-credentials@v6
|
||||||
|
with:
|
||||||
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||||
|
role-duration-seconds: 3600
|
||||||
|
aws-region: ${{ secrets.AWS_REGION }}
|
||||||
|
|
||||||
|
- name: Verify AWS identity
|
||||||
|
run: aws sts get-caller-identity
|
||||||
|
|
||||||
|
- name: Start EC2 instances
|
||||||
|
run: |
|
||||||
|
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
|
||||||
|
echo "EC2 instances started"
|
||||||
|
|
||||||
|
|
||||||
|
release-arm:
|
||||||
|
name: Build and Release (ARM64)
|
||||||
|
runs-on: [self-hosted, linux, arm64, us-east-1]
|
||||||
|
needs: [pre-run]
|
||||||
|
if: >-
|
||||||
|
${{
|
||||||
|
needs.pre-run.result == 'success'
|
||||||
|
}}
|
||||||
|
# Job-level timeout to avoid runaway or stuck runs
|
||||||
|
timeout-minutes: 120
|
||||||
|
env:
|
||||||
|
# Target images
|
||||||
|
AWS_IMAGE: ${{ secrets.aws_account_id }}.dkr.ecr.us-east-1.amazonaws.com/${{ github.event.repository.name }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
- name: Download MaxMind GeoLite2 databases
|
||||||
|
env:
|
||||||
|
MAXMIND_LICENSE_KEY: ${{ secrets.MAXMIND_LICENSE_KEY }}
|
||||||
|
run: |
|
||||||
|
echo "Downloading MaxMind GeoLite2 databases..."
|
||||||
|
|
||||||
|
# Download GeoLite2-Country
|
||||||
|
curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
|
||||||
|
-o GeoLite2-Country.tar.gz
|
||||||
|
|
||||||
|
# Download GeoLite2-ASN
|
||||||
|
curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
|
||||||
|
-o GeoLite2-ASN.tar.gz
|
||||||
|
|
||||||
|
# Extract the .mmdb files
|
||||||
|
tar -xzf GeoLite2-Country.tar.gz --strip-components=1 --wildcards '*.mmdb'
|
||||||
|
tar -xzf GeoLite2-ASN.tar.gz --strip-components=1 --wildcards '*.mmdb'
|
||||||
|
|
||||||
|
# Verify files exist
|
||||||
|
if [ ! -f "GeoLite2-Country.mmdb" ]; then
|
||||||
|
echo "ERROR: Failed to download GeoLite2-Country.mmdb"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "GeoLite2-ASN.mmdb" ]; then
|
||||||
|
echo "ERROR: Failed to download GeoLite2-ASN.mmdb"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean up tar files
|
||||||
|
rm -f GeoLite2-Country.tar.gz GeoLite2-ASN.tar.gz
|
||||||
|
|
||||||
|
echo "MaxMind databases downloaded successfully"
|
||||||
|
ls -lh GeoLite2-*.mmdb
|
||||||
|
|
||||||
|
- name: Monitor storage space
|
||||||
|
run: |
|
||||||
|
THRESHOLD=75
|
||||||
|
USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g')
|
||||||
|
echo "Used space: $USED_SPACE%"
|
||||||
|
if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then
|
||||||
|
echo "Used space is below the threshold of 75% free. Running Docker system prune."
|
||||||
|
echo y | docker system prune -a
|
||||||
|
else
|
||||||
|
echo "Storage space is above the threshold. No action needed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Configure AWS credentials
|
||||||
|
uses: aws-actions/configure-aws-credentials@v6
|
||||||
|
with:
|
||||||
|
role-to-assume: arn:aws:iam::${{ secrets.aws_account_id }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||||
|
role-duration-seconds: 3600
|
||||||
|
aws-region: ${{ secrets.AWS_REGION }}
|
||||||
|
|
||||||
|
- name: Login to Amazon ECR
|
||||||
|
id: login-ecr
|
||||||
|
uses: aws-actions/amazon-ecr-login@v2
|
||||||
|
|
||||||
|
- name: Extract tag name
|
||||||
|
id: get-tag
|
||||||
|
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Update version in package.json
|
||||||
|
run: |
|
||||||
|
TAG=${{ env.TAG }}
|
||||||
|
sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts
|
||||||
|
cat server/lib/consts.ts
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Build and push Docker images (Docker Hub - ARM64)
|
||||||
|
run: |
|
||||||
|
TAG=${{ env.TAG }}
|
||||||
|
make build-saas tag=$TAG
|
||||||
|
echo "Built & pushed ARM64 images to: ${{ env.AWS_IMAGE }}:${TAG}"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
post-run:
|
||||||
|
needs: [pre-run, release-arm]
|
||||||
|
if: >-
|
||||||
|
${{
|
||||||
|
always() &&
|
||||||
|
needs.pre-run.result == 'success' &&
|
||||||
|
(needs.release-arm.result == 'success' || needs.release-arm.result == 'skipped' || needs.release-arm.result == 'failure')
|
||||||
|
}}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions: write-all
|
||||||
|
steps:
|
||||||
|
- name: Configure AWS credentials
|
||||||
|
uses: aws-actions/configure-aws-credentials@v6
|
||||||
|
with:
|
||||||
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||||
|
role-duration-seconds: 3600
|
||||||
|
aws-region: ${{ secrets.AWS_REGION }}
|
||||||
|
|
||||||
|
- name: Verify AWS identity
|
||||||
|
run: aws sts get-caller-identity
|
||||||
|
|
||||||
|
- name: Stop EC2 instances
|
||||||
|
run: |
|
||||||
|
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
|
||||||
|
echo "EC2 instances stopped"
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"flag"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -69,9 +68,6 @@ const (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
|
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
|
||||||
|
|
||||||
fmt.Println("Welcome to the Pangolin installer!")
|
fmt.Println("Welcome to the Pangolin installer!")
|
||||||
@@ -210,7 +206,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if *crowdsecFlag && !checkIsCrowdsecInstalledInCompose() {
|
if !checkIsCrowdsecInstalledInCompose() {
|
||||||
fmt.Println("\n=== CrowdSec Install ===")
|
fmt.Println("\n=== CrowdSec Install ===")
|
||||||
// check if crowdsec is installed
|
// check if crowdsec is installed
|
||||||
if readBool("Would you like to install CrowdSec?", false) {
|
if readBool("Would you like to install CrowdSec?", false) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
userOrgRoles,
|
userOrgRoles,
|
||||||
userSiteResources
|
userSiteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { and, count, eq, inArray, ne } from "drizzle-orm";
|
import { and, eq, inArray, ne } from "drizzle-orm";
|
||||||
|
|
||||||
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
|
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
|
||||||
import {
|
import {
|
||||||
@@ -39,11 +39,6 @@ import {
|
|||||||
removePeerData,
|
removePeerData,
|
||||||
removeTargets as removeSubnetProxyTargets
|
removeTargets as removeSubnetProxyTargets
|
||||||
} from "@server/routers/client/targets";
|
} from "@server/routers/client/targets";
|
||||||
import { lockManager } from "#dynamic/lib/lock";
|
|
||||||
|
|
||||||
// TTL for rebuild-association locks. These functions can fan out into many
|
|
||||||
// peer/proxy updates, so give them a generous window.
|
|
||||||
const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000;
|
|
||||||
|
|
||||||
export async function getClientSiteResourceAccess(
|
export async function getClientSiteResourceAccess(
|
||||||
siteResource: SiteResource,
|
siteResource: SiteResource,
|
||||||
@@ -166,23 +161,6 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
pubKey: string | null;
|
pubKey: string | null;
|
||||||
subnet: string | null;
|
subnet: string | null;
|
||||||
}[];
|
}[];
|
||||||
}> {
|
|
||||||
return await lockManager.withLock(
|
|
||||||
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
|
|
||||||
() => rebuildClientAssociationsFromSiteResourceImpl(siteResource, trx),
|
|
||||||
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rebuildClientAssociationsFromSiteResourceImpl(
|
|
||||||
siteResource: SiteResource,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<{
|
|
||||||
mergedAllClients: {
|
|
||||||
clientId: number;
|
|
||||||
pubKey: string | null;
|
|
||||||
subnet: string | null;
|
|
||||||
}[];
|
|
||||||
}> {
|
}> {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
|
||||||
@@ -561,29 +539,6 @@ async function handleMessagesForSiteClients(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// get the number of sites on each of these clients so we can log it and make decisions about whether to send messages based on it
|
|
||||||
const clientSiteCounts: Record<number, number> = {};
|
|
||||||
if (clientsToProcess.size > 0) {
|
|
||||||
const clientIdsToProcess = Array.from(clientsToProcess.keys());
|
|
||||||
const siteCounts = await trx
|
|
||||||
.select({
|
|
||||||
clientId: clientSitesAssociationsCache.clientId,
|
|
||||||
siteCount: count(clientSitesAssociationsCache.siteId)
|
|
||||||
})
|
|
||||||
.from(clientSitesAssociationsCache)
|
|
||||||
.where(
|
|
||||||
inArray(
|
|
||||||
clientSitesAssociationsCache.clientId,
|
|
||||||
clientIdsToProcess
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.groupBy(clientSitesAssociationsCache.clientId);
|
|
||||||
|
|
||||||
for (const row of siteCounts) {
|
|
||||||
clientSiteCounts[row.clientId] = Number(row.siteCount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const client of clientsToProcess.values()) {
|
for (const client of clientsToProcess.values()) {
|
||||||
// UPDATE THE NEWT
|
// UPDATE THE NEWT
|
||||||
if (!client.subnet || !client.pubKey) {
|
if (!client.subnet || !client.pubKey) {
|
||||||
@@ -627,14 +582,7 @@ async function handleMessagesForSiteClients(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAdd) {
|
if (isAdd) {
|
||||||
if (clientSiteCounts[client.clientId] > 250) {
|
// TODO: if we are in jit mode here should we really be sending this?
|
||||||
// skip adding the peer if we have more than 250 sites because we are in jit mode anyway
|
|
||||||
logger.info(
|
|
||||||
`rebuildClientAssociations: Client ${client.clientId} has ${clientSiteCounts[client.clientId]} sites so skipping adding peer to newt and olm because it is likely in jit mode`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
// this will kick off the add peer process for the client
|
||||||
client.clientId,
|
client.clientId,
|
||||||
@@ -652,24 +600,9 @@ async function handleMessagesForSiteClients(
|
|||||||
exitNodeJobs.push(updateClientSiteDestinations(client, trx));
|
exitNodeJobs.push(updateClientSiteDestinations(client, trx));
|
||||||
}
|
}
|
||||||
|
|
||||||
Promise.all(exitNodeJobs).catch((error) => {
|
await Promise.all(exitNodeJobs);
|
||||||
logger.error(
|
await Promise.all(newtJobs); // do the servers first to make sure they are ready?
|
||||||
`rebuildClientAssociations: Error updating client site destinations for site ${site.siteId}:`,
|
await Promise.all(olmJobs);
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
Promise.all(newtJobs).catch((error) => {
|
|
||||||
logger.error(
|
|
||||||
`rebuildClientAssociations: Error updating Newt peers for site ${site.siteId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
Promise.all(olmJobs).catch((error) => {
|
|
||||||
logger.error(
|
|
||||||
`rebuildClientAssociations: Error updating Olm peers for site ${site.siteId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PeerDestination {
|
interface PeerDestination {
|
||||||
@@ -952,17 +885,6 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
export async function rebuildClientAssociationsFromClient(
|
export async function rebuildClientAssociationsFromClient(
|
||||||
client: Client,
|
client: Client,
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
|
||||||
return await lockManager.withLock(
|
|
||||||
`rebuild-client-associations:client:${client.clientId}`,
|
|
||||||
() => rebuildClientAssociationsFromClientImpl(client, trx),
|
|
||||||
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rebuildClientAssociationsFromClientImpl(
|
|
||||||
client: Client,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let newSiteResourceIds: number[] = [];
|
let newSiteResourceIds: number[] = [];
|
||||||
|
|
||||||
@@ -1235,12 +1157,6 @@ async function handleMessagesForClientSites(
|
|||||||
const olmJobs: Promise<any>[] = [];
|
const olmJobs: Promise<any>[] = [];
|
||||||
const exitNodeJobs: Promise<any>[] = [];
|
const exitNodeJobs: Promise<any>[] = [];
|
||||||
|
|
||||||
const totalSitesOnClient = await trx
|
|
||||||
.select({ count: count(clientSitesAssociationsCache.siteId) })
|
|
||||||
.from(clientSitesAssociationsCache)
|
|
||||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId))
|
|
||||||
.then((rows) => Number(rows[0].count));
|
|
||||||
|
|
||||||
for (const siteData of sitesData) {
|
for (const siteData of sitesData) {
|
||||||
const site = siteData.sites;
|
const site = siteData.sites;
|
||||||
const exitNode = siteData.exitNodes;
|
const exitNode = siteData.exitNodes;
|
||||||
@@ -1301,14 +1217,7 @@ async function handleMessagesForClientSites(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (totalSitesOnClient > 250) {
|
// TODO: if we are in jit mode here should we really be sending this?
|
||||||
// skip adding the site if we have more than 250 because we are in jit mode anyway
|
|
||||||
logger.info(
|
|
||||||
`rebuildClientAssociations: Client ${client.clientId} has ${totalSitesOnClient} sites so skipping adding peer to newt and olm because it is likely in jit mode`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
// this will kick off the add peer process for the client
|
||||||
client.clientId,
|
client.clientId,
|
||||||
@@ -1336,24 +1245,9 @@ async function handleMessagesForClientSites(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Promise.all(exitNodeJobs).catch((error) => {
|
await Promise.all(exitNodeJobs);
|
||||||
logger.error(
|
await Promise.all(newtJobs);
|
||||||
`rebuildClientAssociations: Error updating client site destinations for client ${client.clientId}:`,
|
await Promise.all(olmJobs);
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
Promise.all(newtJobs).catch((error) => {
|
|
||||||
logger.error(
|
|
||||||
`rebuildClientAssociations: Error updating Newt peers for client ${client.clientId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
Promise.all(olmJobs).catch((error) => {
|
|
||||||
logger.error(
|
|
||||||
`rebuildClientAssociations: Error updating Olm peers for client ${client.clientId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMessagesForClientResources(
|
async function handleMessagesForClientResources(
|
||||||
@@ -1634,195 +1528,3 @@ async function handleMessagesForClientResources(
|
|||||||
|
|
||||||
await Promise.all([...proxyJobs, ...olmJobs]);
|
await Promise.all([...proxyJobs, ...olmJobs]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClientAssociationsCacheVerification = {
|
|
||||||
clientId: number;
|
|
||||||
consistent: boolean;
|
|
||||||
// What permissions say the cache should contain
|
|
||||||
expectedSiteResourceIds: number[];
|
|
||||||
expectedSiteIds: number[];
|
|
||||||
// What the cache currently contains
|
|
||||||
actualSiteResourceIds: number[];
|
|
||||||
actualSiteIds: number[];
|
|
||||||
// Diff
|
|
||||||
missingSiteResourceIds: number[]; // present in expected, missing from cache
|
|
||||||
extraSiteResourceIds: number[]; // present in cache, not in expected
|
|
||||||
missingSiteIds: number[];
|
|
||||||
extraSiteIds: number[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// verifyClientAssociationsCache walks the same permission-derivation logic as
|
|
||||||
// rebuildClientAssociationsFromClient but does NOT modify the database. It
|
|
||||||
// returns the expected vs actual cache contents and a boolean indicating
|
|
||||||
// whether the cache is in sync with what permissions imply.
|
|
||||||
export async function verifyClientAssociationsCache(
|
|
||||||
client: Client,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<ClientAssociationsCacheVerification> {
|
|
||||||
let newSiteResourceIds: number[] = [];
|
|
||||||
|
|
||||||
// 1. Direct client associations
|
|
||||||
const directSiteResources = await trx
|
|
||||||
.select({ siteResourceId: clientSiteResources.siteResourceId })
|
|
||||||
.from(clientSiteResources)
|
|
||||||
.innerJoin(
|
|
||||||
siteResources,
|
|
||||||
eq(siteResources.siteResourceId, clientSiteResources.siteResourceId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(clientSiteResources.clientId, client.clientId),
|
|
||||||
eq(siteResources.orgId, client.orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
newSiteResourceIds.push(
|
|
||||||
...directSiteResources.map((r) => r.siteResourceId)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. User-based and role-based access (if client has a userId)
|
|
||||||
if (client.userId) {
|
|
||||||
const userSiteResourceIds = await trx
|
|
||||||
.select({ siteResourceId: userSiteResources.siteResourceId })
|
|
||||||
.from(userSiteResources)
|
|
||||||
.innerJoin(
|
|
||||||
siteResources,
|
|
||||||
eq(
|
|
||||||
siteResources.siteResourceId,
|
|
||||||
userSiteResources.siteResourceId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userSiteResources.userId, client.userId),
|
|
||||||
eq(siteResources.orgId, client.orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
newSiteResourceIds.push(
|
|
||||||
...userSiteResourceIds.map((r) => r.siteResourceId)
|
|
||||||
);
|
|
||||||
|
|
||||||
const roleIds = await trx
|
|
||||||
.select({ roleId: userOrgRoles.roleId })
|
|
||||||
.from(userOrgRoles)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userOrgRoles.userId, client.userId),
|
|
||||||
eq(userOrgRoles.orgId, client.orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.then((rows) => rows.map((row) => row.roleId));
|
|
||||||
|
|
||||||
if (roleIds.length > 0) {
|
|
||||||
const roleSiteResourceIds = await trx
|
|
||||||
.select({ siteResourceId: roleSiteResources.siteResourceId })
|
|
||||||
.from(roleSiteResources)
|
|
||||||
.innerJoin(
|
|
||||||
siteResources,
|
|
||||||
eq(
|
|
||||||
siteResources.siteResourceId,
|
|
||||||
roleSiteResources.siteResourceId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(roleSiteResources.roleId, roleIds),
|
|
||||||
eq(siteResources.orgId, client.orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
newSiteResourceIds.push(
|
|
||||||
...roleSiteResourceIds.map((r) => r.siteResourceId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
newSiteResourceIds = Array.from(new Set(newSiteResourceIds));
|
|
||||||
|
|
||||||
const newSiteResources =
|
|
||||||
newSiteResourceIds.length > 0
|
|
||||||
? await trx
|
|
||||||
.select()
|
|
||||||
.from(siteResources)
|
|
||||||
.where(
|
|
||||||
inArray(siteResources.siteResourceId, newSiteResourceIds)
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const networkIds = Array.from(
|
|
||||||
new Set(
|
|
||||||
newSiteResources
|
|
||||||
.map((sr) => sr.networkId)
|
|
||||||
.filter((id): id is number => id !== null)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const newSiteIds =
|
|
||||||
networkIds.length > 0
|
|
||||||
? await trx
|
|
||||||
.select({ siteId: siteNetworks.siteId })
|
|
||||||
.from(siteNetworks)
|
|
||||||
.where(inArray(siteNetworks.networkId, networkIds))
|
|
||||||
.then((rows) =>
|
|
||||||
Array.from(new Set(rows.map((r) => r.siteId)))
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Read the existing cache state
|
|
||||||
const existingResourceAssociations = await trx
|
|
||||||
.select({
|
|
||||||
siteResourceId: clientSiteResourcesAssociationsCache.siteResourceId
|
|
||||||
})
|
|
||||||
.from(clientSiteResourcesAssociationsCache)
|
|
||||||
.where(
|
|
||||||
eq(clientSiteResourcesAssociationsCache.clientId, client.clientId)
|
|
||||||
);
|
|
||||||
const existingSiteResourceIds = existingResourceAssociations.map(
|
|
||||||
(r) => r.siteResourceId
|
|
||||||
);
|
|
||||||
|
|
||||||
const existingSiteAssociations = await trx
|
|
||||||
.select({ siteId: clientSitesAssociationsCache.siteId })
|
|
||||||
.from(clientSitesAssociationsCache)
|
|
||||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
|
||||||
const existingSiteIds = existingSiteAssociations.map((s) => s.siteId);
|
|
||||||
|
|
||||||
const expectedSiteResourceSet = new Set(newSiteResourceIds);
|
|
||||||
const actualSiteResourceSet = new Set(existingSiteResourceIds);
|
|
||||||
const expectedSiteSet = new Set(newSiteIds);
|
|
||||||
const actualSiteSet = new Set(existingSiteIds);
|
|
||||||
|
|
||||||
const missingSiteResourceIds = newSiteResourceIds.filter(
|
|
||||||
(id) => !actualSiteResourceSet.has(id)
|
|
||||||
);
|
|
||||||
const extraSiteResourceIds = existingSiteResourceIds.filter(
|
|
||||||
(id) => !expectedSiteResourceSet.has(id)
|
|
||||||
);
|
|
||||||
const missingSiteIds = newSiteIds.filter((id) => !actualSiteSet.has(id));
|
|
||||||
const extraSiteIds = existingSiteIds.filter(
|
|
||||||
(id) => !expectedSiteSet.has(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
const consistent =
|
|
||||||
missingSiteResourceIds.length === 0 &&
|
|
||||||
extraSiteResourceIds.length === 0 &&
|
|
||||||
missingSiteIds.length === 0 &&
|
|
||||||
extraSiteIds.length === 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
clientId: client.clientId,
|
|
||||||
consistent,
|
|
||||||
expectedSiteResourceIds: Array.from(expectedSiteResourceSet).sort(
|
|
||||||
(a, b) => a - b
|
|
||||||
),
|
|
||||||
expectedSiteIds: Array.from(expectedSiteSet).sort((a, b) => a - b),
|
|
||||||
actualSiteResourceIds: Array.from(actualSiteResourceSet).sort(
|
|
||||||
(a, b) => a - b
|
|
||||||
),
|
|
||||||
actualSiteIds: Array.from(actualSiteSet).sort((a, b) => a - b),
|
|
||||||
missingSiteResourceIds: missingSiteResourceIds.sort((a, b) => a - b),
|
|
||||||
extraSiteResourceIds: extraSiteResourceIds.sort((a, b) => a - b),
|
|
||||||
missingSiteIds: missingSiteIds.sort((a, b) => a - b),
|
|
||||||
extraSiteIds: extraSiteIds.sort((a, b) => a - b)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
|
|||||||
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
||||||
import * as alertRule from "#private/routers/alertRule";
|
import * as alertRule from "#private/routers/alertRule";
|
||||||
import * as healthChecks from "#private/routers/healthChecks";
|
import * as healthChecks from "#private/routers/healthChecks";
|
||||||
import * as client from "@server/routers/client";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -776,15 +775,3 @@ authenticated.get(
|
|||||||
verifyUserHasAction(ActionsEnum.getTarget),
|
verifyUserHasAction(ActionsEnum.getTarget),
|
||||||
healthChecks.getHealthCheckStatusHistory
|
healthChecks.getHealthCheckStatusHistory
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/client/:clientId/verify-associations-cache",
|
|
||||||
verifyClientAccess,
|
|
||||||
client.verifyClientAssociationsCache
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/client/:clientId/rebuild-associations-cache",
|
|
||||||
verifyClientAccess,
|
|
||||||
client.rebuildClientAssociationsCacheRoute
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { eq, InferInsertModel } from "drizzle-orm";
|
import { eq, InferInsertModel } from "drizzle-orm";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
||||||
import config from "#private/lib/config";
|
import config from "#private/lib/config";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
@@ -33,7 +34,79 @@ const paramsSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const bodySchema = z.strictObject({
|
const bodySchema = z.strictObject({
|
||||||
logoUrl: z.string().optional(),
|
logoUrl: z
|
||||||
|
.union([
|
||||||
|
z.literal(""),
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.superRefine(async (urlOrPath, ctx) => {
|
||||||
|
const parseResult = z.url().safeParse(urlOrPath);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
if (build !== "enterprise") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Must be a valid URL"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
validateLocalPath(urlOrPath);
|
||||||
|
} catch (error) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(urlOrPath, {
|
||||||
|
method: "HEAD"
|
||||||
|
}).catch(() => {
|
||||||
|
// If HEAD fails (CORS or method not allowed), try GET
|
||||||
|
return fetch(urlOrPath, { 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) {
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
])
|
||||||
|
.transform((val) => (val === "" ? null : val))
|
||||||
|
.nullish(),
|
||||||
logoWidth: z.coerce.number<number>().min(1),
|
logoWidth: z.coerce.number<number>().min(1),
|
||||||
logoHeight: z.coerce.number<number>().min(1),
|
logoHeight: z.coerce.number<number>().min(1),
|
||||||
resourceTitle: z.string(),
|
resourceTitle: z.string(),
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
logsDb,
|
logsDb,
|
||||||
newts,
|
newts,
|
||||||
roles,
|
roles,
|
||||||
roleSiteResources,
|
|
||||||
roundTripMessageTracker,
|
roundTripMessageTracker,
|
||||||
siteResources,
|
siteResources,
|
||||||
siteNetworks,
|
siteNetworks,
|
||||||
@@ -362,26 +361,9 @@ export async function signSshKey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const roleRows = await db
|
const roleRows = await db
|
||||||
.select({
|
.select()
|
||||||
sshSudoCommands: roles.sshSudoCommands,
|
|
||||||
sshUnixGroups: roles.sshUnixGroups,
|
|
||||||
sshCreateHomeDir: roles.sshCreateHomeDir,
|
|
||||||
sshSudoMode: roles.sshSudoMode
|
|
||||||
})
|
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.innerJoin(
|
.where(inArray(roles.roleId, roleIds));
|
||||||
roleSiteResources,
|
|
||||||
eq(roleSiteResources.roleId, roles.roleId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(roles.roleId, roleIds),
|
|
||||||
eq(
|
|
||||||
roleSiteResources.siteResourceId,
|
|
||||||
resource.siteResourceId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const parsedSudoCommands: string[] = [];
|
const parsedSudoCommands: string[] = [];
|
||||||
const parsedGroupsSet = new Set<string>();
|
const parsedGroupsSet = new Set<string>();
|
||||||
@@ -397,17 +379,13 @@ export async function signSshKey(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
|
const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
|
||||||
if (Array.isArray(grps))
|
if (Array.isArray(grps)) grps.forEach((g: string) => parsedGroupsSet.add(g));
|
||||||
grps.forEach((g: string) => parsedGroupsSet.add(g));
|
|
||||||
} catch {
|
} catch {
|
||||||
// skip
|
// skip
|
||||||
}
|
}
|
||||||
if (roleRow?.sshCreateHomeDir === true) homedir = true;
|
if (roleRow?.sshCreateHomeDir === true) homedir = true;
|
||||||
const m = roleRow?.sshSudoMode ?? "none";
|
const m = roleRow?.sshSudoMode ?? "none";
|
||||||
if (
|
if (sudoModeOrder[m as keyof typeof sudoModeOrder] > sudoModeOrder[sudoMode]) {
|
||||||
sudoModeOrder[m as keyof typeof sudoModeOrder] >
|
|
||||||
sudoModeOrder[sudoMode]
|
|
||||||
) {
|
|
||||||
sudoMode = m as "none" | "commands" | "full";
|
sudoMode = m as "none" | "commands" | "full";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,3 @@ export * from "./listUserDevices";
|
|||||||
export * from "./updateClient";
|
export * from "./updateClient";
|
||||||
export * from "./getClient";
|
export * from "./getClient";
|
||||||
export * from "./createUserClient";
|
export * from "./createUserClient";
|
||||||
export * from "./verifyClientAssociationsCache";
|
|
||||||
export * from "./rebuildClientAssociationsCacheRoute";
|
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { db } from "@server/db";
|
|
||||||
import { clients } from "@server/db";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
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";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
clientId: z.string().transform(Number).pipe(z.int().positive())
|
|
||||||
});
|
|
||||||
|
|
||||||
registry.registerPath({
|
|
||||||
method: "post",
|
|
||||||
path: "/client/{clientId}/rebuild-associations-cache",
|
|
||||||
description:
|
|
||||||
"Rebuild the client's site/site-resource association cache based on current permissions.",
|
|
||||||
tags: [OpenAPITags.Client],
|
|
||||||
request: {
|
|
||||||
params: paramsSchema
|
|
||||||
},
|
|
||||||
responses: {}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function rebuildClientAssociationsCacheRoute(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { clientId } = parsedParams.data;
|
|
||||||
|
|
||||||
const [client] = await db
|
|
||||||
.select()
|
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.clientId, clientId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!client) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Client with ID ${clientId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await rebuildClientAssociationsFromClient(client);
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: null,
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Client association cache rebuilt successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Failed to rebuild client association cache"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { db } from "@server/db";
|
|
||||||
import { clients } from "@server/db";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
|
||||||
import { verifyClientAssociationsCache as verifyClientAssociationsCacheLib } from "@server/lib/rebuildClientAssociations";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
clientId: z.string().transform(Number).pipe(z.int().positive())
|
|
||||||
});
|
|
||||||
|
|
||||||
registry.registerPath({
|
|
||||||
method: "get",
|
|
||||||
path: "/client/{clientId}/verify-associations-cache",
|
|
||||||
description:
|
|
||||||
"Read-only check of whether the client's site/site-resource association cache matches what the current permissions imply.",
|
|
||||||
tags: [OpenAPITags.Client],
|
|
||||||
request: {
|
|
||||||
params: paramsSchema
|
|
||||||
},
|
|
||||||
responses: {}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function verifyClientAssociationsCache(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { clientId } = parsedParams.data;
|
|
||||||
|
|
||||||
const [client] = await db
|
|
||||||
.select()
|
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.clientId, clientId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!client) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Client with ID ${clientId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const report = await verifyClientAssociationsCacheLib(client);
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: report,
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: report.consistent
|
|
||||||
? "Client association cache is consistent"
|
|
||||||
: "Client association cache is INCONSISTENT",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Failed to verify client association cache"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -153,65 +153,6 @@ export default function GeneralPage() {
|
|||||||
const [approvalId, setApprovalId] = useState<number | null>(null);
|
const [approvalId, setApprovalId] = useState<number | null>(null);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
const [cacheCheck, setCacheCheck] = useState<null | {
|
|
||||||
consistent: boolean;
|
|
||||||
missingSiteResourceIds: number[];
|
|
||||||
extraSiteResourceIds: number[];
|
|
||||||
missingSiteIds: number[];
|
|
||||||
extraSiteIds: number[];
|
|
||||||
expectedSiteResourceIds: number[];
|
|
||||||
actualSiteResourceIds: number[];
|
|
||||||
expectedSiteIds: number[];
|
|
||||||
actualSiteIds: number[];
|
|
||||||
}>(null);
|
|
||||||
const [isCheckingCache, setIsCheckingCache] = useState(false);
|
|
||||||
const [isRebuildingCache, setIsRebuildingCache] = useState(false);
|
|
||||||
|
|
||||||
const handleRebuildCache = async () => {
|
|
||||||
if (!client.clientId) return;
|
|
||||||
setIsRebuildingCache(true);
|
|
||||||
try {
|
|
||||||
await api.post(
|
|
||||||
`/client/${client.clientId}/rebuild-associations-cache`
|
|
||||||
);
|
|
||||||
// Re-verify after rebuild so the result refreshes
|
|
||||||
const res = await api.get(
|
|
||||||
`/client/${client.clientId}/verify-associations-cache`
|
|
||||||
);
|
|
||||||
setCacheCheck(res.data.data);
|
|
||||||
toast({
|
|
||||||
title: "Cache rebuilt",
|
|
||||||
description: "Association cache rebuilt successfully."
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Rebuild failed",
|
|
||||||
description: formatAxiosError(e, "Failed to rebuild cache")
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsRebuildingCache(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVerifyCache = async () => {
|
|
||||||
if (!client.clientId) return;
|
|
||||||
setIsCheckingCache(true);
|
|
||||||
try {
|
|
||||||
const res = await api.get(
|
|
||||||
`/client/${client.clientId}/verify-associations-cache`
|
|
||||||
);
|
|
||||||
setCacheCheck(res.data.data);
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Cache check failed",
|
|
||||||
description: formatAxiosError(e, "Failed to verify cache")
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsCheckingCache(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
const showApprovalFeatures =
|
const showApprovalFeatures =
|
||||||
@@ -903,75 +844,6 @@ export default function GeneralPage() {
|
|||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hidden cache verification — subtle button, dev/admin diagnostic */}
|
|
||||||
<div className="mt-8 flex flex-col gap-2 items-start opacity-30 hover:opacity-100 transition-opacity">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleVerifyCache}
|
|
||||||
disabled={isCheckingCache}
|
|
||||||
className="text-xs text-muted-foreground underline disabled:opacity-50"
|
|
||||||
title="Verify the client's site association cache against current permissions (read-only)"
|
|
||||||
>
|
|
||||||
{isCheckingCache
|
|
||||||
? "Checking cache…"
|
|
||||||
: "Verify association cache"}
|
|
||||||
</button>
|
|
||||||
{cacheCheck && (
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"text-xs rounded border px-2 py-1 " +
|
|
||||||
(cacheCheck.consistent
|
|
||||||
? "border-green-600 text-green-700"
|
|
||||||
: "border-red-600 text-red-700")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{cacheCheck.consistent ? (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<CheckCircle2 className="h-3 w-3" />
|
|
||||||
Cache is consistent
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-1 font-semibold">
|
|
||||||
<XCircle className="h-3 w-3" />
|
|
||||||
Cache is INCONSISTENT
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Missing site resources: [
|
|
||||||
{cacheCheck.missingSiteResourceIds.join(
|
|
||||||
", "
|
|
||||||
)}
|
|
||||||
]
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Extra site resources: [
|
|
||||||
{cacheCheck.extraSiteResourceIds.join(", ")}
|
|
||||||
]
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Missing sites: [
|
|
||||||
{cacheCheck.missingSiteIds.join(", ")}]
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Extra sites: [
|
|
||||||
{cacheCheck.extraSiteIds.join(", ")}]
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleRebuildCache}
|
|
||||||
disabled={isRebuildingCache}
|
|
||||||
className="mt-1 text-xs underline font-semibold disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isRebuildingCache
|
|
||||||
? "Rebuilding…"
|
|
||||||
: "Rebuild cache now"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,16 @@ const AuthPageFormSchema = z.object({
|
|||||||
let errorMessage =
|
let errorMessage =
|
||||||
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
|
"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({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: errorMessage
|
message: errorMessage
|
||||||
|
|||||||
@@ -61,14 +61,14 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
|||||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{client.online ? (
|
{client.online ? (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="text-green-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
<span>{t("connected")}</span>
|
<span>{t("online")}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="text-neutral-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||||
<span>{t("disconnected")}</span>
|
<span>{t("offline")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ export default function ExitNodeInfoCard({}: ExitNodeInfoCardProps) {
|
|||||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{remoteExitNode.online ? (
|
{remoteExitNode.online ? (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="text-green-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
<span>{t("online")}</span>
|
<span>{t("online")}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="text-neutral-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||||
<span>{t("offline")}</span>
|
<span>{t("offline")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -140,14 +140,14 @@ export default function ExitNodesTable({
|
|||||||
const originalRow = row.original;
|
const originalRow = row.original;
|
||||||
if (originalRow.online) {
|
if (originalRow.online) {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center space-x-2">
|
<span className="text-green-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
<span>{t("online")}</span>
|
<span>{t("online")}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center space-x-2">
|
<span className="text-neutral-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||||
<span>{t("offline")}</span>
|
<span>{t("offline")}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -519,21 +519,21 @@ export default function HealthChecksTable({
|
|||||||
const health = row.original.hcHealth;
|
const health = row.original.hcHealth;
|
||||||
if (health === "healthy") {
|
if (health === "healthy") {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center space-x-2">
|
<span className="text-green-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full" />
|
<div className="w-2 h-2 bg-green-500 rounded-full" />
|
||||||
<span>{t("standaloneHcHealthStateHealthy")}</span>
|
<span>{t("standaloneHcHealthStateHealthy")}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else if (health === "unhealthy") {
|
} else if (health === "unhealthy") {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center space-x-2">
|
<span className="text-red-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-red-500 rounded-full" />
|
<div className="w-2 h-2 bg-red-500 rounded-full" />
|
||||||
<span>{t("standaloneHcHealthStateUnhealthy")}</span>
|
<span>{t("standaloneHcHealthStateUnhealthy")}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center space-x-2">
|
<span className="text-neutral-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-neutral-500 rounded-full" />
|
<div className="w-2 h-2 bg-neutral-500 rounded-full" />
|
||||||
<span>{t("standaloneHcHealthStateUnknown")}</span>
|
<span>{t("standaloneHcHealthStateUnknown")}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -285,14 +285,14 @@ export default function MachineClientsTable({
|
|||||||
const originalRow = row.original;
|
const originalRow = row.original;
|
||||||
if (originalRow.online) {
|
if (originalRow.online) {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center space-x-2">
|
<span className="text-green-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
<span>{t("connected")}</span>
|
<span>{t("connected")}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center space-x-2">
|
<span className="text-neutral-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||||
<span>{t("disconnected")}</span>
|
<span>{t("disconnected")}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -228,14 +228,14 @@ export default function PendingSitesTable({
|
|||||||
) {
|
) {
|
||||||
if (originalRow.online) {
|
if (originalRow.online) {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center space-x-2">
|
<span className="text-green-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
<span>{t("online")}</span>
|
<span>{t("online")}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center space-x-2">
|
<span className="text-neutral-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||||
<span>{t("offline")}</span>
|
<span>{t("offline")}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -89,12 +89,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
<InfoSectionTitle>Socket</InfoSectionTitle>
|
<InfoSectionTitle>Socket</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{isAvailable ? (
|
{isAvailable ? (
|
||||||
<span className="flex items-center space-x-2">
|
<span className="text-green-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
<span>Online</span>
|
<span>Online</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex items-center space-x-2">
|
<span className="text-neutral-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||||
<span>Offline</span>
|
<span>Offline</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
|||||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{site.online ? (
|
{site.online ? (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="text-green-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
<span>{t("online")}</span>
|
<span>{t("online")}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="text-neutral-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||||
<span>{t("offline")}</span>
|
<span>{t("offline")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -226,14 +226,14 @@ export default function SitesTable({
|
|||||||
) {
|
) {
|
||||||
if (originalRow.online) {
|
if (originalRow.online) {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center space-x-2">
|
<span className="text-green-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
<span>{t("online")}</span>
|
<span>{t("online")}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center space-x-2">
|
<span className="text-neutral-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||||
<span>{t("offline")}</span>
|
<span>{t("offline")}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -436,14 +436,14 @@ export default function UserDevicesTable({
|
|||||||
const originalRow = row.original;
|
const originalRow = row.original;
|
||||||
if (originalRow.online) {
|
if (originalRow.online) {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center space-x-2">
|
<span className="text-green-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
<span>{t("connected")}</span>
|
<span>{t("connected")}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center space-x-2">
|
<span className="text-neutral-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||||
<span>{t("disconnected")}</span>
|
<span>{t("disconnected")}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user