Compare commits
276 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b4e52a725 | ||
|
|
e41eafd497 | ||
|
|
d70396a664 | ||
|
|
3d59556bcd | ||
|
|
c7018e92b0 | ||
|
|
a575bace39 | ||
|
|
2047aa30e1 | ||
|
|
3b10453af3 | ||
|
|
3257edc2a0 | ||
|
|
cd54e7dd38 | ||
|
|
9177eaba22 | ||
|
|
33ae2e08cc | ||
|
|
4fc61386d3 | ||
|
|
c409266954 | ||
|
|
57315a36ee | ||
|
|
63637b91a8 | ||
|
|
09238cd98a | ||
|
|
67b149ce4b | ||
|
|
96151de814 | ||
|
|
f2e461a1ee | ||
|
|
8125622c98 | ||
|
|
1a6942ccc9 | ||
|
|
7b0e1df778 | ||
|
|
7d6b114d67 | ||
|
|
a169256770 | ||
|
|
2e54afd72f | ||
|
|
26207bd951 | ||
|
|
3ed681e277 | ||
|
|
c135b5e3cf | ||
|
|
e648307f0b | ||
|
|
0e4f35e87a | ||
|
|
553dffd4ee | ||
|
|
c1fd38ac39 | ||
|
|
33e2798313 | ||
|
|
f0cb65f65c | ||
|
|
9bb6cb14a6 | ||
|
|
b6f67e0f0b | ||
|
|
980545c636 | ||
|
|
92135ff9c1 | ||
|
|
ab843b1a43 | ||
|
|
4593edbb45 | ||
|
|
96b451843c | ||
|
|
54aa3ce7d8 | ||
|
|
45a70152ee | ||
|
|
8c5f00a446 | ||
|
|
af98610d0d | ||
|
|
875ec662ad | ||
|
|
8800ec9675 | ||
|
|
df4da75c57 | ||
|
|
717dfae26c | ||
|
|
58a2a9dcc9 | ||
|
|
27a0df4ed4 | ||
|
|
6fc6f325a7 | ||
|
|
b46e49922c | ||
|
|
2cca561e51 | ||
|
|
17919192e0 | ||
|
|
9f979c5019 | ||
|
|
c3d2c34279 | ||
|
|
430f187fde | ||
|
|
f438d2ddbf | ||
|
|
6d519af198 | ||
|
|
a34e88257d | ||
|
|
ea0ab0e63c | ||
|
|
80375cd0dc | ||
|
|
f817ba7664 | ||
|
|
3398088e03 | ||
|
|
e586dd50f4 | ||
|
|
5a71c0ba65 | ||
|
|
f13b6abd78 | ||
|
|
34c6b590d7 | ||
|
|
ab797203eb | ||
|
|
30e8b1f0fe | ||
|
|
d03bee98f5 | ||
|
|
fa365fb7b8 | ||
|
|
ea1cd4b0d4 | ||
|
|
be0c7444e9 | ||
|
|
858c809514 | ||
|
|
10ff2c8a65 | ||
|
|
167d0b6867 | ||
|
|
8c121daf6c | ||
|
|
a23d437bd3 | ||
|
|
cd280d1396 | ||
|
|
d18200739a | ||
|
|
a62b2e8d10 | ||
|
|
c92069a1f4 | ||
|
|
c5e37c1608 | ||
|
|
948eb7f6d0 | ||
|
|
62a0104e70 | ||
|
|
6dd8db5cd1 | ||
|
|
9ea7275371 | ||
|
|
c997b8625f | ||
|
|
6f3514199a | ||
|
|
0cfc4d7dad | ||
|
|
56fd366a7d | ||
|
|
23b5dcfbed | ||
|
|
30ebbaaef0 | ||
|
|
b467d6afa1 | ||
|
|
373441b7ab | ||
|
|
825730052b | ||
|
|
edc8716297 | ||
|
|
3ee4aaf194 | ||
|
|
b9a5d486b9 | ||
|
|
dc66ebeed6 | ||
|
|
1f584bf3e8 | ||
|
|
5b0200154a | ||
|
|
1e55d96376 | ||
|
|
a512148348 | ||
|
|
d9eccd6c13 | ||
|
|
492669f68a | ||
|
|
caded23b51 | ||
|
|
e9cc48a3ae | ||
|
|
4ed98c227b | ||
|
|
f66fb7d4a3 | ||
|
|
f25990a9a7 | ||
|
|
21d5b67ef1 | ||
|
|
198810121c | ||
|
|
83c0379c6b | ||
|
|
21f1326045 | ||
|
|
f62e32724c | ||
|
|
5e052a446a | ||
|
|
9a167b5acb | ||
|
|
5d2f3186cc | ||
|
|
e58d10fc53 | ||
|
|
4392bb604c | ||
|
|
5a4a6655a5 | ||
|
|
a20befd89f | ||
|
|
a9f0b9aa38 | ||
|
|
f8e0219b49 | ||
|
|
cb431f3574 | ||
|
|
1ff3a9b2f9 | ||
|
|
237960fc5b | ||
|
|
3ebc01df8c | ||
|
|
81adcd9234 | ||
|
|
cffc156cf6 | ||
|
|
e4af990bf2 | ||
|
|
e236364124 | ||
|
|
f5a3fd7202 | ||
|
|
b3026ba663 | ||
|
|
18e6f16ce7 | ||
|
|
599d0a52bf | ||
|
|
eed6081ade | ||
|
|
c4ae34383d | ||
|
|
c543376a0a | ||
|
|
a5b782b72a | ||
|
|
4819f410e6 | ||
|
|
4084849fdc | ||
|
|
35e5f39c71 | ||
|
|
80d76befc9 | ||
|
|
893244100e | ||
|
|
2a43b3ce4a | ||
|
|
b82754c7af | ||
|
|
8793d3976d | ||
|
|
6e833d4cee | ||
|
|
b3d0b69c04 | ||
|
|
28ac5e1237 | ||
|
|
8990de5618 | ||
|
|
6aeddde1cd | ||
|
|
c3dbc64a58 | ||
|
|
2a00c877ea | ||
|
|
91b4bb4683 | ||
|
|
f4fd33b47f | ||
|
|
d6d6a59eee | ||
|
|
4dba75f913 | ||
|
|
548a883e3f | ||
|
|
a6d6aaaadd | ||
|
|
566e66daa4 | ||
|
|
97af632c61 | ||
|
|
5d6e15b0d6 | ||
|
|
419bacf55f | ||
|
|
960eb34c7d | ||
|
|
6f59d0cd2d | ||
|
|
6fd1dbc638 | ||
|
|
87915f29f6 | ||
|
|
181071e4f6 | ||
|
|
feb558cfa8 | ||
|
|
9ea7c43212 | ||
|
|
38528ae8c5 | ||
|
|
c837899d82 | ||
|
|
7938b419cc | ||
|
|
bf8bb1a0df | ||
|
|
957fa67e24 | ||
|
|
b4c6897850 | ||
|
|
e2f056e6ca | ||
|
|
8fa719181a | ||
|
|
b4fda6a1f6 | ||
|
|
99188233db | ||
|
|
3bab90891f | ||
|
|
8c0e4d2d8c | ||
|
|
3e94384cde | ||
|
|
189b739997 | ||
|
|
334fc55dd0 | ||
|
|
ab933d48de | ||
|
|
36b62a5fe4 | ||
|
|
08752820fc | ||
|
|
787ec50a9c | ||
|
|
65b29161a0 | ||
|
|
f60f15345f | ||
|
|
c286c28d46 | ||
|
|
8fb003d7ce | ||
|
|
35daf42a55 | ||
|
|
976aaca287 | ||
|
|
0454f09383 | ||
|
|
6b5674a107 | ||
|
|
45a75d0bee | ||
|
|
12f627711c | ||
|
|
442775ac90 | ||
|
|
01da3b3225 | ||
|
|
51ac815b23 | ||
|
|
285ad45a0e | ||
|
|
4707722e6e | ||
|
|
499f75edd1 | ||
|
|
57b96adcd0 | ||
|
|
eb9675c6cf | ||
|
|
b59c6e377a | ||
|
|
432f38333e | ||
|
|
e86640547e | ||
|
|
25c125b96d | ||
|
|
aa3b527f67 | ||
|
|
bacd5a4373 | ||
|
|
53be2739bb | ||
|
|
4a42aa385a | ||
|
|
ac8e315fbd | ||
|
|
7556a59e11 | ||
|
|
8b0c30f19f | ||
|
|
b731a50cc9 | ||
|
|
2398931cc1 | ||
|
|
419e576a3e | ||
|
|
1a750e8279 | ||
|
|
f14379a1c8 | ||
|
|
521bbbf1d6 | ||
|
|
cb775340a4 | ||
|
|
31bd42f964 | ||
|
|
e64e7d1d92 | ||
|
|
480a5f648d | ||
|
|
9cb215295a | ||
|
|
764c56c4a1 | ||
|
|
e057c5f3bf | ||
|
|
bc8cd5c941 | ||
|
|
6350edf8fd | ||
|
|
8e8fdabd03 | ||
|
|
2883d8c544 | ||
|
|
dd8c426faa | ||
|
|
64a2cc23c6 | ||
|
|
ec33fe5657 | ||
|
|
56b3b2ab3b | ||
|
|
a436dff4a0 | ||
|
|
cf80d67bf8 | ||
|
|
e24edc0803 | ||
|
|
d89ca10a82 | ||
|
|
d9e6d0c71a | ||
|
|
517bc7f632 | ||
|
|
674316aa46 | ||
|
|
7a55c9ad03 | ||
|
|
c7f3c9da92 | ||
|
|
be77b3e8f3 | ||
|
|
d7f50bac6a | ||
|
|
3ccfe60685 | ||
|
|
40040af957 | ||
|
|
1568b38eac | ||
|
|
7fd1652a71 | ||
|
|
787a172a7c | ||
|
|
23a68fbc10 | ||
|
|
f078ee6051 | ||
|
|
0450f62108 | ||
|
|
b2faeb3c17 | ||
|
|
9ea37789d6 | ||
|
|
aa45150c51 | ||
|
|
a708750fea | ||
|
|
d260450a84 | ||
|
|
a76e3e00f7 | ||
|
|
a33ebe5bc5 | ||
|
|
b75f848b90 | ||
|
|
c4e62a7aee | ||
|
|
c903c03979 | ||
|
|
d7b9755f3a | ||
|
|
e17bf0db13 |
35
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
groups:
|
||||
dev-patch-updates:
|
||||
dependency-type: "development"
|
||||
update-types:
|
||||
- "patch"
|
||||
dev-minor-updates:
|
||||
dependency-type: "development"
|
||||
update-types:
|
||||
- "minor"
|
||||
prod-patch-updates:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "patch"
|
||||
prod-minor-updates:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "minor"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
groups:
|
||||
patch-updates:
|
||||
update-types:
|
||||
- "patch"
|
||||
minor-updates:
|
||||
update-types:
|
||||
- "minor"
|
||||
4
.github/workflows/stale-bot.yml
vendored
@@ -16,9 +16,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
days-before-stale: 30
|
||||
days-before-stale: 14
|
||||
days-before-close: 14
|
||||
stale-issue-message: 'This issue has been automatically marked as stale due to 30 days of inactivity. It will be closed in 14 days if no further activity occurs.'
|
||||
stale-issue-message: 'This issue has been automatically marked as stale due to 14 days of inactivity. It will be closed in 14 days if no further activity occurs.'
|
||||
close-issue-message: 'This issue has been automatically closed due to inactivity. If you believe this is still relevant, please open a new issue with up-to-date information.'
|
||||
stale-issue-label: 'stale'
|
||||
|
||||
|
||||
1
.gitignore
vendored
@@ -32,3 +32,4 @@ installer
|
||||
bin
|
||||
.secrets
|
||||
test_event.json
|
||||
.idea/
|
||||
18
Dockerfile
@@ -2,14 +2,17 @@ FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
# COPY package.json package-lock.json ./
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npx drizzle-kit generate --dialect sqlite --schema ./server/db/schemas/ --out init
|
||||
RUN echo 'export * from "./sqlite";' > server/db/index.ts
|
||||
|
||||
RUN npm run build
|
||||
RUN npx drizzle-kit generate --dialect sqlite --schema ./server/db/sqlite/schema.ts --out init
|
||||
|
||||
RUN npm run build:sqlite
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
@@ -18,8 +21,9 @@ WORKDIR /app
|
||||
# Curl used for the health checks
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
# COPY package.json package-lock.json ./
|
||||
COPY package.json ./
|
||||
RUN npm install --only=production && npm cache clean --force
|
||||
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
@@ -30,4 +34,4 @@ COPY server/db/names.json ./dist/names.json
|
||||
|
||||
COPY public ./public
|
||||
|
||||
CMD ["npm", "start"]
|
||||
CMD ["npm", "run", "start:sqlite"]
|
||||
|
||||
37
Dockerfile.pg
Normal file
@@ -0,0 +1,37 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# COPY package.json package-lock.json ./
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN echo 'export * from "./pg";' > server/db/index.ts
|
||||
|
||||
RUN npx drizzle-kit generate --dialect postgresql --schema ./server/db/pg/schema.ts --out init
|
||||
|
||||
RUN npm run build:pg
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Curl used for the health checks
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
# COPY package.json package-lock.json ./
|
||||
COPY package.json ./
|
||||
RUN npm install --only=production && npm cache clean --force
|
||||
|
||||
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 server/db/names.json ./dist/names.json
|
||||
|
||||
COPY public ./public
|
||||
|
||||
CMD ["npm", "run", "start:pg"]
|
||||
2
Makefile
@@ -5,6 +5,8 @@ build-release:
|
||||
fi
|
||||
docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:latest -f Dockerfile --push .
|
||||
docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:$(tag) -f Dockerfile --push .
|
||||
docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:postgresql-latest -f Dockerfile.pg --push .
|
||||
docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:postgresql-$(tag) -f Dockerfile.pg --push .
|
||||
|
||||
build-arm:
|
||||
docker buildx build --platform linux/arm64 -t fosrl/pangolin:latest .
|
||||
|
||||
111
README.md
@@ -1,15 +1,13 @@
|
||||
<div align="center">
|
||||
<h2 align="center"><a href="https://fossorial.io"><img alt="pangolin" src="public/logo//word_mark.png" width="400" /></a></h2>
|
||||
|
||||
[](https://docs.fossorial.io/)
|
||||
[](https://hub.docker.com/r/fosrl/pangolin)
|
||||

|
||||
[](https://discord.gg/HCJR8Xhme4)
|
||||
[](https://www.youtube.com/@fossorial-app)
|
||||
|
||||
<h2>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="public/logo/word_mark_white.png">
|
||||
<img alt="Pangolin Logo" src="public/logo/word_mark_black.png" width="250">
|
||||
</picture>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<h3 align="center">Tunneled Mesh Reverse Proxy Server with Access Control</h3>
|
||||
<h4 align="center">Tunneled Reverse Proxy Server with Access Control</h4>
|
||||
<div align="center">
|
||||
|
||||
_Your own self-hosted zero trust tunnel._
|
||||
@@ -30,57 +28,68 @@ _Your own self-hosted zero trust tunnel._
|
||||
Contact Us
|
||||
</a>
|
||||
</h5>
|
||||
|
||||
[](https://hub.docker.com/r/fosrl/pangolin)
|
||||

|
||||
[](https://discord.gg/HCJR8Xhme4)
|
||||
[](https://www.youtube.com/@fossorial-app)
|
||||
|
||||
</div>
|
||||
|
||||
Pangolin is a self-hosted tunneled reverse proxy server with identity and access control, designed to securely expose private resources on distributed networks. Acting as a central hub, it connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports.
|
||||
|
||||
<img src="public/screenshots/sites.png" alt="Preview"/>
|
||||
<img src="public/screenshots/hero.png" alt="Preview"/>
|
||||
|
||||
_Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected to the central server._
|
||||
_Resources page of Pangolin dashboard (dark mode) showing multiple resources available to connect._
|
||||
|
||||
## Key Features
|
||||
|
||||
### Reverse Proxy Through WireGuard Tunnel
|
||||
|
||||
- Expose private resources on your network **without opening ports** (firewall punching).
|
||||
- Secure and easy to configure site-to-site connectivity via a custom **user space WireGuard client**, [Newt](https://github.com/fosrl/newt).
|
||||
- Built-in support for any WireGuard client.
|
||||
- Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/).
|
||||
- Support for HTTP/HTTPS and **raw TCP/UDP services**.
|
||||
- Load balancing.
|
||||
- Expose private resources on your network **without opening ports** (firewall punching).
|
||||
- Secure and easy to configure site-to-site connectivity via a custom **user space WireGuard client**, [Newt](https://github.com/fosrl/newt).
|
||||
- Built-in support for any WireGuard client.
|
||||
- Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/).
|
||||
- Support for HTTP/HTTPS and **raw TCP/UDP services**.
|
||||
- Load balancing.
|
||||
|
||||
### Identity & Access Management
|
||||
|
||||
- Centralized authentication system using platform SSO. **Users will only have to manage one login.**
|
||||
- **Define access control rules for IPs, IP ranges, and URL paths per resource.**
|
||||
- TOTP with backup codes for two-factor authentication.
|
||||
- Create organizations, each with multiple sites, users, and roles.
|
||||
- **Role-based access control** to manage resource access permissions.
|
||||
- Additional authentication options include:
|
||||
- Email whitelisting with **one-time passcodes.**
|
||||
- **Temporary, self-destructing share links.**
|
||||
- Resource specific pin codes.
|
||||
- Resource specific passwords.
|
||||
- Centralized authentication system using platform SSO. **Users will only have to manage one login.**
|
||||
- **Define access control rules for IPs, IP ranges, and URL paths per resource.**
|
||||
- TOTP with backup codes for two-factor authentication.
|
||||
- Create organizations, each with multiple sites, users, and roles.
|
||||
- **Role-based access control** to manage resource access permissions.
|
||||
- Additional authentication options include:
|
||||
- Email whitelisting with **one-time passcodes.**
|
||||
- **Temporary, self-destructing share links.**
|
||||
- Resource specific pin codes.
|
||||
- Resource specific passwords.
|
||||
- External identity provider (IdP) support with OAuth2/OIDC, such as Authentik, Keycloak, Okta, and others.
|
||||
- Auto-provision users and roles from your IdP.
|
||||
|
||||
### Simple Dashboard UI
|
||||
|
||||
- Manage sites, users, and roles with a clean and intuitive UI.
|
||||
- Monitor site usage and connectivity.
|
||||
- Light and dark mode options.
|
||||
- Mobile friendly.
|
||||
- Manage sites, users, and roles with a clean and intuitive UI.
|
||||
- Monitor site usage and connectivity.
|
||||
- Light and dark mode options.
|
||||
- Mobile friendly.
|
||||
|
||||
### Easy Deployment
|
||||
|
||||
- Run on any cloud provider or on-premises.
|
||||
- **Docker Compose based setup** for simplified deployment.
|
||||
- Future-proof installation script for streamlined setup and feature additions.
|
||||
- Use any WireGuard client to connect, or use **Newt, our custom user space client** for the best experience.
|
||||
- Run on any cloud provider or on-premises.
|
||||
- **Docker Compose based setup** for simplified deployment.
|
||||
- Future-proof installation script for streamlined setup and feature additions.
|
||||
- Use any WireGuard client to connect, or use **Newt, our custom user space client** for the best experience.
|
||||
- Use the API to create custom integrations and scripts.
|
||||
- Fine-grained access control to the API via scoped API keys.
|
||||
- Comprehensive Swagger documentation for the API.
|
||||
|
||||
### Modular Design
|
||||
|
||||
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin) and [Geoblock](github.com/PascalMinder/geoblock).
|
||||
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin) and [Geoblock](https://github.com/PascalMinder/geoblock).
|
||||
- **Automatically install and configure Crowdsec via Pangolin's installer script.**
|
||||
- Attach as many sites to the central server as you wish.
|
||||
- Attach as many sites to the central server as you wish.
|
||||
|
||||
<img src="public/screenshots/collage.png" alt="Collage"/>
|
||||
|
||||
@@ -88,22 +97,22 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
||||
|
||||
1. **Deploy the Central Server**:
|
||||
|
||||
- Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like RackNerd, Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
|
||||
|
||||
- Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like RackNerd, Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
|
||||
|
||||
> [!TIP]
|
||||
> Many of our users have had a great experience with [RackNerd](https://my.racknerd.com/aff.php?aff=13788). Depending on promotions, you can likely get a **VPS with 1 vCPU, 1GB RAM, and ~20GB SSD for just around $12/year**. That's a great deal!
|
||||
> Many of our users have had a great experience with [RackNerd](https://my.racknerd.com/aff.php?aff=13788). Depending on promotions, you can get a [**VPS with 1 vCPU, 1GB RAM, and ~20GB SSD for just around $12/year**](https://my.racknerd.com/aff.php?aff=13788&pid=912). That's a great deal!
|
||||
> We are part of the [RackNerd](https://my.racknerd.com/aff.php?aff=13788) affiliate program, so if you purchase through [our link](https://my.racknerd.com/aff.php?aff=13788), we receive a small commission which helps us maintain the project and keep it free for everyone.
|
||||
|
||||
2. **Domain Configuration**:
|
||||
1. **Domain Configuration**:
|
||||
|
||||
- Point your domain name to the VPS and configure Pangolin with your preferred settings.
|
||||
|
||||
3. **Connect Private Sites**:
|
||||
2. **Connect Private Sites**:
|
||||
|
||||
- Install Newt or use another WireGuard client on private sites.
|
||||
- Automatically establish a connection from these sites to the central server.
|
||||
|
||||
4. **Expose Resources**:
|
||||
3. **Expose Resources**:
|
||||
|
||||
- Add resources to the central server and configure access control rules.
|
||||
- Access these resources securely from anywhere.
|
||||
@@ -111,21 +120,19 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
||||
**Use Case Example - Bypassing Port Restrictions in Home Lab**:
|
||||
Imagine private sites where the ISP restricts port forwarding. By connecting these sites to Pangolin via WireGuard, you can securely expose HTTP and HTTPS resources on the private network without any networking complexity.
|
||||
|
||||
**Use Case Example - Deploying Services For Your Business**:
|
||||
You can use Pangolin as an easy way to expose your business applications to your users behind a safe authentication portal you can integrate into your IdP solution. Expose resources on prem and on the cloud.
|
||||
|
||||
**Use Case Example - IoT Networks**:
|
||||
IoT networks are often fragmented and difficult to manage. By deploying Pangolin on a central server, you can connect all your IoT sites via Newt or another WireGuard client. This creates a simple, secure, and centralized way to access IoT resources without the need for intricate networking setups.
|
||||
|
||||
|
||||
<img src="public/screenshots/resources.png" alt="Resources"/>
|
||||
|
||||
_Resources page of Pangolin dashboard (dark mode) showing HTTPS and TCP resources with access control rules._
|
||||
|
||||
## Similar Projects and Inspirations
|
||||
|
||||
**Cloudflare Tunnels**:
|
||||
A similar approach to proxying private resources securely, but Pangolin is a self-hosted alternative, giving you full control over your infrastructure.
|
||||
A similar approach to proxying private resources securely, but Pangolin is a self-hosted alternative, giving you full control over your infrastructure.
|
||||
|
||||
**Authentik and Authelia**:
|
||||
These projects inspired Pangolin’s centralized authentication system for proxies, enabling robust user and role management.
|
||||
**Authelia**:
|
||||
This inspired Pangolin’s centralized authentication system for proxies, enabling robust user and role management.
|
||||
|
||||
## Project Development / Roadmap
|
||||
|
||||
@@ -136,7 +143,7 @@ View the [project board](https://github.com/orgs/fosrl/projects/1) for more deta
|
||||
|
||||
## Licensing
|
||||
|
||||
Pangolin is dual licensed under the AGPL-3 and the Fossorial Commercial license. To see our commercial offerings, please see our [website](https://fossorial.io) for details. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io).
|
||||
Pangolin is dual licensed under the AGPL-3 and the Fossorial Commercial license. Please see the [LICENSE](./LICENSE) file in the repository for details. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io).
|
||||
|
||||
## Contributions
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ server:
|
||||
internal_hostname: "pangolin"
|
||||
session_cookie_name: "p_session_token"
|
||||
resource_access_token_param: "p_token"
|
||||
secret: "your_secret_key_here"
|
||||
resource_access_token_headers:
|
||||
id: "P-Access-Token-Id"
|
||||
token: "P-Access-Token"
|
||||
|
||||
@@ -10,7 +10,7 @@ services:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"]
|
||||
interval: "3s"
|
||||
timeout: "3s"
|
||||
retries: 5
|
||||
retries: 15
|
||||
|
||||
gerbil:
|
||||
image: fosrl/gerbil:latest
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
- 80:80 # Port for traefik because of the network_mode
|
||||
|
||||
traefik:
|
||||
image: traefik:v3.3.3
|
||||
image: traefik:v3.4.0
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||
|
||||
12
drizzle.pg.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "postgresql",
|
||||
schema: path.join("server", "db", "pg", "schema.ts"),
|
||||
out: path.join("server", "migrations"),
|
||||
verbose: true,
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL as string
|
||||
}
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "sqlite",
|
||||
schema: path.join("server", "db", "schemas"),
|
||||
schema: path.join("server", "db", "sqlite", "schema.ts"),
|
||||
out: path.join("server", "migrations"),
|
||||
verbose: true,
|
||||
dbCredentials: {
|
||||
@@ -52,6 +52,7 @@ esbuild
|
||||
bundle: true,
|
||||
outfile: argv.out,
|
||||
format: "esm",
|
||||
minify: true,
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// eslint.config.js
|
||||
export default [
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
tseslint.configs.recommended,
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
|
||||
rules: {
|
||||
semi: "error",
|
||||
"prefer-const": "error"
|
||||
}
|
||||
}
|
||||
];
|
||||
);
|
||||
|
||||
@@ -22,10 +22,11 @@ server:
|
||||
id: "P-Access-Token-Id"
|
||||
token: "P-Access-Token"
|
||||
resource_session_request_param: "p_session_request"
|
||||
secret: {{.Secret}}
|
||||
cors:
|
||||
origins: ["https://{{.DashboardDomain}}"]
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||
headers: ["X-CSRF-Token", "Content-Type"]
|
||||
allowed_headers: ["X-CSRF-Token", "Content-Type"]
|
||||
credentials: false
|
||||
|
||||
traefik:
|
||||
|
||||
6
install/config/crowdsec/acquis.d/appsec.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
listen_addr: 0.0.0.0:7422
|
||||
appsec_config: crowdsecurity/appsec-default
|
||||
name: myAppSecComponent
|
||||
source: appsec
|
||||
labels:
|
||||
type: appsec
|
||||
5
install/config/crowdsec/acquis.d/traefik.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
poll_without_inotify: false
|
||||
filenames:
|
||||
- /var/log/traefik/*.log
|
||||
labels:
|
||||
type: traefik
|
||||
@@ -1,18 +0,0 @@
|
||||
filenames:
|
||||
- /var/log/auth.log
|
||||
- /var/log/syslog
|
||||
labels:
|
||||
type: syslog
|
||||
---
|
||||
poll_without_inotify: false
|
||||
filenames:
|
||||
- /var/log/traefik/*.log
|
||||
labels:
|
||||
type: traefik
|
||||
---
|
||||
listen_addr: 0.0.0.0:7422
|
||||
appsec_config: crowdsecurity/appsec-default
|
||||
name: myAppSecComponent
|
||||
source: appsec
|
||||
labels:
|
||||
type: appsec
|
||||
@@ -7,9 +7,11 @@ services:
|
||||
COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
|
||||
ENROLL_INSTANCE_NAME: "pangolin-crowdsec"
|
||||
PARSERS: crowdsecurity/whitelists
|
||||
ACQUIRE_FILES: "/var/log/traefik/*.log"
|
||||
ENROLL_TAGS: docker
|
||||
healthcheck:
|
||||
interval: 10s
|
||||
retries: 15
|
||||
timeout: 10s
|
||||
test: ["CMD", "cscli", "capi", "status"]
|
||||
labels:
|
||||
- "traefik.enable=false" # Disable traefik for crowdsec
|
||||
@@ -18,13 +20,8 @@ services:
|
||||
- ./config/crowdsec:/etc/crowdsec # crowdsec config
|
||||
- ./config/crowdsec/db:/var/lib/crowdsec/data # crowdsec db
|
||||
# log bind mounts into crowdsec
|
||||
- ./config/crowdsec_logs/auth.log:/var/log/auth.log:ro # auth.log
|
||||
- ./config/crowdsec_logs/syslog:/var/log/syslog:ro # syslog
|
||||
- ./config/crowdsec_logs:/var/log # crowdsec logs
|
||||
- ./config/traefik/logs:/var/log/traefik # traefik logs
|
||||
ports:
|
||||
- 6060:6060 # metrics endpoint for prometheus
|
||||
expose:
|
||||
- 6060 # metrics endpoint for prometheus
|
||||
restart: unless-stopped
|
||||
command: -t # Add test config flag to verify configuration
|
||||
command: -t # Add test config flag to verify configuration
|
||||
|
||||
@@ -42,6 +42,7 @@ http:
|
||||
crowdsecAppsecHost: crowdsec:7422 # CrowdSec IP address which you noted down later
|
||||
crowdsecAppsecFailureBlock: true # Block on failure
|
||||
crowdsecAppsecUnreachableBlock: true # Block on unreachable
|
||||
crowdsecAppsecBodyLimit: 10485760
|
||||
crowdsecLapiKey: "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK" # CrowdSec API key which you noted down later
|
||||
crowdsecLapiHost: crowdsec:8080 # CrowdSec
|
||||
crowdsecLapiScheme: http # CrowdSec API scheme
|
||||
|
||||
@@ -16,11 +16,15 @@ experimental:
|
||||
version: "{{.BadgerVersion}}"
|
||||
crowdsec: # CrowdSec plugin configuration added
|
||||
moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
version: "v1.3.5"
|
||||
version: "v1.4.2"
|
||||
|
||||
log:
|
||||
level: "INFO"
|
||||
format: "json" # Log format changed to json for better parsing
|
||||
maxSize: 100
|
||||
maxBackups: 3
|
||||
maxAge: 3
|
||||
compress: true
|
||||
|
||||
accessLog: # We enable access logs as json
|
||||
filePath: "/var/log/traefik/access.log"
|
||||
|
||||
@@ -8,9 +8,9 @@ services:
|
||||
- ./config:/app/config
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"]
|
||||
interval: "3s"
|
||||
timeout: "3s"
|
||||
retries: 5
|
||||
interval: "10s"
|
||||
timeout: "10s"
|
||||
retries: 15
|
||||
{{if .InstallGerbil}}
|
||||
gerbil:
|
||||
image: fosrl/gerbil:{{.GerbilVersion}}
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
- 80:80 # Port for traefik because of the network_mode
|
||||
{{end}}
|
||||
traefik:
|
||||
image: traefik:v3.3.3
|
||||
image: traefik:v3.4.1
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
{{if .InstallGerbil}}
|
||||
@@ -58,4 +58,4 @@ services:
|
||||
networks:
|
||||
default:
|
||||
driver: bridge
|
||||
name: pangolin
|
||||
name: pangolin
|
||||
|
||||
@@ -18,6 +18,10 @@ experimental:
|
||||
log:
|
||||
level: "INFO"
|
||||
format: "common"
|
||||
maxSize: 100
|
||||
maxBackups: 3
|
||||
maxAge: 3
|
||||
compress: true
|
||||
|
||||
certificatesResolvers:
|
||||
letsencrypt:
|
||||
|
||||
@@ -3,9 +3,12 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func installCrowdsec(config Config) error {
|
||||
@@ -25,7 +28,7 @@ func installCrowdsec(config Config) error {
|
||||
}
|
||||
|
||||
os.MkdirAll("config/crowdsec/db", 0755)
|
||||
os.MkdirAll("config/crowdsec_logs/syslog", 0755)
|
||||
os.MkdirAll("config/crowdsec/acquis.d", 0755)
|
||||
os.MkdirAll("config/traefik/logs", 0755)
|
||||
|
||||
if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil {
|
||||
@@ -63,6 +66,12 @@ func installCrowdsec(config Config) error {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// check and add the service dependency of crowdsec to traefik
|
||||
if err := CheckAndAddCrowdsecDependency("docker-compose.yml"); err != nil {
|
||||
fmt.Printf("Error adding crowdsec dependency to traefik: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := startContainers(); err != nil {
|
||||
return fmt.Errorf("failed to start containers: %v", err)
|
||||
}
|
||||
@@ -135,3 +144,58 @@ func checkIfTextInFile(file, text string) bool {
|
||||
// Check for text
|
||||
return bytes.Contains(content, []byte(text))
|
||||
}
|
||||
|
||||
func CheckAndAddCrowdsecDependency(composePath string) error {
|
||||
// Read the docker-compose.yml file
|
||||
data, err := os.ReadFile(composePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading compose file: %w", err)
|
||||
}
|
||||
|
||||
// Parse YAML into a generic map
|
||||
var compose map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &compose); err != nil {
|
||||
return fmt.Errorf("error parsing compose file: %w", err)
|
||||
}
|
||||
|
||||
// Get services section
|
||||
services, ok := compose["services"].(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("services section not found or invalid")
|
||||
}
|
||||
|
||||
// Get traefik service
|
||||
traefik, ok := services["traefik"].(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("traefik service not found or invalid")
|
||||
}
|
||||
|
||||
// Get dependencies
|
||||
dependsOn, ok := traefik["depends_on"].(map[string]interface{})
|
||||
if ok {
|
||||
// Append the new block for crowdsec
|
||||
dependsOn["crowdsec"] = map[string]interface{}{
|
||||
"condition": "service_healthy",
|
||||
}
|
||||
} else {
|
||||
// No dependencies exist, create it
|
||||
traefik["depends_on"] = map[string]interface{}{
|
||||
"crowdsec": map[string]interface{}{
|
||||
"condition": "service_healthy",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Marshal the modified data back to YAML with indentation
|
||||
modifiedData, err := MarshalYAMLWithIndent(compose, 2) // Set indentation to 2 spaces
|
||||
if err != nil {
|
||||
log.Fatalf("error marshaling YAML: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(composePath, modifiedData, 0644); err != nil {
|
||||
return fmt.Errorf("error writing updated compose file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Added dependency of crowdsec to traefik")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ module installer
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/term v0.28.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
golang.org/x/term v0.28.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.29.0 // indirect
|
||||
|
||||
@@ -2,6 +2,7 @@ golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
323
install/main.go
@@ -9,6 +9,7 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
@@ -16,6 +17,8 @@ import (
|
||||
"text/template"
|
||||
"time"
|
||||
"unicode"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
@@ -50,26 +53,37 @@ type Config struct {
|
||||
InstallGerbil bool
|
||||
TraefikBouncerKey string
|
||||
DoCrowdsecInstall bool
|
||||
Secret string
|
||||
}
|
||||
|
||||
func main() {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
// check if the user is root
|
||||
if os.Geteuid() != 0 {
|
||||
fmt.Println("This script must be run as root")
|
||||
// check if docker is not installed and the user is root
|
||||
if !isDockerInstalled() {
|
||||
if os.Geteuid() != 0 {
|
||||
fmt.Println("Docker is not installed. Please install Docker manually or run this installer as root.")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// check if the user is in the docker group (linux only)
|
||||
if !isUserInDockerGroup() {
|
||||
fmt.Println("You are not in the docker group.")
|
||||
fmt.Println("The installer will not be able to run docker commands without running it as root.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var config Config
|
||||
config.DoCrowdsecInstall = false
|
||||
|
||||
|
||||
// check if there is already a config file
|
||||
if _, err := os.Stat("config/config.yml"); err != nil {
|
||||
config = collectUserInput(reader)
|
||||
|
||||
|
||||
loadVersions(&config)
|
||||
|
||||
config.DoCrowdsecInstall = false
|
||||
config.Secret = generateRandomSecretKey()
|
||||
|
||||
if err := createConfigFiles(config); err != nil {
|
||||
fmt.Printf("Error creating config files: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -80,6 +94,27 @@ func main() {
|
||||
if !isDockerInstalled() && runtime.GOOS == "linux" {
|
||||
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
|
||||
installDocker()
|
||||
// try to start docker service but ignore errors
|
||||
if err := startDockerService(); err != nil {
|
||||
fmt.Println("Error starting Docker service:", err)
|
||||
} else {
|
||||
fmt.Println("Docker service started successfully!")
|
||||
}
|
||||
// wait 10 seconds for docker to start checking if docker is running every 2 seconds
|
||||
fmt.Println("Waiting for Docker to start...")
|
||||
for i := 0; i < 5; i++ {
|
||||
if isDockerRunning() {
|
||||
fmt.Println("Docker is running!")
|
||||
break
|
||||
}
|
||||
fmt.Println("Docker is not running yet, waiting...")
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
if !isDockerRunning() {
|
||||
fmt.Println("Docker is still not running after 10 seconds. Please check the installation.")
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Docker installed successfully!")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +122,15 @@ func main() {
|
||||
|
||||
if isDockerInstalled() {
|
||||
if readBool(reader, "Would you like to install and start the containers?", true) {
|
||||
pullAndStartContainers()
|
||||
if err := pullContainers(); err != nil {
|
||||
fmt.Println("Error: ", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := startContainers(); err != nil {
|
||||
fmt.Println("Error: ", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -191,7 +234,7 @@ func collectUserInput(reader *bufio.Reader) Config {
|
||||
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
|
||||
config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", "pangolin."+config.BaseDomain)
|
||||
config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "")
|
||||
config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunned connections", true)
|
||||
config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunneled connections", true)
|
||||
|
||||
// Admin user configuration
|
||||
fmt.Println("\n=== Admin User Configuration ===")
|
||||
@@ -386,7 +429,7 @@ func installDocker() error {
|
||||
return fmt.Errorf("failed to detect Linux distribution: %v", err)
|
||||
}
|
||||
osRelease := string(output)
|
||||
|
||||
|
||||
// Detect system architecture
|
||||
archCmd := exec.Command("uname", "-m")
|
||||
archOutput, err := archCmd.Output()
|
||||
@@ -394,7 +437,7 @@ func installDocker() error {
|
||||
return fmt.Errorf("failed to detect system architecture: %v", err)
|
||||
}
|
||||
arch := strings.TrimSpace(string(archOutput))
|
||||
|
||||
|
||||
// Map architecture to Docker's architecture naming
|
||||
var dockerArch string
|
||||
switch arch {
|
||||
@@ -427,24 +470,44 @@ func installDocker() error {
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||
`, dockerArch))
|
||||
case strings.Contains(osRelease, "ID=fedora"):
|
||||
// Detect Fedora version to handle DNF 5 changes
|
||||
versionCmd := exec.Command("bash", "-c", "grep VERSION_ID /etc/os-release | cut -d'=' -f2 | tr -d '\"'")
|
||||
versionOutput, err := versionCmd.Output()
|
||||
var fedoraVersion int
|
||||
if err == nil {
|
||||
if v, parseErr := strconv.Atoi(strings.TrimSpace(string(versionOutput))); parseErr == nil {
|
||||
fedoraVersion = v
|
||||
}
|
||||
}
|
||||
|
||||
// Use appropriate DNF syntax based on version
|
||||
var repoCmd string
|
||||
if fedoraVersion >= 41 {
|
||||
// DNF 5 syntax for Fedora 41+
|
||||
repoCmd = "dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/fedora/docker-ce.repo"
|
||||
} else {
|
||||
// DNF 4 syntax for Fedora < 41
|
||||
repoCmd = "dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo"
|
||||
}
|
||||
|
||||
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
|
||||
dnf -y install dnf-plugins-core &&
|
||||
dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo &&
|
||||
%s &&
|
||||
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||
`))
|
||||
`, repoCmd))
|
||||
case strings.Contains(osRelease, "ID=opensuse") || strings.Contains(osRelease, "ID=\"opensuse-"):
|
||||
installCmd = exec.Command("bash", "-c", `
|
||||
zypper install -y docker docker-compose &&
|
||||
systemctl enable docker
|
||||
`)
|
||||
case strings.Contains(osRelease, "ID=rhel") || strings.Contains(osRelease, "ID=\"rhel"):
|
||||
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
|
||||
installCmd = exec.Command("bash", "-c", `
|
||||
dnf remove -y runc &&
|
||||
dnf -y install yum-utils &&
|
||||
dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo &&
|
||||
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin &&
|
||||
systemctl enable docker
|
||||
`))
|
||||
`)
|
||||
case strings.Contains(osRelease, "ID=amzn"):
|
||||
installCmd = exec.Command("bash", "-c", `
|
||||
yum update -y &&
|
||||
@@ -455,11 +518,26 @@ func installDocker() error {
|
||||
default:
|
||||
return fmt.Errorf("unsupported Linux distribution")
|
||||
}
|
||||
|
||||
installCmd.Stdout = os.Stdout
|
||||
installCmd.Stderr = os.Stderr
|
||||
return installCmd.Run()
|
||||
}
|
||||
|
||||
func startDockerService() error {
|
||||
if runtime.GOOS == "linux" {
|
||||
cmd := exec.Command("systemctl", "enable", "--now", "docker")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
} else if runtime.GOOS == "darwin" {
|
||||
// On macOS, Docker is usually started via the Docker Desktop application
|
||||
fmt.Println("Please start Docker Desktop manually on macOS.")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unsupported operating system for starting Docker service")
|
||||
}
|
||||
|
||||
func isDockerInstalled() bool {
|
||||
cmd := exec.Command("docker", "--version")
|
||||
if err := cmd.Run(); err != nil {
|
||||
@@ -468,162 +546,113 @@ func isDockerInstalled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func getCommandString(useNewStyle bool) string {
|
||||
if useNewStyle {
|
||||
return "'docker compose'"
|
||||
func isUserInDockerGroup() bool {
|
||||
if runtime.GOOS == "darwin" {
|
||||
// Docker group is not applicable on macOS
|
||||
// So we assume that the user can run Docker commands
|
||||
return true
|
||||
}
|
||||
return "'docker-compose'"
|
||||
|
||||
if os.Geteuid() == 0 {
|
||||
return true // Root user can run Docker commands anyway
|
||||
}
|
||||
|
||||
// Check if the current user is in the docker group
|
||||
if dockerGroup, err := user.LookupGroup("docker"); err == nil {
|
||||
if currentUser, err := user.Current(); err == nil {
|
||||
if currentUserGroupIds, err := currentUser.GroupIds(); err == nil {
|
||||
for _, groupId := range currentUserGroupIds {
|
||||
if groupId == dockerGroup.Gid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Eventually, if any of the checks fail, we assume the user cannot run Docker commands
|
||||
return false
|
||||
}
|
||||
|
||||
func pullAndStartContainers() error {
|
||||
fmt.Println("Starting containers...")
|
||||
// isDockerRunning checks if the Docker daemon is running by using the `docker info` command.
|
||||
func isDockerRunning() bool {
|
||||
cmd := exec.Command("docker", "info")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Check which docker compose command is available
|
||||
// executeDockerComposeCommandWithArgs executes the appropriate docker command with arguments supplied
|
||||
func executeDockerComposeCommandWithArgs(args ...string) error {
|
||||
var cmd *exec.Cmd
|
||||
var useNewStyle bool
|
||||
|
||||
if !isDockerInstalled() {
|
||||
return fmt.Errorf("docker is not installed")
|
||||
}
|
||||
|
||||
checkCmd := exec.Command("docker", "compose", "version")
|
||||
if err := checkCmd.Run(); err == nil {
|
||||
useNewStyle = true
|
||||
} else {
|
||||
// Check if docker-compose (old style) is available
|
||||
checkCmd = exec.Command("docker-compose", "version")
|
||||
if err := checkCmd.Run(); err != nil {
|
||||
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to execute docker compose commands
|
||||
executeCommand := func(args ...string) error {
|
||||
var cmd *exec.Cmd
|
||||
if useNewStyle {
|
||||
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||
if err := checkCmd.Run(); err == nil {
|
||||
useNewStyle = false
|
||||
} else {
|
||||
cmd = exec.Command("docker-compose", args...)
|
||||
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available")
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
if useNewStyle {
|
||||
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||
} else {
|
||||
cmd = exec.Command("docker-compose", args...)
|
||||
}
|
||||
|
||||
// Pull containers
|
||||
fmt.Printf("Using %s command to pull containers...\n", getCommandString(useNewStyle))
|
||||
if err := executeCommand("-f", "docker-compose.yml", "pull"); err != nil {
|
||||
return fmt.Errorf("failed to pull containers: %v", err)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// pullContainers pulls the containers using the appropriate command.
|
||||
func pullContainers() error {
|
||||
fmt.Println("Pulling the container images...")
|
||||
|
||||
if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "pull", "--policy", "always"); err != nil {
|
||||
return fmt.Errorf("failed to pull the containers: %v", err)
|
||||
}
|
||||
|
||||
// Start containers
|
||||
fmt.Printf("Using %s command to start containers...\n", getCommandString(useNewStyle))
|
||||
if err := executeCommand("-f", "docker-compose.yml", "up", "-d"); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// startContainers starts the containers using the appropriate command.
|
||||
func startContainers() error {
|
||||
fmt.Println("Starting containers...")
|
||||
if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "up", "-d", "--force-recreate"); err != nil {
|
||||
return fmt.Errorf("failed to start containers: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// bring containers down
|
||||
// stopContainers stops the containers using the appropriate command.
|
||||
func stopContainers() error {
|
||||
fmt.Println("Stopping containers...")
|
||||
|
||||
// Check which docker compose command is available
|
||||
var useNewStyle bool
|
||||
checkCmd := exec.Command("docker", "compose", "version")
|
||||
if err := checkCmd.Run(); err == nil {
|
||||
useNewStyle = true
|
||||
} else {
|
||||
// Check if docker-compose (old style) is available
|
||||
checkCmd = exec.Command("docker-compose", "version")
|
||||
if err := checkCmd.Run(); err != nil {
|
||||
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to execute docker compose commands
|
||||
executeCommand := func(args ...string) error {
|
||||
var cmd *exec.Cmd
|
||||
if useNewStyle {
|
||||
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||
} else {
|
||||
cmd = exec.Command("docker-compose", args...)
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
if err := executeCommand("-f", "docker-compose.yml", "down"); err != nil {
|
||||
|
||||
if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "down"); err != nil {
|
||||
return fmt.Errorf("failed to stop containers: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// just start containers
|
||||
func startContainers() error {
|
||||
fmt.Println("Starting containers...")
|
||||
|
||||
// Check which docker compose command is available
|
||||
var useNewStyle bool
|
||||
checkCmd := exec.Command("docker", "compose", "version")
|
||||
if err := checkCmd.Run(); err == nil {
|
||||
useNewStyle = true
|
||||
} else {
|
||||
// Check if docker-compose (old style) is available
|
||||
checkCmd = exec.Command("docker-compose", "version")
|
||||
if err := checkCmd.Run(); err != nil {
|
||||
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to execute docker compose commands
|
||||
executeCommand := func(args ...string) error {
|
||||
var cmd *exec.Cmd
|
||||
if useNewStyle {
|
||||
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||
} else {
|
||||
cmd = exec.Command("docker-compose", args...)
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
if err := executeCommand("-f", "docker-compose.yml", "up", "-d"); err != nil {
|
||||
return fmt.Errorf("failed to start containers: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// restartContainer restarts a specific container using the appropriate command.
|
||||
func restartContainer(container string) error {
|
||||
fmt.Printf("Restarting %s container...\n", container)
|
||||
|
||||
// Check which docker compose command is available
|
||||
var useNewStyle bool
|
||||
checkCmd := exec.Command("docker", "compose", "version")
|
||||
if err := checkCmd.Run(); err == nil {
|
||||
useNewStyle = true
|
||||
} else {
|
||||
// Check if docker-compose (old style) is available
|
||||
checkCmd = exec.Command("docker-compose", "version")
|
||||
if err := checkCmd.Run(); err != nil {
|
||||
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to execute docker compose commands
|
||||
executeCommand := func(args ...string) error {
|
||||
var cmd *exec.Cmd
|
||||
if useNewStyle {
|
||||
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||
} else {
|
||||
cmd = exec.Command("docker-compose", args...)
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
if err := executeCommand("-f", "docker-compose.yml", "restart", container); err != nil {
|
||||
return fmt.Errorf("failed to restart %s container: %v", container, err)
|
||||
fmt.Println("Restarting containers...")
|
||||
|
||||
if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "restart", container); err != nil {
|
||||
return fmt.Errorf("failed to stop the container \"%s\": %v", container, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -681,3 +710,17 @@ func waitForContainer(containerName string) error {
|
||||
|
||||
return fmt.Errorf("container %s did not start within %v seconds", containerName, maxAttempts*int(retryInterval.Seconds()))
|
||||
}
|
||||
|
||||
func generateRandomSecretKey() string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
const length = 32
|
||||
|
||||
var seededRand *rand.Rand = rand.New(
|
||||
rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
b[i] = charset[seededRand.Intn(len(charset))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
## Authentication Site
|
||||
|
||||
| EN | DE | Notes |
|
||||
| -------------------------------------------------------- | ---------------------------------------------------------------------------------- | ---------- |
|
||||
| Powered by [Pangolin](https://github.com/fosrl/pangolin) | Bereitgestellt von [Pangolin](https://github.com/fosrl/pangolin) | |
|
||||
| Authentication Required | Authentifizierung erforderlich | |
|
||||
| Choose your preferred method to access {resource} | Wählen Sie Ihre bevorzugte Methode, um auf {resource} zuzugreifen | |
|
||||
| PIN | PIN | |
|
||||
| User | Benutzer | |
|
||||
| 6-digit PIN Code | 6-stelliger PIN-Code | pin login |
|
||||
| Login in with PIN | Mit PIN anmelden | pin login |
|
||||
| Email | E-Mail | user login |
|
||||
| Enter your email | Geben Sie Ihre E-Mail-Adresse ein | user login |
|
||||
| Password | Passwort | user login |
|
||||
| Enter your password | Geben Sie Ihr Passwort ein | user login |
|
||||
| Forgot your password? | Passwort vergessen? | user login |
|
||||
| Log in | Anmelden | user login |
|
||||
|
||||
---
|
||||
|
||||
## Login site
|
||||
|
||||
| EN | DE | Notes |
|
||||
|
||||
310
internationalization/tr.md
Normal file
@@ -0,0 +1,310 @@
|
||||
## Authentication Site
|
||||
|
||||
| EN | TR | Notes |
|
||||
| -------------------------------------------------------- | ---------------------------------------------------------------------------------- | ---------- |
|
||||
| Powered by [Pangolin](https://github.com/fosrl/pangolin) | Pangolin Tarafından Destekleniyor | |
|
||||
| Authentication Required | Kimlik Doğrulaması Gerekli | |
|
||||
| Choose your preferred method to access {resource} | {resource}'a erişmek için tercih ettiğiniz yöntemi seçin | |
|
||||
| PIN | PIN | |
|
||||
| User | Kullanıcı | |
|
||||
| 6-digit PIN Code | 6 haneli PIN Kodu | pin login |
|
||||
| Login in with PIN | PIN ile Giriş Yap | pin login |
|
||||
| Email | E-posta | user login |
|
||||
| Enter your email | E-postanızı girin | user login |
|
||||
| Password | Şifre | user login |
|
||||
| Enter your password | Şifrenizi girin | user login |
|
||||
| Forgot your password? | Şifrenizi mi unuttunuz? | user login |
|
||||
| Log in | Giriş Yap | user login |
|
||||
|
||||
---
|
||||
|
||||
## Login site
|
||||
|
||||
| EN | TR | Notes |
|
||||
| --------------------- | ------------------------------------------------------ | ----------- |
|
||||
| Welcome to Pangolin | Pangolin'e Hoşgeldiniz | |
|
||||
| Log in to get started | Başlamak için giriş yapın | |
|
||||
| Email | E-posta | |
|
||||
| Enter your email | E-posta adresinizi girin | placeholder |
|
||||
| Password | Şifre | |
|
||||
| Enter your password | Şifrenizi girin | placeholder |
|
||||
| Forgot your password? | Şifrenizi mi unuttunuz? | |
|
||||
| Log in | Giriş Yap | |
|
||||
|
||||
---
|
||||
|
||||
# Organization site after successful login
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------- | ----- |
|
||||
| Welcome to Pangolin | Pangolin'e Hoşgeldiniz | |
|
||||
| You're a member of {number} organization. | {number} organizasyonunun üyesiniz. | |
|
||||
|
||||
---
|
||||
|
||||
## Shared Header, Navbar and Footer
|
||||
|
||||
##### Header
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------- | -------------------------- | ----- |
|
||||
| Documentation | Dokümantasyon | |
|
||||
| Support | Destek | |
|
||||
| Organization {name} | Organizasyon {name} | |
|
||||
|
||||
##### Organization selector
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ---------------- | ---------------------- | ----- |
|
||||
| Search… | Ara… | |
|
||||
| Create | Oluştur | |
|
||||
| New Organization | Yeni Organizasyon | |
|
||||
| Organizations | Organizasyonlar | |
|
||||
|
||||
##### Navbar
|
||||
|
||||
| EN | TR | Notes |
|
||||
| --------------- | ------------------------------- | ----- |
|
||||
| Sites | Siteler | |
|
||||
| Resources | Kaynaklar | |
|
||||
| User & Roles | Kullanıcılar ve Roller | |
|
||||
| Shareable Links | Paylaşılabilir Linkler | |
|
||||
| General | Genel | |
|
||||
|
||||
##### Footer
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------------- | ------------------------------------------------ | -------------------- |
|
||||
| Page {number} of {number} | Sayfa {number} / {number} | |
|
||||
| Rows per page | Sayfa başına satırlar | |
|
||||
| Pangolin | Pangolin | Footer'da yer alır |
|
||||
| Built by Fossorial | Fossorial tarafından oluşturuldu | Footer'da yer alır |
|
||||
| Open Source | Açık Kaynak | Footer'da yer alır |
|
||||
| Documentation | Dokümantasyon | Footer'da yer alır |
|
||||
| {version} | {version} | Footer'da yer alır |
|
||||
|
||||
---
|
||||
|
||||
## Main “Sites”
|
||||
|
||||
##### “Hero” section
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | ----- |
|
||||
| Newt (Recommended) | Newt (Tavsiye Edilen) | |
|
||||
| For the best user experience, use Newt. It uses WireGuard under the hood and allows you to address your private resources by their LAN address on your private network from within the Pangolin dashboard. | En iyi kullanıcı deneyimi için Newt'i kullanın. Newt, arka planda WireGuard kullanır ve Pangolin kontrol paneli üzerinden özel ağınızdaki kaynaklarınıza LAN adresleriyle erişmenizi sağlar. | |
|
||||
| Runs in Docker | Docker üzerinde çalışır | |
|
||||
| Runs in shell on macOS, Linux, and Windows | macOS, Linux ve Windows’ta komut satırında çalışır | |
|
||||
| Install Newt | Newt'i Yükle | |
|
||||
| Basic WireGuard<br> | Temel WireGuard<br> | |
|
||||
| Compatible with all WireGuard clients<br> | Tüm WireGuard istemcileriyle uyumlu<br> | |
|
||||
| Manual configuration required | Manuel yapılandırma gereklidir | |
|
||||
|
||||
##### Content
|
||||
|
||||
| EN | TR | Notes |
|
||||
| --------------------------------------------------------- | --------------------------------------------------------------------------- | ------------ |
|
||||
| Manage Sites | Siteleri Yönet | |
|
||||
| Allow connectivity to your network through secure tunnels | Güvenli tüneller aracılığıyla ağınıza bağlantı sağlayın | |
|
||||
| Search sites | Siteleri ara | placeholder |
|
||||
| Add Site | Site Ekle | |
|
||||
| Name | Ad | Table Header |
|
||||
| Online | Çevrimiçi | Table Header |
|
||||
| Site | Site | Table Header |
|
||||
| Data In | Gelen Veri | Table Header |
|
||||
| Data Out | Giden Veri | Table Header |
|
||||
| Connection Type | Bağlantı Türü | Table Header |
|
||||
| Online | Çevrimiçi | Site state |
|
||||
| Offline | Çevrimdışı | Site state |
|
||||
| Edit → | Düzenle → | |
|
||||
| View settings | Ayarları Görüntüle | Popup |
|
||||
| Delete | Sil | Popup |
|
||||
|
||||
##### Add Site Popup
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------- | ----------- |
|
||||
| Create Site | Site Oluştur | |
|
||||
| Create a new site to start connection for this site | Bu site için bağlantıyı başlatmak amacıyla yeni bir site oluşturun | |
|
||||
| Name | Ad | |
|
||||
| Site name | Site adı | placeholder |
|
||||
| This is the name that will be displayed for this site. | Bu, site için görüntülenecek addır. | desc |
|
||||
| Method | Yöntem | |
|
||||
| Local | Yerel | |
|
||||
| Newt | Newt | |
|
||||
| WireGuard | WireGuard | |
|
||||
| This is how you will expose connections. | Bağlantılarınızı bu şekilde açığa çıkaracaksınız. | |
|
||||
| You will only be able to see the configuration once. | Yapılandırmayı yalnızca bir kez görüntüleyebilirsiniz. | |
|
||||
| Learn how to install Newt on your system | Sisteminizde Newt'in nasıl kurulacağını öğrenin | |
|
||||
| I have copied the config | Yapılandırmayı kopyaladım | |
|
||||
| Create Site | Site Oluştur | |
|
||||
| Close | Kapat | |
|
||||
|
||||
---
|
||||
|
||||
## Main “Resources”
|
||||
|
||||
##### “Hero” section
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | ----- |
|
||||
| Resources | Kaynaklar | |
|
||||
| Ressourcen sind Proxy-Server für Anwendungen, die in Ihrem privaten Netzwerk laufen. Erstellen Sie eine Ressource für jede HTTP- oder HTTPS-Anwendung in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Website verbunden sein, um eine private und sichere Verbindung über den verschlüsselten WireGuard-Tunnel zu ermöglichen. | Kaynaklar, özel ağınızda çalışan uygulamalar için proxy sunucularıdır. Özel ağınızdaki her HTTP veya HTTPS uygulaması için bir kaynak oluşturun. Her kaynağın, şifrelenmiş WireGuard tüneli üzerinden özel ve güvenli bağlantı sağlamak üzere bir siteyle ilişkili olması gerekir. | |
|
||||
| Secure connectivity with WireGuard encryption | WireGuard şifrelemesiyle güvenli bağlantı | |
|
||||
| Configure multiple authentication methods | Birden çok kimlik doğrulama yöntemini yapılandırın | |
|
||||
| User and role-based access control | Kullanıcı ve role dayalı erişim kontrolü | |
|
||||
|
||||
##### Content
|
||||
|
||||
| EN | TR | Notes |
|
||||
| -------------------------------------------------- | ------------------------------------------------------------- | -------------------- |
|
||||
| Manage Resources | Kaynakları Yönet | |
|
||||
| Create secure proxies to your private applications | Özel uygulamalarınız için güvenli proxy’ler oluşturun | |
|
||||
| Search resources | Kaynakları ara | placeholder |
|
||||
| Name | Ad | |
|
||||
| Site | Site | |
|
||||
| Full URL | Tam URL | |
|
||||
| Authentication | Kimlik Doğrulama | |
|
||||
| Not Protected | Korunmayan | authentication state |
|
||||
| Protected | Korunan | authentication state |
|
||||
| Edit → | Düzenle → | |
|
||||
| Add Resource | Kaynak Ekle | |
|
||||
|
||||
##### Add Resource Popup
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | ------------- |
|
||||
| Create Resource | Kaynak Oluştur | |
|
||||
| Create a new resource to proxy request to your app | Uygulamanıza gelen istekleri yönlendirmek için yeni bir kaynak oluşturun | |
|
||||
| Name | Ad | |
|
||||
| My Resource | Kaynağım | name placeholder |
|
||||
| This is the name that will be displayed for this resource. | Bu, kaynağın görüntülenecek adıdır. | |
|
||||
| Subdomain | Alt alan adı | |
|
||||
| Enter subdomain | Alt alan adını girin | |
|
||||
| This is the fully qualified domain name that will be used to access the resource. | Kaynağa erişmek için kullanılacak tam nitelikli alan adıdır. | |
|
||||
| Site | Site | |
|
||||
| Search site… | Site ara… | Site selector popup |
|
||||
| This is the site that will be used in the dashboard. | Kontrol panelinde kullanılacak sitedir. | |
|
||||
| Create Resource | Kaynak Oluştur | |
|
||||
| Close | Kapat | |
|
||||
|
||||
---
|
||||
|
||||
## Main “User & Roles”
|
||||
|
||||
##### Content
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ----------------------------- |
|
||||
| Manage User & Roles | Kullanıcılar ve Rolleri Yönet | |
|
||||
| Invite users and add them to roles to manage access to your organization | Organizasyonunuza erişimi yönetmek için kullanıcıları davet edin ve rollere atayın | |
|
||||
| Users | Kullanıcılar | sidebar item |
|
||||
| Roles | Roller | sidebar item |
|
||||
| **User tab** | **Kullanıcı Sekmesi** | |
|
||||
| Search users | Kullanıcıları ara | placeholder |
|
||||
| Invite User | Kullanıcı Davet Et | addbutton |
|
||||
| Email | E-posta | table header |
|
||||
| Status | Durum | table header |
|
||||
| Role | Rol | table header |
|
||||
| Confirmed | Onaylandı | account status |
|
||||
| Not confirmed (?) | Onaylanmadı (?) | account status |
|
||||
| Owner | Sahip | role |
|
||||
| Admin | Yönetici | role |
|
||||
| Member | Üye | role |
|
||||
| **Roles Tab** | **Roller Sekmesi** | |
|
||||
| Search roles | Rolleri ara | placeholder |
|
||||
| Add Role | Rol Ekle | addbutton |
|
||||
| Name | Ad | table header |
|
||||
| Description | Açıklama | table header |
|
||||
| Admin | Yönetici | role |
|
||||
| Member | Üye | role |
|
||||
| Admin role with the most permissions | En fazla yetkiye sahip yönetici rolü | admin role desc |
|
||||
| Members can only view resources | Üyeler yalnızca kaynakları görüntüleyebilir | member role desc |
|
||||
|
||||
##### Invite User popup
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ----------------- | ----------------------------------------------------------------------- | ----------- |
|
||||
| Invite User | Kullanıcı Davet Et | |
|
||||
| Email | E-posta | |
|
||||
| Enter an email | Bir e-posta adresi girin | placeholder |
|
||||
| Role | Rol | |
|
||||
| Select role | Rol seçin | placeholder |
|
||||
| Gültig für | Geçerlilik Süresi | |
|
||||
| 1 day | 1 gün | |
|
||||
| 2 days | 2 gün | |
|
||||
| 3 days | 3 gün | |
|
||||
| 4 days | 4 gün | |
|
||||
| 5 days | 5 gün | |
|
||||
| 6 days | 6 gün | |
|
||||
| 7 days | 7 gün | |
|
||||
| Create Invitation | Davetiye Oluştur | |
|
||||
| Close | Kapat | |
|
||||
|
||||
---
|
||||
|
||||
## Main “Shareable Links”
|
||||
|
||||
##### “Hero” section
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | ----- |
|
||||
| Shareable Links | Paylaşılabilir Bağlantılar | |
|
||||
| Create shareable links to your resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one. | Kaynaklarınıza paylaşılabilir bağlantılar oluşturun. Bağlantılar, kaynağınıza geçici veya sınırsız erişim sağlar. Oluştururken bağlantının geçerlilik süresini ayarlayabilirsiniz. | |
|
||||
| Easy to create and share | Oluşturması ve paylaşması kolay | |
|
||||
| Configurable expiration duration | Yapılandırılabilir geçerlilik süresi | |
|
||||
| Secure and revocable | Güvenli ve iptal edilebilir | |
|
||||
|
||||
##### Content
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | -------------- |
|
||||
| Manage Shareable Links | Paylaşılabilir Bağlantıları Yönet | |
|
||||
| Create shareable links to grant temporary or permanent access to your resources | Kaynaklarınıza geçici veya kalıcı erişim sağlamak için paylaşılabilir bağlantılar oluşturun | |
|
||||
| Search links | Bağlantıları ara | placeholder |
|
||||
| Create Share Link | Bağlantı Oluştur | addbutton |
|
||||
| Resource | Kaynak | table header |
|
||||
| Title | Başlık | table header |
|
||||
| Created | Oluşturulma Tarihi | table header |
|
||||
| Expires | Son Kullanma Tarihi | table header |
|
||||
| No links. Create one to get started. | Bağlantı yok. Başlamak için bir tane oluşturun. | table placeholder |
|
||||
|
||||
##### Create Shareable Link popup
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | ----------------------- |
|
||||
| Create Shareable Link | Paylaşılabilir Bağlantı Oluştur | |
|
||||
| Anyone with this link can access the resource | Bu bağlantıya sahip olan herkes kaynağa erişebilir | |
|
||||
| Resource | Kaynak | |
|
||||
| Select resource | Kaynak seçin | |
|
||||
| Search resources… | Kaynak ara… | resource selector popup |
|
||||
| Title (optional) | Başlık (isteğe bağlı) | |
|
||||
| Enter title | Başlık girin | placeholder |
|
||||
| Expire in | Sona Erme Süresi | |
|
||||
| Minutes | Dakika | |
|
||||
| Hours | Saat | |
|
||||
| Days | Gün | |
|
||||
| Months | Ay | |
|
||||
| Years | Yıl | |
|
||||
| Never expire | Asla sona erme | |
|
||||
| Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource. | Bağlantının geçerlilik süresi, bağlantının ne kadar süreyle kullanılabilir olacağını ve kaynağa erişim sağlayacağını belirler. Bu sürenin sonunda bağlantı çalışmaz hale gelir ve bağlantıyı kullananlar kaynağa erişimini kaybeder. | |
|
||||
| Create Link | Bağlantı Oluştur | |
|
||||
| Close | Kapat | |
|
||||
|
||||
---
|
||||
|
||||
## Main “General”
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------- | ------------ |
|
||||
| General | Genel | |
|
||||
| Configure your organization’s general settings | Organizasyonunuzun genel ayarlarını yapılandırın | |
|
||||
| General | Genel | sidebar item |
|
||||
| Organization Settings | Organizasyon Ayarları | |
|
||||
| Manage your organization details and configuration | Organizasyonunuzun detaylarını ve yapılandırmasını yönetin | |
|
||||
| Name | Ad | |
|
||||
| This is the display name of the org | Bu, organizasyonunuzun görüntülenecek adıdır. | |
|
||||
| Save Settings | Ayarları Kaydet | |
|
||||
| Danger Zone | Tehlikeli Bölge | |
|
||||
| Once you delete this org, there is no going back. Please be certain. | Bu organizasyonu sildikten sonra geri dönüş yoktur. Lütfen emin olun. | |
|
||||
| Delete Organization Data | Organizasyon Verilerini Sil | |
|
||||
@@ -1,7 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
ignoreDuringBuilds: true
|
||||
},
|
||||
output: "standalone"
|
||||
};
|
||||
|
||||
7041
package-lock.json
generated
141
package.json
@@ -12,105 +12,132 @@
|
||||
"license": "SEE LICENSE IN LICENSE AND README.md",
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:push": "npx tsx server/db/migrate.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"build": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrations.ts -o dist/migrations.mjs",
|
||||
"start": "NODE_OPTIONS=--enable-source-maps NODE_ENV=development ENVIRONMENT=prod sh -c 'node dist/migrations.mjs && node dist/server.mjs'",
|
||||
"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",
|
||||
"db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts",
|
||||
"db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts",
|
||||
"db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts",
|
||||
"db:clear-migrations": "rm -rf server/migrations",
|
||||
"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:sqlite": "DB_TYPE=sqlite NODE_OPTIONS=--enable-source-maps NODE_ENV=development ENVIRONMENT=prod sh -c 'node dist/migrations.mjs && node dist/server.mjs'",
|
||||
"start:pg": "DB_TYPE=pg NODE_OPTIONS=--enable-source-maps NODE_ENV=development ENVIRONMENT=prod sh -c 'node dist/migrations.mjs && node dist/server.mjs'",
|
||||
"email": "email dev --dir server/emails/templates --port 3005"
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "^7.3.2",
|
||||
"@hookform/resolvers": "3.9.1",
|
||||
"@node-rs/argon2": "2.0.2",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@oslojs/crypto": "1.0.1",
|
||||
"@oslojs/encoding": "1.1.0",
|
||||
"@radix-ui/react-avatar": "1.1.2",
|
||||
"@radix-ui/react-checkbox": "1.1.3",
|
||||
"@radix-ui/react-collapsible": "1.1.2",
|
||||
"@radix-ui/react-dialog": "1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.4",
|
||||
"@radix-ui/react-avatar": "1.1.10",
|
||||
"@radix-ui/react-checkbox": "1.3.2",
|
||||
"@radix-ui/react-collapsible": "1.1.11",
|
||||
"@radix-ui/react-dialog": "1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.15",
|
||||
"@radix-ui/react-icons": "1.3.2",
|
||||
"@radix-ui/react-label": "2.1.1",
|
||||
"@radix-ui/react-popover": "1.1.4",
|
||||
"@radix-ui/react-radio-group": "1.2.2",
|
||||
"@radix-ui/react-select": "2.1.4",
|
||||
"@radix-ui/react-separator": "1.1.1",
|
||||
"@radix-ui/react-slot": "1.1.1",
|
||||
"@radix-ui/react-switch": "1.1.2",
|
||||
"@radix-ui/react-tabs": "1.1.2",
|
||||
"@radix-ui/react-toast": "1.2.4",
|
||||
"@react-email/components": "0.0.31",
|
||||
"@react-email/tailwind": "1.0.4",
|
||||
"@tanstack/react-table": "8.20.6",
|
||||
"axios": "1.7.9",
|
||||
"@radix-ui/react-label": "2.1.7",
|
||||
"@radix-ui/react-popover": "1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "1.3.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "2.2.5",
|
||||
"@radix-ui/react-separator": "1.1.7",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-switch": "1.2.5",
|
||||
"@radix-ui/react-tabs": "1.1.12",
|
||||
"@radix-ui/react-toast": "1.2.14",
|
||||
"@react-email/components": "0.0.41",
|
||||
"@react-email/render": "^1.1.2",
|
||||
"@react-email/tailwind": "1.0.5",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"arctic": "^3.7.0",
|
||||
"axios": "1.9.0",
|
||||
"better-sqlite3": "11.7.0",
|
||||
"canvas-confetti": "1.9.3",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
"cmdk": "1.1.1",
|
||||
"cookie": "^1.0.2",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cookies": "^0.9.1",
|
||||
"cors": "2.8.5",
|
||||
"crypto-js": "^4.2.0",
|
||||
"drizzle-orm": "0.38.3",
|
||||
"eslint": "9.17.0",
|
||||
"eslint-config-next": "15.1.3",
|
||||
"eslint": "9.28.0",
|
||||
"eslint-config-next": "15.3.3",
|
||||
"express": "4.21.2",
|
||||
"express-rate-limit": "7.5.0",
|
||||
"glob": "11.0.0",
|
||||
"helmet": "8.0.0",
|
||||
"glob": "11.0.2",
|
||||
"helmet": "8.1.0",
|
||||
"http-errors": "2.0.0",
|
||||
"i": "^0.3.7",
|
||||
"input-otp": "1.4.1",
|
||||
"input-otp": "1.4.2",
|
||||
"jmespath": "^0.16.0",
|
||||
"js-yaml": "4.1.0",
|
||||
"lucide-react": "0.469.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "0.511.0",
|
||||
"moment": "2.30.1",
|
||||
"next": "15.1.3",
|
||||
"next-themes": "0.4.4",
|
||||
"next": "15.3.3",
|
||||
"next-themes": "0.4.6",
|
||||
"node-cache": "5.1.2",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "6.9.16",
|
||||
"npm": "^11.2.0",
|
||||
"npm": "^11.4.1",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "^8.16.0",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-easy-sort": "^1.6.0",
|
||||
"react-hook-form": "7.54.2",
|
||||
"react-hook-form": "7.56.4",
|
||||
"react-icons": "^5.5.0",
|
||||
"rebuild": "0.1.2",
|
||||
"semver": "7.6.3",
|
||||
"semver": "7.7.2",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tailwind-merge": "2.6.0",
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"tw-animate-css": "^1.3.3",
|
||||
"uuid": "^11.1.0",
|
||||
"vaul": "1.1.2",
|
||||
"winston": "3.17.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
"ws": "8.18.0",
|
||||
"zod": "3.24.1",
|
||||
"zod-validation-error": "3.4.0"
|
||||
"ws": "8.18.2",
|
||||
"zod": "3.25.56",
|
||||
"zod-validation-error": "3.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "1.32.0",
|
||||
"@dotenvx/dotenvx": "1.44.1",
|
||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@types/better-sqlite3": "7.6.12",
|
||||
"@types/cookie-parser": "1.4.8",
|
||||
"@types/cors": "2.8.17",
|
||||
"@types/cookie-parser": "1.4.9",
|
||||
"@types/cors": "2.8.19",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/express": "5.0.0",
|
||||
"@types/jmespath": "^0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/node": "^22",
|
||||
"@types/nodemailer": "6.4.17",
|
||||
"@types/react": "19.0.2",
|
||||
"@types/react-dom": "19.0.2",
|
||||
"@types/semver": "7.5.8",
|
||||
"@types/ws": "8.5.13",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@types/semver": "7.7.0",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/yargs": "17.0.33",
|
||||
"drizzle-kit": "0.30.1",
|
||||
"esbuild": "0.24.2",
|
||||
"esbuild-node-externals": "1.16.0",
|
||||
"drizzle-kit": "0.31.1",
|
||||
"esbuild": "0.25.5",
|
||||
"esbuild-node-externals": "1.18.0",
|
||||
"postcss": "^8",
|
||||
"react-email": "3.0.4",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsc-alias": "1.8.10",
|
||||
"tsx": "4.19.2",
|
||||
"react-email": "4.0.16",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsx": "4.19.4",
|
||||
"typescript": "^5",
|
||||
"yargs": "17.7.2"
|
||||
"typescript-eslint": "^8.34.0",
|
||||
"yargs": "18.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"emblor": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="900.82861"
|
||||
height="955.20648"
|
||||
viewBox="0 0 238.34422 252.7317"
|
||||
version="1.1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 399.99999 400.00002"
|
||||
enable-background="new 0 0 419.528 419.528"
|
||||
xml:space="preserve"
|
||||
id="svg52"
|
||||
sodipodi:docname="noun-pangolin-1798092.svg"
|
||||
width="400"
|
||||
height="400"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
id="svg420"
|
||||
inkscape:export-filename="logo.svg"
|
||||
inkscape:export-xdpi="221.14999"
|
||||
inkscape:export-ydpi="221.14999"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs56" /><sodipodi:namedview
|
||||
id="namedview54"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview422"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
@@ -24,15 +23,18 @@
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showgrid="false"
|
||||
inkscape:zoom="1.9583914"
|
||||
inkscape:cx="209.86611"
|
||||
inkscape:cy="262.20499"
|
||||
inkscape:window-width="3840"
|
||||
inkscape:window-height="2136"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg52" /><path
|
||||
d="m 62.232921,184.91974 c 0,2.431 -1.97,4.402 -4.399,4.402 -2.429,0 -4.399,-1.972 -4.399,-4.402 0,-2.429 1.97,-4.399 4.399,-4.399 2.429,-10e-4 4.399,1.97 4.399,4.399 z m 58.993999,-4.821 c -25.943999,-2.826 -38.978999,7.453 -71.181999,31.357 -27.572,20.467 -32.767,4.381 -31.748,-2.614 1.499,-10.282 25.222,-58.573 48.079,-88.461 28.273,7.34 49.869999,30.727 54.850999,59.718 z m -55.915999,4.821 c 0,-4.131 -3.349,-7.478 -7.478,-7.478 -4.129,0 -7.478,3.347 -7.478,7.478 0,4.131 3.349,7.481 7.478,7.481 4.13,0 7.478,-3.35 7.478,-7.481 z m -15.032,48.424 -0.234,14.041 20.413,22.687 -9.818,7.353 33.306,27.492 -11.759,8.124 42.631999,19.939 -10.825,9.747 48.291,8.078 -7.526,10.307 48.758,-4.531 -3.997,11.725 53.916,-18.153 -2.76,13.357 48.077,-34.345 1.479,13.562 34.087,-48.576 7.478,14.206 15.187,-58.89 10.391,8.533 -2.14,-57.884 13.814,5.13 -21.082,-51.204 13.404,0.048 -33.696,-42.131 15.312,-1.366 -47.026,-32.831002 14.255,-8.399 -54.817,-14.682 9.257,-11.695 -49.625,0.352 0.6,-13.337 -38.537,14.084 -1.597,-12.689 -29.984,21.429 -6.446,-10.852 -22.59,26.504 -7.021,-9.572 -18.923,30.294 -9.595999,-8.744 -16.754,30.138002 c 31.509999,10.197 54.979999,37.951 59.126999,71.547 0.404,0.087 -22.37,31.257 10.955,57.85 -0.576,-2.985 -6.113,-53.902 47.496,-57.61 26.668,-1.844 48.4,21.666 48.4,48.399 0,8.184 -2.05,15.883 -5.636,22.64 -15.927,29.611 -64.858,30.755 -80.429,30.596 -45.154,-0.459 -104.051999,-51.521 -104.051999,-51.521 z"
|
||||
id="path46" /></svg>
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false" />
|
||||
<defs
|
||||
id="defs417" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-13.119542,-5.9258171)">
|
||||
<path
|
||||
d="m 213.66176,90.072122 c 4.95655,0 8.97383,4.018046 8.97383,8.973827 0,4.956581 -4.01728,8.974621 -8.97383,8.974621 -4.95657,0 -8.97462,-4.01804 -8.97462,-8.974621 0,-4.955781 4.01805,-8.973827 8.97462,-8.973827 z m 35.2316,37.450998 c -0.90048,29.80928 -23.66033,69.21262 -54.51292,79.34466 -36.04206,11.836 -63.40991,-5.92226 -72.08409,-26.74061 -6.75754,-16.21966 -1.65117,-35.62363 10.96266,-43.83669 10.6506,-6.93533 30.48543,-8.76736 47.15454,2.19144 -5.85627,-15.34246 -21.62491,-25.4256 -35.59101,-28.49424 -13.96613,-3.06867 -28.38324,0.43858 -38.74504,5.69946 13.29071,-14.68572 44.40801,-28.946049 78.24077,-10.95958 22.67676,12.05491 32.43775,28.93208 42.0489,51.72763 C 251.59637,117.87858 234.026,71.411066 203.39074,43.794029 172.15544,15.636686 129.95516,4.340214 97.668803,6.103155 108.32483,12.678273 120.84625,22.06586 132.41209,33.053363 81.298533,26.697169 39.174705,38.314245 13.119542,73.749217 27.67508,70.878527 46.868833,69.073666 65.974711,70.016861 28.737658,96.252107 7.1124298,140.38147 18.105298,186.43137 c 6.718497,-11.74129 16.767711,-25.84558 28.726275,-38.62863 -3.677175,34.36994 1.42836,80.83745 45.62293,110.85478 -2.25587,-9.42394 -4.08014,-20.88443 -4.91466,-33.0154 20.673197,16.1282 50.685067,29.42205 87.917917,20.24096 65.77679,-16.21975 83.34719,-79.78335 73.4356,-118.35996"
|
||||
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0776283"
|
||||
id="path32" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -1,39 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="900.82861"
|
||||
height="955.20648"
|
||||
viewBox="0 0 238.34422 252.7317"
|
||||
version="1.1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 399.99999 400.00002"
|
||||
enable-background="new 0 0 419.528 419.528"
|
||||
xml:space="preserve"
|
||||
id="svg52"
|
||||
sodipodi:docname="pangolin_orange.svg"
|
||||
width="400"
|
||||
height="400"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
id="svg420"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs56" /><sodipodi:namedview
|
||||
id="namedview54"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showgrid="false"
|
||||
inkscape:zoom="1.9583914"
|
||||
inkscape:cx="127.40048"
|
||||
inkscape:cy="262.71561"
|
||||
inkscape:window-width="1436"
|
||||
inkscape:window-height="1236"
|
||||
inkscape:window-x="2208"
|
||||
inkscape:window-y="511"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg52" /><path
|
||||
d="m 62.232921,184.91974 c 0,2.431 -1.97,4.402 -4.399,4.402 -2.429,0 -4.399,-1.972 -4.399,-4.402 0,-2.429 1.97,-4.399 4.399,-4.399 2.429,-10e-4 4.399,1.97 4.399,4.399 z m 58.993999,-4.821 c -25.943999,-2.826 -38.978999,7.453 -71.181999,31.357 -27.572,20.467 -32.767,4.381 -31.748,-2.614 1.499,-10.282 25.222,-58.573 48.079,-88.461 28.273,7.34 49.869999,30.727 54.850999,59.718 z m -55.915999,4.821 c 0,-4.131 -3.349,-7.478 -7.478,-7.478 -4.129,0 -7.478,3.347 -7.478,7.478 0,4.131 3.349,7.481 7.478,7.481 4.13,0 7.478,-3.35 7.478,-7.481 z m -15.032,48.424 -0.234,14.041 20.413,22.687 -9.818,7.353 33.306,27.492 -11.759,8.124 42.631999,19.939 -10.825,9.747 48.291,8.078 -7.526,10.307 48.758,-4.531 -3.997,11.725 53.916,-18.153 -2.76,13.357 48.077,-34.345 1.479,13.562 34.087,-48.576 7.478,14.206 15.187,-58.89 10.391,8.533 -2.14,-57.884 13.814,5.13 -21.082,-51.204 13.404,0.048 -33.696,-42.131 15.312,-1.366 -47.026,-32.831002 14.255,-8.399 -54.817,-14.682 9.257,-11.695 -49.625,0.352 0.6,-13.337 -38.537,14.084 -1.597,-12.689 -29.984,21.429 -6.446,-10.852 -22.59,26.504 -7.021,-9.572 -18.923,30.294 -9.595999,-8.744 -16.754,30.138002 c 31.509999,10.197 54.979999,37.951 59.126999,71.547 0.404,0.087 -22.37,31.257 10.955,57.85 -0.576,-2.985 -6.113,-53.902 47.496,-57.61 26.668,-1.844 48.4,21.666 48.4,48.399 0,8.184 -2.05,15.883 -5.636,22.64 -15.927,29.611 -64.858,30.755 -80.429,30.596 -45.154,-0.459 -104.051999,-51.521 -104.051999,-51.521 z"
|
||||
id="path46"
|
||||
style="fill:#f97315;fill-opacity:1" /></svg>
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs417" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-13.119542,-5.9258171)">
|
||||
<path
|
||||
d="m 213.66176,90.072122 c 4.95655,0 8.97383,4.018046 8.97383,8.973827 0,4.956581 -4.01728,8.974621 -8.97383,8.974621 -4.95657,0 -8.97462,-4.01804 -8.97462,-8.974621 0,-4.955781 4.01805,-8.973827 8.97462,-8.973827 z m 35.2316,37.450998 c -0.90048,29.80928 -23.66033,69.21262 -54.51292,79.34466 -36.04206,11.836 -63.40991,-5.92226 -72.08409,-26.74061 -6.75754,-16.21966 -1.65117,-35.62363 10.96266,-43.83669 10.6506,-6.93533 30.48543,-8.76736 47.15454,2.19144 -5.85627,-15.34246 -21.62491,-25.4256 -35.59101,-28.49424 -13.96613,-3.06867 -28.38324,0.43858 -38.74504,5.69946 13.29071,-14.68572 44.40801,-28.946049 78.24077,-10.95958 22.67676,12.05491 32.43775,28.93208 42.0489,51.72763 C 251.59637,117.87858 234.026,71.411066 203.39074,43.794029 172.15544,15.636686 129.95516,4.340214 97.668803,6.103155 108.32483,12.678273 120.84625,22.06586 132.41209,33.053363 81.298533,26.697169 39.174705,38.314245 13.119542,73.749217 27.67508,70.878527 46.868833,69.073666 65.974711,70.016861 28.737658,96.252107 7.1124298,140.38147 18.105298,186.43137 c 6.718497,-11.74129 16.767711,-25.84558 28.726275,-38.62863 -3.677175,34.36994 1.42836,80.83745 45.62293,110.85478 -2.25587,-9.42394 -4.08014,-20.88443 -4.91466,-33.0154 20.673197,16.1282 50.685067,29.42205 87.917917,20.24096 65.77679,-16.21975 83.34719,-79.78335 73.4356,-118.35996"
|
||||
style="fill:#f36118;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0776283"
|
||||
id="path32" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 7.4 KiB |
BIN
public/logo/pangolin_profile_picture.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 36 KiB |
BIN
public/logo/word_mark_black.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/logo/word_mark_white.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 574 KiB |
BIN
public/screenshots/hero.png
Normal file
|
After Width: | Height: | Size: 434 KiB |
|
Before Width: | Height: | Size: 706 KiB |
|
Before Width: | Height: | Size: 729 KiB |
@@ -1,11 +1,14 @@
|
||||
import { Request } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { userActions, roleActions, userOrgs } from "@server/db/schemas";
|
||||
import { userActions, roleActions, userOrgs } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export enum ActionsEnum {
|
||||
createOrgUser = "createOrgUser",
|
||||
listOrgs = "listOrgs",
|
||||
listUserOrgs = "listUserOrgs",
|
||||
createOrg = "createOrg",
|
||||
// deleteOrg = "deleteOrg",
|
||||
getOrg = "getOrg",
|
||||
@@ -32,6 +35,8 @@ export enum ActionsEnum {
|
||||
listRoles = "listRoles",
|
||||
updateRole = "updateRole",
|
||||
inviteUser = "inviteUser",
|
||||
listInvitations = "listInvitations",
|
||||
removeInvitation = "removeInvitation",
|
||||
removeUser = "removeUser",
|
||||
listUsers = "listUsers",
|
||||
listSiteRoles = "listSiteRoles",
|
||||
@@ -64,6 +69,23 @@ export enum ActionsEnum {
|
||||
updateResourceRule = "updateResourceRule",
|
||||
listOrgDomains = "listOrgDomains",
|
||||
createNewt = "createNewt",
|
||||
createIdp = "createIdp",
|
||||
updateIdp = "updateIdp",
|
||||
deleteIdp = "deleteIdp",
|
||||
listIdps = "listIdps",
|
||||
getIdp = "getIdp",
|
||||
createIdpOrg = "createIdpOrg",
|
||||
deleteIdpOrg = "deleteIdpOrg",
|
||||
listIdpOrgs = "listIdpOrgs",
|
||||
updateIdpOrg = "updateIdpOrg",
|
||||
checkOrgId = "checkOrgId",
|
||||
createApiKey = "createApiKey",
|
||||
deleteApiKey = "deleteApiKey",
|
||||
setApiKeyActions = "setApiKeyActions",
|
||||
setApiKeyOrgs = "setApiKeyOrgs",
|
||||
listApiKeyActions = "listApiKeyActions",
|
||||
listApiKeys = "listApiKeys",
|
||||
getApiKey = "getApiKey"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import db from "@server/db";
|
||||
import { db } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { roleResources, userResources } from "@server/db/schemas";
|
||||
import { roleResources, userResources } from "@server/db";
|
||||
|
||||
export async function canUserAccessResource({
|
||||
userId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import db from "@server/db";
|
||||
import { UserInvite, userInvites } from "@server/db/schemas";
|
||||
import { db } from "@server/db";
|
||||
import { UserInvite, userInvites } from "@server/db";
|
||||
import { isWithinExpirationDate } from "oslo";
|
||||
import { verifyPassword } from "./password";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@server/db';
|
||||
import { limitsTable } from '@server/db/schemas';
|
||||
import { limitsTable } from '@server/db';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import createHttpError from 'http-errors';
|
||||
import HttpCode from '@server/types/HttpCode';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import db from "@server/db";
|
||||
import { resourceOtp } from "@server/db/schemas";
|
||||
import { db } from "@server/db";
|
||||
import { resourceOtp } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { createDate, isWithinExpirationDate, TimeSpan } from "oslo";
|
||||
import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TimeSpan, createDate } from "oslo";
|
||||
import { generateRandomString, alphabet } from "oslo/crypto";
|
||||
import db from "@server/db";
|
||||
import { users, emailVerificationCodes } from "@server/db/schemas";
|
||||
import { db } from "@server/db";
|
||||
import { users, emailVerificationCodes } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { sendEmail } from "@server/emails";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
sessions,
|
||||
User,
|
||||
users
|
||||
} from "@server/db/schemas";
|
||||
import db from "@server/db";
|
||||
} from "@server/db";
|
||||
import { db } from "@server/db";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import config from "@server/lib/config";
|
||||
import type { RandomReader } from "@oslojs/crypto/random";
|
||||
|
||||
@@ -2,8 +2,8 @@ import {
|
||||
encodeHexLowerCase,
|
||||
} from "@oslojs/encoding";
|
||||
import { sha256 } from "@oslojs/crypto/sha2";
|
||||
import { Newt, newts, newtSessions, NewtSession } from "@server/db/schemas";
|
||||
import db from "@server/db";
|
||||
import { Newt, newts, newtSessions, NewtSession } from "@server/db";
|
||||
import { db } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const EXPIRES = 1000 * 60 * 60 * 24 * 30;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { encodeHexLowerCase } from "@oslojs/encoding";
|
||||
import { sha256 } from "@oslojs/crypto/sha2";
|
||||
import { resourceSessions, ResourceSession } from "@server/db/schemas";
|
||||
import db from "@server/db";
|
||||
import { resourceSessions, ResourceSession } from "@server/db";
|
||||
import { db } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { verify } from "@node-rs/argon2";
|
||||
import db from "@server/db";
|
||||
import { twoFactorBackupCodes } from "@server/db/schemas";
|
||||
import { db } from "@server/db";
|
||||
import { twoFactorBackupCodes } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { decodeHex } from "oslo/encoding";
|
||||
import { TOTPController } from "oslo/otp";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import db from "@server/db";
|
||||
import { db } from "@server/db";
|
||||
import {
|
||||
Resource,
|
||||
ResourceAccessToken,
|
||||
resourceAccessToken,
|
||||
resources
|
||||
} from "@server/db/schemas";
|
||||
} from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { isWithinExpirationDate } from "oslo";
|
||||
import { verifyPassword } from "./password";
|
||||
|
||||
72
server/db/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Database
|
||||
|
||||
Pangolin can use a Postgres or SQLite database to store its data.
|
||||
|
||||
## Development
|
||||
|
||||
### Postgres
|
||||
|
||||
To use Postgres, edit `server/db/index.ts` to export all from `server/db/pg/index.ts`:
|
||||
|
||||
```typescript
|
||||
export * from "./pg";
|
||||
```
|
||||
|
||||
Make sure you have a valid config file with a connection string:
|
||||
|
||||
```yaml
|
||||
postgres:
|
||||
connection_string: postgresql://postgres:postgres@localhost:5432
|
||||
```
|
||||
|
||||
You can run an ephemeral Postgres database for local development using Docker:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name postgres \
|
||||
--rm \
|
||||
-p 5432:5432 \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-v $(mktemp -d):/var/lib/postgresql/data \
|
||||
postgres:17
|
||||
```
|
||||
|
||||
### Schema
|
||||
|
||||
`server/db/pg/schema.ts` and `server/db/sqlite/schema.ts` contain the database schema definitions. These need to be kept in sync with with each other.
|
||||
|
||||
Stick to common data types and avoid Postgres-specific features to ensure compatibility with SQLite.
|
||||
|
||||
### SQLite
|
||||
|
||||
To use SQLite, edit `server/db/index.ts` to export all from `server/db/sqlite/index.ts`:
|
||||
|
||||
```typescript
|
||||
export * from "./sqlite";
|
||||
```
|
||||
|
||||
No edits to the config are needed. If you keep the Postgres config, it will be ignored.
|
||||
|
||||
## Generate and Push Migrations
|
||||
|
||||
Ensure drizzle-kit is installed.
|
||||
|
||||
### Postgres
|
||||
|
||||
You must have a connection string in your config file, as shown above.
|
||||
|
||||
```bash
|
||||
npm run db:pg:generate
|
||||
npm run db:pg:push
|
||||
```
|
||||
|
||||
### SQLite
|
||||
|
||||
```bash
|
||||
npm run db:sqlite:generate
|
||||
npm run db:sqlite:push
|
||||
```
|
||||
|
||||
## Build Time
|
||||
|
||||
There is a dockerfile for each database type. The dockerfile swaps out the `server/db/index.ts` file to use the correct database type.
|
||||
@@ -1,52 +1,2 @@
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import Database from "better-sqlite3";
|
||||
import * as schema from "@server/db/schemas";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import { APP_PATH } from "@server/lib/consts";
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
|
||||
export const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||
export const exists = await checkFileExists(location);
|
||||
|
||||
bootstrapVolume();
|
||||
|
||||
const sqlite = new Database(location);
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
|
||||
export default db;
|
||||
|
||||
async function checkFileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function bootstrapVolume() {
|
||||
const appPath = APP_PATH;
|
||||
|
||||
const dbDir = path.join(appPath, "db");
|
||||
const logsDir = path.join(appPath, "logs");
|
||||
|
||||
// check if the db directory exists and create it if it doesn't
|
||||
if (!existsSync(dbDir)) {
|
||||
mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
// check if the logs directory exists and create it if it doesn't
|
||||
if (!existsSync(logsDir)) {
|
||||
mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// THIS IS FOR TRAEFIK; NOT REALLY NEEDED, BUT JUST IN CASE
|
||||
|
||||
const traefikDir = path.join(appPath, "traefik");
|
||||
|
||||
// check if the traefik directory exists and create it if it doesn't
|
||||
if (!existsSync(traefikDir)) {
|
||||
mkdirSync(traefikDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
export * from "./sqlite";
|
||||
// export * from "./pg";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { join } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
import { db } from "@server/db";
|
||||
import { exitNodes, sites } from "./schemas/schema";
|
||||
import { exitNodes, sites } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { __DIRNAME } from "@server/lib/consts";
|
||||
|
||||
|
||||
17
server/db/pg/driver.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
|
||||
import { readConfigFile } from "@server/lib/readConfigFile";
|
||||
|
||||
function createDb() {
|
||||
const config = readConfigFile();
|
||||
|
||||
const connectionString = config.postgres?.connection_string;
|
||||
|
||||
if (!connectionString) {
|
||||
throw new Error("Postgres connection string is not defined in the configuration file.");
|
||||
}
|
||||
|
||||
return DrizzlePostgres(connectionString);
|
||||
}
|
||||
|
||||
export const db = createDb();
|
||||
export default db;
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./driver";
|
||||
export * from "./schema";
|
||||
20
server/db/pg/migrate.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||
import db from "./driver";
|
||||
import path from "path";
|
||||
|
||||
const migrationsFolder = path.join("server/migrations");
|
||||
|
||||
const runMigrations = async () => {
|
||||
console.log("Running migrations...");
|
||||
try {
|
||||
await migrate(db as any, {
|
||||
migrationsFolder: migrationsFolder
|
||||
});
|
||||
console.log("Migrations completed successfully.");
|
||||
} catch (error) {
|
||||
console.error("Error running migrations:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
runMigrations();
|
||||
532
server/db/pg/schema.ts
Normal file
@@ -0,0 +1,532 @@
|
||||
import {
|
||||
pgTable,
|
||||
serial,
|
||||
varchar,
|
||||
boolean,
|
||||
integer,
|
||||
bigint,
|
||||
real
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
|
||||
export const domains = pgTable("domains", {
|
||||
domainId: varchar("domainId").primaryKey(),
|
||||
baseDomain: varchar("baseDomain").notNull(),
|
||||
configManaged: boolean("configManaged").notNull().default(false)
|
||||
});
|
||||
|
||||
export const orgs = pgTable("orgs", {
|
||||
orgId: varchar("orgId").primaryKey(),
|
||||
name: varchar("name").notNull()
|
||||
});
|
||||
|
||||
export const orgDomains = pgTable("orgDomains", {
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
domainId: varchar("domainId")
|
||||
.notNull()
|
||||
.references(() => domains.domainId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const sites = pgTable("sites", {
|
||||
siteId: serial("siteId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
niceId: varchar("niceId").notNull(),
|
||||
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
name: varchar("name").notNull(),
|
||||
pubKey: varchar("pubKey"),
|
||||
subnet: varchar("subnet").notNull(),
|
||||
megabytesIn: real("bytesIn"),
|
||||
megabytesOut: real("bytesOut"),
|
||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||
type: varchar("type").notNull(), // "newt" or "wireguard"
|
||||
online: boolean("online").notNull().default(false),
|
||||
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true)
|
||||
});
|
||||
|
||||
export const resources = pgTable("resources", {
|
||||
resourceId: serial("resourceId").primaryKey(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
subdomain: varchar("subdomain"),
|
||||
fullDomain: varchar("fullDomain"),
|
||||
domainId: varchar("domainId").references(() => domains.domainId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
ssl: boolean("ssl").notNull().default(false),
|
||||
blockAccess: boolean("blockAccess").notNull().default(false),
|
||||
sso: boolean("sso").notNull().default(true),
|
||||
http: boolean("http").notNull().default(true),
|
||||
protocol: varchar("protocol").notNull(),
|
||||
proxyPort: integer("proxyPort"),
|
||||
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
isBaseDomain: boolean("isBaseDomain"),
|
||||
applyRules: boolean("applyRules").notNull().default(false),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
stickySession: boolean("stickySession").notNull().default(false),
|
||||
tlsServerName: varchar("tlsServerName"),
|
||||
setHostHeader: varchar("setHostHeader")
|
||||
});
|
||||
|
||||
export const targets = pgTable("targets", {
|
||||
targetId: serial("targetId").primaryKey(),
|
||||
resourceId: integer("resourceId")
|
||||
.references(() => resources.resourceId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
ip: varchar("ip").notNull(),
|
||||
method: varchar("method"),
|
||||
port: integer("port").notNull(),
|
||||
internalPort: integer("internalPort"),
|
||||
enabled: boolean("enabled").notNull().default(true)
|
||||
});
|
||||
|
||||
export const exitNodes = pgTable("exitNodes", {
|
||||
exitNodeId: serial("exitNodeId").primaryKey(),
|
||||
name: varchar("name").notNull(),
|
||||
address: varchar("address").notNull(),
|
||||
endpoint: varchar("endpoint").notNull(),
|
||||
publicKey: varchar("publicKey").notNull(),
|
||||
listenPort: integer("listenPort").notNull(),
|
||||
reachableAt: varchar("reachableAt")
|
||||
});
|
||||
|
||||
export const users = pgTable("user", {
|
||||
userId: varchar("id").primaryKey(),
|
||||
email: varchar("email"),
|
||||
username: varchar("username").notNull(),
|
||||
name: varchar("name"),
|
||||
type: varchar("type").notNull(), // "internal", "oidc"
|
||||
idpId: integer("idpId").references(() => idp.idpId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
passwordHash: varchar("passwordHash"),
|
||||
twoFactorEnabled: boolean("twoFactorEnabled").notNull().default(false),
|
||||
twoFactorSecret: varchar("twoFactorSecret"),
|
||||
emailVerified: boolean("emailVerified").notNull().default(false),
|
||||
dateCreated: varchar("dateCreated").notNull(),
|
||||
serverAdmin: boolean("serverAdmin").notNull().default(false)
|
||||
});
|
||||
|
||||
export const newts = pgTable("newt", {
|
||||
newtId: varchar("id").primaryKey(),
|
||||
secretHash: varchar("secretHash").notNull(),
|
||||
dateCreated: varchar("dateCreated").notNull(),
|
||||
siteId: integer("siteId").references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const twoFactorBackupCodes = pgTable("twoFactorBackupCodes", {
|
||||
codeId: serial("id").primaryKey(),
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
codeHash: varchar("codeHash").notNull()
|
||||
});
|
||||
|
||||
export const sessions = pgTable("session", {
|
||||
sessionId: varchar("id").primaryKey(),
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
export const newtSessions = pgTable("newtSession", {
|
||||
sessionId: varchar("id").primaryKey(),
|
||||
newtId: varchar("newtId")
|
||||
.notNull()
|
||||
.references(() => newts.newtId, { onDelete: "cascade" }),
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
export const userOrgs = pgTable("userOrgs", {
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId),
|
||||
isOwner: boolean("isOwner").notNull().default(false)
|
||||
});
|
||||
|
||||
export const emailVerificationCodes = pgTable("emailVerificationCodes", {
|
||||
codeId: serial("id").primaryKey(),
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
email: varchar("email").notNull(),
|
||||
code: varchar("code").notNull(),
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
export const passwordResetTokens = pgTable("passwordResetTokens", {
|
||||
tokenId: serial("id").primaryKey(),
|
||||
email: varchar("email").notNull(),
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
tokenHash: varchar("tokenHash").notNull(),
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
export const actions = pgTable("actions", {
|
||||
actionId: varchar("actionId").primaryKey(),
|
||||
name: varchar("name"),
|
||||
description: varchar("description")
|
||||
});
|
||||
|
||||
export const roles = pgTable("roles", {
|
||||
roleId: serial("roleId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
isAdmin: boolean("isAdmin"),
|
||||
name: varchar("name").notNull(),
|
||||
description: varchar("description")
|
||||
});
|
||||
|
||||
export const roleActions = pgTable("roleActions", {
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||
actionId: varchar("actionId")
|
||||
.notNull()
|
||||
.references(() => actions.actionId, { onDelete: "cascade" }),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const userActions = pgTable("userActions", {
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
actionId: varchar("actionId")
|
||||
.notNull()
|
||||
.references(() => actions.actionId, { onDelete: "cascade" }),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const roleSites = pgTable("roleSites", {
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const userSites = pgTable("userSites", {
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const roleResources = pgTable("roleResources", {
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const userResources = pgTable("userResources", {
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const limitsTable = pgTable("limits", {
|
||||
limitId: serial("limitId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
value: bigint("value", { mode: "number" }).notNull(),
|
||||
description: varchar("description")
|
||||
});
|
||||
|
||||
export const userInvites = pgTable("userInvites", {
|
||||
inviteId: varchar("inviteId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
email: varchar("email").notNull(),
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull(),
|
||||
tokenHash: varchar("token").notNull(),
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const resourcePincode = pgTable("resourcePincode", {
|
||||
pincodeId: serial("pincodeId").primaryKey(),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
pincodeHash: varchar("pincodeHash").notNull(),
|
||||
digitLength: integer("digitLength").notNull()
|
||||
});
|
||||
|
||||
export const resourcePassword = pgTable("resourcePassword", {
|
||||
passwordId: serial("passwordId").primaryKey(),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
passwordHash: varchar("passwordHash").notNull()
|
||||
});
|
||||
|
||||
export const resourceAccessToken = pgTable("resourceAccessToken", {
|
||||
accessTokenId: varchar("accessTokenId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
tokenHash: varchar("tokenHash").notNull(),
|
||||
sessionLength: bigint("sessionLength", { mode: "number" }).notNull(),
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }),
|
||||
title: varchar("title"),
|
||||
description: varchar("description"),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
export const resourceSessions = pgTable("resourceSessions", {
|
||||
sessionId: varchar("id").primaryKey(),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull(),
|
||||
sessionLength: bigint("sessionLength", { mode: "number" }).notNull(),
|
||||
doNotExtend: boolean("doNotExtend").notNull().default(false),
|
||||
isRequestToken: boolean("isRequestToken"),
|
||||
userSessionId: varchar("userSessionId").references(
|
||||
() => sessions.sessionId,
|
||||
{
|
||||
onDelete: "cascade"
|
||||
}
|
||||
),
|
||||
passwordId: integer("passwordId").references(
|
||||
() => resourcePassword.passwordId,
|
||||
{
|
||||
onDelete: "cascade"
|
||||
}
|
||||
),
|
||||
pincodeId: integer("pincodeId").references(
|
||||
() => resourcePincode.pincodeId,
|
||||
{
|
||||
onDelete: "cascade"
|
||||
}
|
||||
),
|
||||
whitelistId: integer("whitelistId").references(
|
||||
() => resourceWhitelist.whitelistId,
|
||||
{
|
||||
onDelete: "cascade"
|
||||
}
|
||||
),
|
||||
accessTokenId: varchar("accessTokenId").references(
|
||||
() => resourceAccessToken.accessTokenId,
|
||||
{
|
||||
onDelete: "cascade"
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
export const resourceWhitelist = pgTable("resourceWhitelist", {
|
||||
whitelistId: serial("id").primaryKey(),
|
||||
email: varchar("email").notNull(),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const resourceOtp = pgTable("resourceOtp", {
|
||||
otpId: serial("otpId").primaryKey(),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
email: varchar("email").notNull(),
|
||||
otpHash: varchar("otpHash").notNull(),
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
export const versionMigrations = pgTable("versionMigrations", {
|
||||
version: varchar("version").primaryKey(),
|
||||
executedAt: bigint("executedAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
export const resourceRules = pgTable("resourceRules", {
|
||||
ruleId: serial("ruleId").primaryKey(),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
priority: integer("priority").notNull(),
|
||||
action: varchar("action").notNull(), // ACCEPT, DROP
|
||||
match: varchar("match").notNull(), // CIDR, PATH, IP
|
||||
value: varchar("value").notNull()
|
||||
});
|
||||
|
||||
export const supporterKey = pgTable("supporterKey", {
|
||||
keyId: serial("keyId").primaryKey(),
|
||||
key: varchar("key").notNull(),
|
||||
githubUsername: varchar("githubUsername").notNull(),
|
||||
phrase: varchar("phrase"),
|
||||
tier: varchar("tier"),
|
||||
valid: boolean("valid").notNull().default(false)
|
||||
});
|
||||
|
||||
export const idp = pgTable("idp", {
|
||||
idpId: serial("idpId").primaryKey(),
|
||||
name: varchar("name").notNull(),
|
||||
type: varchar("type").notNull(),
|
||||
defaultRoleMapping: varchar("defaultRoleMapping"),
|
||||
defaultOrgMapping: varchar("defaultOrgMapping"),
|
||||
autoProvision: boolean("autoProvision").notNull().default(false)
|
||||
});
|
||||
|
||||
export const idpOidcConfig = pgTable("idpOidcConfig", {
|
||||
idpOauthConfigId: serial("idpOauthConfigId").primaryKey(),
|
||||
idpId: integer("idpId")
|
||||
.notNull()
|
||||
.references(() => idp.idpId, { onDelete: "cascade" }),
|
||||
clientId: varchar("clientId").notNull(),
|
||||
clientSecret: varchar("clientSecret").notNull(),
|
||||
authUrl: varchar("authUrl").notNull(),
|
||||
tokenUrl: varchar("tokenUrl").notNull(),
|
||||
identifierPath: varchar("identifierPath").notNull(),
|
||||
emailPath: varchar("emailPath"),
|
||||
namePath: varchar("namePath"),
|
||||
scopes: varchar("scopes").notNull()
|
||||
});
|
||||
|
||||
export const licenseKey = pgTable("licenseKey", {
|
||||
licenseKeyId: varchar("licenseKeyId").primaryKey().notNull(),
|
||||
instanceId: varchar("instanceId").notNull(),
|
||||
token: varchar("token").notNull()
|
||||
});
|
||||
|
||||
export const hostMeta = pgTable("hostMeta", {
|
||||
hostMetaId: varchar("hostMetaId").primaryKey().notNull(),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
export const apiKeys = pgTable("apiKeys", {
|
||||
apiKeyId: varchar("apiKeyId").primaryKey(),
|
||||
name: varchar("name").notNull(),
|
||||
apiKeyHash: varchar("apiKeyHash").notNull(),
|
||||
lastChars: varchar("lastChars").notNull(),
|
||||
createdAt: varchar("dateCreated").notNull(),
|
||||
isRoot: boolean("isRoot").notNull().default(false)
|
||||
});
|
||||
|
||||
export const apiKeyActions = pgTable("apiKeyActions", {
|
||||
apiKeyId: varchar("apiKeyId")
|
||||
.notNull()
|
||||
.references(() => apiKeys.apiKeyId, { onDelete: "cascade" }),
|
||||
actionId: varchar("actionId")
|
||||
.notNull()
|
||||
.references(() => actions.actionId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const apiKeyOrg = pgTable("apiKeyOrg", {
|
||||
apiKeyId: varchar("apiKeyId")
|
||||
.notNull()
|
||||
.references(() => apiKeys.apiKeyId, { onDelete: "cascade" }),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const idpOrg = pgTable("idpOrg", {
|
||||
idpId: integer("idpId")
|
||||
.notNull()
|
||||
.references(() => idp.idpId, { onDelete: "cascade" }),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
roleMapping: varchar("roleMapping"),
|
||||
orgMapping: varchar("orgMapping")
|
||||
});
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
export type Resource = InferSelectModel<typeof resources>;
|
||||
export type ExitNode = InferSelectModel<typeof exitNodes>;
|
||||
export type Target = InferSelectModel<typeof targets>;
|
||||
export type Session = InferSelectModel<typeof sessions>;
|
||||
export type Newt = InferSelectModel<typeof newts>;
|
||||
export type NewtSession = InferSelectModel<typeof newtSessions>;
|
||||
export type EmailVerificationCode = InferSelectModel<
|
||||
typeof emailVerificationCodes
|
||||
>;
|
||||
export type TwoFactorBackupCode = InferSelectModel<typeof twoFactorBackupCodes>;
|
||||
export type PasswordResetToken = InferSelectModel<typeof passwordResetTokens>;
|
||||
export type Role = InferSelectModel<typeof roles>;
|
||||
export type Action = InferSelectModel<typeof actions>;
|
||||
export type RoleAction = InferSelectModel<typeof roleActions>;
|
||||
export type UserAction = InferSelectModel<typeof userActions>;
|
||||
export type RoleSite = InferSelectModel<typeof roleSites>;
|
||||
export type UserSite = InferSelectModel<typeof userSites>;
|
||||
export type RoleResource = InferSelectModel<typeof roleResources>;
|
||||
export type UserResource = InferSelectModel<typeof userResources>;
|
||||
export type Limit = InferSelectModel<typeof limitsTable>;
|
||||
export type UserInvite = InferSelectModel<typeof userInvites>;
|
||||
export type UserOrg = InferSelectModel<typeof userOrgs>;
|
||||
export type ResourceSession = InferSelectModel<typeof resourceSessions>;
|
||||
export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
|
||||
export type ResourcePassword = InferSelectModel<typeof resourcePassword>;
|
||||
export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
|
||||
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
|
||||
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
||||
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
||||
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
||||
export type Domain = InferSelectModel<typeof domains>;
|
||||
export type SupporterKey = InferSelectModel<typeof supporterKey>;
|
||||
export type Idp = InferSelectModel<typeof idp>;
|
||||
export type ApiKey = InferSelectModel<typeof apiKeys>;
|
||||
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
||||
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
|
||||
58
server/db/sqlite/driver.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
|
||||
import Database from "better-sqlite3";
|
||||
import * as schema from "./schema";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import { APP_PATH } from "@server/lib/consts";
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import { readConfigFile } from "@server/lib/readConfigFile";
|
||||
|
||||
export const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||
export const exists = await checkFileExists(location);
|
||||
|
||||
bootstrapVolume();
|
||||
|
||||
function createDb() {
|
||||
const config = readConfigFile();
|
||||
|
||||
const sqlite = new Database(location);
|
||||
return DrizzleSqlite(sqlite, { schema });
|
||||
}
|
||||
|
||||
export const db = createDb();
|
||||
export default db;
|
||||
|
||||
async function checkFileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function bootstrapVolume() {
|
||||
const appPath = APP_PATH;
|
||||
|
||||
const dbDir = path.join(appPath, "db");
|
||||
const logsDir = path.join(appPath, "logs");
|
||||
|
||||
// check if the db directory exists and create it if it doesn't
|
||||
if (!existsSync(dbDir)) {
|
||||
mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
// check if the logs directory exists and create it if it doesn't
|
||||
if (!existsSync(logsDir)) {
|
||||
mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// THIS IS FOR TRAEFIK; NOT REALLY NEEDED, BUT JUST IN CASE
|
||||
|
||||
const traefikDir = path.join(appPath, "traefik");
|
||||
|
||||
// check if the traefik directory exists and create it if it doesn't
|
||||
if (!existsSync(traefikDir)) {
|
||||
mkdirSync(traefikDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
2
server/db/sqlite/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./driver";
|
||||
export * from "./schema";
|
||||
@@ -1,5 +1,5 @@
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import db from "@server/db";
|
||||
import db from "./driver";
|
||||
import path from "path";
|
||||
|
||||
const migrationsFolder = path.join("server/migrations");
|
||||
@@ -7,7 +7,7 @@ const migrationsFolder = path.join("server/migrations");
|
||||
const runMigrations = async () => {
|
||||
console.log("Running migrations...");
|
||||
try {
|
||||
migrate(db, {
|
||||
migrate(db as any, {
|
||||
migrationsFolder: migrationsFolder,
|
||||
});
|
||||
console.log("Migrations completed successfully.");
|
||||
@@ -41,7 +41,10 @@ export const sites = sqliteTable("sites", {
|
||||
megabytesOut: integer("bytesOut"),
|
||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||
type: text("type").notNull(), // "newt" or "wireguard"
|
||||
online: integer("online", { mode: "boolean" }).notNull().default(false)
|
||||
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
||||
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true)
|
||||
});
|
||||
|
||||
export const resources = sqliteTable("resources", {
|
||||
@@ -77,7 +80,12 @@ export const resources = sqliteTable("resources", {
|
||||
applyRules: integer("applyRules", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
stickySession: integer("stickySession", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
tlsServerName: text("tlsServerName"),
|
||||
setHostHeader: text("setHostHeader")
|
||||
});
|
||||
|
||||
export const targets = sqliteTable("targets", {
|
||||
@@ -99,15 +107,21 @@ export const exitNodes = sqliteTable("exitNodes", {
|
||||
name: text("name").notNull(),
|
||||
address: text("address").notNull(), // this is the address of the wireguard interface in gerbil
|
||||
endpoint: text("endpoint").notNull(), // this is how to reach gerbil externally - gets put into the wireguard config
|
||||
publicKey: text("pubicKey").notNull(),
|
||||
publicKey: text("publicKey").notNull(),
|
||||
listenPort: integer("listenPort").notNull(),
|
||||
reachableAt: text("reachableAt") // this is the internal address of the gerbil http server for command control
|
||||
});
|
||||
|
||||
export const users = sqliteTable("user", {
|
||||
userId: text("id").primaryKey(),
|
||||
email: text("email").notNull().unique(),
|
||||
passwordHash: text("passwordHash").notNull(),
|
||||
email: text("email"),
|
||||
username: text("username").notNull(),
|
||||
name: text("name"),
|
||||
type: text("type").notNull(), // "internal", "oidc"
|
||||
idpId: integer("idpId").references(() => idp.idpId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
passwordHash: text("passwordHash"),
|
||||
twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
@@ -415,6 +429,89 @@ export const supporterKey = sqliteTable("supporterKey", {
|
||||
valid: integer("valid", { mode: "boolean" }).notNull().default(false)
|
||||
});
|
||||
|
||||
// Identity Providers
|
||||
export const idp = sqliteTable("idp", {
|
||||
idpId: integer("idpId").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
type: text("type").notNull(),
|
||||
defaultRoleMapping: text("defaultRoleMapping"),
|
||||
defaultOrgMapping: text("defaultOrgMapping"),
|
||||
autoProvision: integer("autoProvision", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(false)
|
||||
});
|
||||
|
||||
// Identity Provider OAuth Configuration
|
||||
export const idpOidcConfig = sqliteTable("idpOidcConfig", {
|
||||
idpOauthConfigId: integer("idpOauthConfigId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
idpId: integer("idpId")
|
||||
.notNull()
|
||||
.references(() => idp.idpId, { onDelete: "cascade" }),
|
||||
clientId: text("clientId").notNull(),
|
||||
clientSecret: text("clientSecret").notNull(),
|
||||
authUrl: text("authUrl").notNull(),
|
||||
tokenUrl: text("tokenUrl").notNull(),
|
||||
identifierPath: text("identifierPath").notNull(),
|
||||
emailPath: text("emailPath"),
|
||||
namePath: text("namePath"),
|
||||
scopes: text("scopes").notNull()
|
||||
});
|
||||
|
||||
export const licenseKey = sqliteTable("licenseKey", {
|
||||
licenseKeyId: text("licenseKeyId").primaryKey().notNull(),
|
||||
instanceId: text("instanceId").notNull(),
|
||||
token: text("token").notNull()
|
||||
});
|
||||
|
||||
export const hostMeta = sqliteTable("hostMeta", {
|
||||
hostMetaId: text("hostMetaId").primaryKey().notNull(),
|
||||
createdAt: integer("createdAt").notNull()
|
||||
});
|
||||
|
||||
export const apiKeys = sqliteTable("apiKeys", {
|
||||
apiKeyId: text("apiKeyId").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
apiKeyHash: text("apiKeyHash").notNull(),
|
||||
lastChars: text("lastChars").notNull(),
|
||||
createdAt: text("dateCreated").notNull(),
|
||||
isRoot: integer("isRoot", { mode: "boolean" }).notNull().default(false)
|
||||
});
|
||||
|
||||
export const apiKeyActions = sqliteTable("apiKeyActions", {
|
||||
apiKeyId: text("apiKeyId")
|
||||
.notNull()
|
||||
.references(() => apiKeys.apiKeyId, { onDelete: "cascade" }),
|
||||
actionId: text("actionId")
|
||||
.notNull()
|
||||
.references(() => actions.actionId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const apiKeyOrg = sqliteTable("apiKeyOrg", {
|
||||
apiKeyId: text("apiKeyId")
|
||||
.notNull()
|
||||
.references(() => apiKeys.apiKeyId, { onDelete: "cascade" }),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const idpOrg = sqliteTable("idpOrg", {
|
||||
idpId: integer("idpId")
|
||||
.notNull()
|
||||
.references(() => idp.idpId, { onDelete: "cascade" }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
roleMapping: text("roleMapping"),
|
||||
orgMapping: text("orgMapping")
|
||||
});
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -450,3 +547,7 @@ export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
||||
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
||||
export type Domain = InferSelectModel<typeof domains>;
|
||||
export type SupporterKey = InferSelectModel<typeof supporterKey>;
|
||||
export type Idp = InferSelectModel<typeof idp>;
|
||||
export type ApiKey = InferSelectModel<typeof apiKeys>;
|
||||
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
||||
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
|
||||
6
server/extendZod.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
|
||||
import { z } from "zod";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
export default function extendZod() {}
|
||||
@@ -1,8 +1,12 @@
|
||||
import "./extendZod.ts";
|
||||
|
||||
import { runSetupFunctions } from "./setup";
|
||||
import { createApiServer } from "./apiServer";
|
||||
import { createNextServer } from "./nextServer";
|
||||
import { createInternalServer } from "./internalServer";
|
||||
import { Session, User, UserOrg } from "./db/schemas/schema";
|
||||
import { ApiKey, ApiKeyOrg, Session, User, UserOrg } from "@server/db";
|
||||
import { createIntegrationApiServer } from "./integrationApiServer";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
async function startServers() {
|
||||
await runSetupFunctions();
|
||||
@@ -12,10 +16,16 @@ async function startServers() {
|
||||
const internalServer = createInternalServer();
|
||||
const nextServer = await createNextServer();
|
||||
|
||||
let integrationServer;
|
||||
if (config.getRawConfig().flags?.enable_integration_api) {
|
||||
integrationServer = createIntegrationApiServer();
|
||||
}
|
||||
|
||||
return {
|
||||
apiServer,
|
||||
nextServer,
|
||||
internalServer,
|
||||
integrationServer
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,9 +33,11 @@ async function startServers() {
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
apiKey?: ApiKey;
|
||||
user?: User;
|
||||
session?: Session;
|
||||
userOrg?: UserOrg;
|
||||
apiKeyOrg?: ApiKeyOrg;
|
||||
userOrgRoleId?: number;
|
||||
userOrgId?: string;
|
||||
userOrgIds?: string[];
|
||||
|
||||
102
server/integrationApiServer.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import cookieParser from "cookie-parser";
|
||||
import config from "@server/lib/config";
|
||||
import logger from "@server/logger";
|
||||
import {
|
||||
errorHandlerMiddleware,
|
||||
notFoundMiddleware,
|
||||
} from "@server/middlewares";
|
||||
import { authenticated, unauthenticated } from "@server/routers/integration";
|
||||
import { logIncomingMiddleware } from "./middlewares/logIncoming";
|
||||
import helmet from "helmet";
|
||||
import swaggerUi from "swagger-ui-express";
|
||||
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
|
||||
import { registry } from "./openApi";
|
||||
|
||||
const dev = process.env.ENVIRONMENT !== "prod";
|
||||
const externalPort = config.getRawConfig().server.integration_port;
|
||||
|
||||
export function createIntegrationApiServer() {
|
||||
const apiServer = express();
|
||||
|
||||
if (config.getRawConfig().server.trust_proxy) {
|
||||
apiServer.set("trust proxy", 1);
|
||||
}
|
||||
|
||||
apiServer.use(cors());
|
||||
|
||||
if (!dev) {
|
||||
apiServer.use(helmet());
|
||||
}
|
||||
|
||||
apiServer.use(cookieParser());
|
||||
apiServer.use(express.json());
|
||||
|
||||
apiServer.use(
|
||||
"/v1/docs",
|
||||
swaggerUi.serve,
|
||||
swaggerUi.setup(getOpenApiDocumentation())
|
||||
);
|
||||
|
||||
// API routes
|
||||
const prefix = `/v1`;
|
||||
apiServer.use(logIncomingMiddleware);
|
||||
apiServer.use(prefix, unauthenticated);
|
||||
apiServer.use(prefix, authenticated);
|
||||
|
||||
// Error handling
|
||||
apiServer.use(notFoundMiddleware);
|
||||
apiServer.use(errorHandlerMiddleware);
|
||||
|
||||
// Create HTTP server
|
||||
const httpServer = apiServer.listen(externalPort, (err?: any) => {
|
||||
if (err) throw err;
|
||||
logger.info(
|
||||
`Integration API server is running on http://localhost:${externalPort}`
|
||||
);
|
||||
});
|
||||
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
function getOpenApiDocumentation() {
|
||||
const bearerAuth = registry.registerComponent(
|
||||
"securitySchemes",
|
||||
"Bearer Auth",
|
||||
{
|
||||
type: "http",
|
||||
scheme: "bearer"
|
||||
}
|
||||
);
|
||||
|
||||
for (const def of registry.definitions) {
|
||||
if (def.type === "route") {
|
||||
def.route.security = [
|
||||
{
|
||||
[bearerAuth.name]: []
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/",
|
||||
description: "Health check",
|
||||
tags: [],
|
||||
request: {},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
const generator = new OpenApiGeneratorV3(registry.definitions);
|
||||
|
||||
return generator.generateDocument({
|
||||
openapi: "3.0.0",
|
||||
info: {
|
||||
version: "v1",
|
||||
title: "Pangolin Integration API"
|
||||
},
|
||||
servers: [{ url: "/v1" }]
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import db from "@server/db";
|
||||
import { db } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { roleResources, userResources } from "@server/db/schemas";
|
||||
import { roleResources, userResources } from "@server/db";
|
||||
|
||||
export async function canUserAccessResource({
|
||||
userId,
|
||||
|
||||
@@ -1,164 +1,10 @@
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import {
|
||||
__DIRNAME,
|
||||
APP_VERSION,
|
||||
configFilePath1,
|
||||
configFilePath2
|
||||
} from "@server/lib/consts";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import stoi from "./stoi";
|
||||
import db from "@server/db";
|
||||
import { SupporterKey, supporterKey } from "@server/db/schemas";
|
||||
import { suppressDeprecationWarnings } from "moment";
|
||||
import { __DIRNAME, APP_VERSION } from "@server/lib/consts";
|
||||
import { db } from "@server/db";
|
||||
import { SupporterKey, supporterKey } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||
|
||||
const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
|
||||
return process.env[envVar] ?? valFromYaml;
|
||||
};
|
||||
|
||||
const configSchema = z.object({
|
||||
app: z.object({
|
||||
dashboard_url: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.pipe(z.string().url())
|
||||
.transform((url) => url.toLowerCase()),
|
||||
log_level: z.enum(["debug", "info", "warn", "error"]),
|
||||
save_logs: z.boolean(),
|
||||
log_failed_attempts: z.boolean().optional()
|
||||
}),
|
||||
domains: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
base_domain: z
|
||||
.string()
|
||||
.nonempty("base_domain must not be empty")
|
||||
.transform((url) => url.toLowerCase()),
|
||||
cert_resolver: z.string().optional(),
|
||||
prefer_wildcard_cert: z.boolean().optional()
|
||||
})
|
||||
)
|
||||
.refine(
|
||||
(domains) => {
|
||||
const keys = Object.keys(domains);
|
||||
|
||||
if (keys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "At least one domain must be defined"
|
||||
}
|
||||
),
|
||||
server: z.object({
|
||||
external_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||
internal_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||
next_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||
internal_hostname: z.string().transform((url) => url.toLowerCase()),
|
||||
session_cookie_name: z.string(),
|
||||
resource_access_token_param: z.string(),
|
||||
resource_access_token_headers: z.object({
|
||||
id: z.string(),
|
||||
token: z.string()
|
||||
}),
|
||||
resource_session_request_param: z.string(),
|
||||
dashboard_session_length_hours: z
|
||||
.number()
|
||||
.positive()
|
||||
.gt(0)
|
||||
.optional()
|
||||
.default(720),
|
||||
resource_session_length_hours: z
|
||||
.number()
|
||||
.positive()
|
||||
.gt(0)
|
||||
.optional()
|
||||
.default(720),
|
||||
cors: z
|
||||
.object({
|
||||
origins: z.array(z.string()).optional(),
|
||||
methods: z.array(z.string()).optional(),
|
||||
allowed_headers: z.array(z.string()).optional(),
|
||||
credentials: z.boolean().optional()
|
||||
})
|
||||
.optional(),
|
||||
trust_proxy: z.boolean().optional().default(true)
|
||||
}),
|
||||
traefik: z.object({
|
||||
http_entrypoint: z.string(),
|
||||
https_entrypoint: z.string().optional(),
|
||||
additional_middlewares: z.array(z.string()).optional()
|
||||
}),
|
||||
gerbil: z.object({
|
||||
start_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||
base_endpoint: z
|
||||
.string()
|
||||
.optional()
|
||||
.pipe(z.string())
|
||||
.transform((url) => url.toLowerCase()),
|
||||
use_subdomain: z.boolean(),
|
||||
subnet_group: z.string(),
|
||||
block_size: z.number().positive().gt(0),
|
||||
site_block_size: z.number().positive().gt(0)
|
||||
}),
|
||||
rate_limits: z.object({
|
||||
global: z.object({
|
||||
window_minutes: z.number().positive().gt(0),
|
||||
max_requests: z.number().positive().gt(0)
|
||||
}),
|
||||
auth: z
|
||||
.object({
|
||||
window_minutes: z.number().positive().gt(0),
|
||||
max_requests: z.number().positive().gt(0)
|
||||
})
|
||||
.optional()
|
||||
}),
|
||||
email: z
|
||||
.object({
|
||||
smtp_host: z.string().optional(),
|
||||
smtp_port: portSchema.optional(),
|
||||
smtp_user: z.string().optional(),
|
||||
smtp_pass: z.string().optional(),
|
||||
smtp_secure: z.boolean().optional(),
|
||||
smtp_tls_reject_unauthorized: z.boolean().optional(),
|
||||
no_reply: z.string().email().optional()
|
||||
})
|
||||
.optional(),
|
||||
users: z.object({
|
||||
server_admin: z.object({
|
||||
email: z
|
||||
.string()
|
||||
.email()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("USERS_SERVERADMIN_EMAIL"))
|
||||
.pipe(z.string().email())
|
||||
.transform((v) => v.toLowerCase()),
|
||||
password: passwordSchema
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD"))
|
||||
.pipe(passwordSchema)
|
||||
})
|
||||
}),
|
||||
flags: z
|
||||
.object({
|
||||
require_email_verification: z.boolean().optional(),
|
||||
disable_signup_without_invite: z.boolean().optional(),
|
||||
disable_user_create_org: z.boolean().optional(),
|
||||
allow_raw_resources: z.boolean().optional(),
|
||||
allow_base_domain_resources: z.boolean().optional(),
|
||||
allow_local_sites: z.boolean().optional()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
import { license } from "@server/license/license";
|
||||
import { configSchema, readConfigFile } from "./readConfigFile";
|
||||
|
||||
export class Config {
|
||||
private rawConfig!: z.infer<typeof configSchema>;
|
||||
@@ -170,96 +16,64 @@ export class Config {
|
||||
isDev: boolean = process.env.ENVIRONMENT !== "prod";
|
||||
|
||||
constructor() {
|
||||
this.loadConfig();
|
||||
this.load();
|
||||
}
|
||||
|
||||
public loadConfig() {
|
||||
const loadConfig = (configPath: string) => {
|
||||
try {
|
||||
const yamlContent = fs.readFileSync(configPath, "utf8");
|
||||
const config = yaml.load(yamlContent);
|
||||
return config;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(
|
||||
`Error loading configuration file: ${error.message}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
let environment: any;
|
||||
if (fs.existsSync(configFilePath1)) {
|
||||
environment = loadConfig(configFilePath1);
|
||||
} else if (fs.existsSync(configFilePath2)) {
|
||||
environment = loadConfig(configFilePath2);
|
||||
}
|
||||
|
||||
if (process.env.APP_BASE_DOMAIN) {
|
||||
console.log(
|
||||
"You're using deprecated environment variables. Transition to the configuration file. https://docs.fossorial.io/"
|
||||
);
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(
|
||||
"No configuration file found. Please create one. https://docs.fossorial.io/"
|
||||
);
|
||||
}
|
||||
|
||||
const parsedConfig = configSchema.safeParse(environment);
|
||||
|
||||
if (!parsedConfig.success) {
|
||||
const errors = fromError(parsedConfig.error);
|
||||
throw new Error(`Invalid configuration file: ${errors}`);
|
||||
}
|
||||
public load() {
|
||||
const parsedConfig = readConfigFile();
|
||||
|
||||
process.env.APP_VERSION = APP_VERSION;
|
||||
|
||||
process.env.NEXT_PORT = parsedConfig.data.server.next_port.toString();
|
||||
process.env.NEXT_PORT = parsedConfig.server.next_port.toString();
|
||||
process.env.SERVER_EXTERNAL_PORT =
|
||||
parsedConfig.data.server.external_port.toString();
|
||||
parsedConfig.server.external_port.toString();
|
||||
process.env.SERVER_INTERNAL_PORT =
|
||||
parsedConfig.data.server.internal_port.toString();
|
||||
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED = parsedConfig.data.flags
|
||||
parsedConfig.server.internal_port.toString();
|
||||
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED = parsedConfig.flags
|
||||
?.require_email_verification
|
||||
? "true"
|
||||
: "false";
|
||||
process.env.FLAGS_ALLOW_RAW_RESOURCES = parsedConfig.data.flags
|
||||
process.env.FLAGS_ALLOW_RAW_RESOURCES = parsedConfig.flags
|
||||
?.allow_raw_resources
|
||||
? "true"
|
||||
: "false";
|
||||
process.env.SESSION_COOKIE_NAME =
|
||||
parsedConfig.data.server.session_cookie_name;
|
||||
process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false";
|
||||
process.env.DISABLE_SIGNUP_WITHOUT_INVITE = parsedConfig.data.flags
|
||||
parsedConfig.server.session_cookie_name;
|
||||
process.env.EMAIL_ENABLED = parsedConfig.email ? "true" : "false";
|
||||
process.env.DISABLE_SIGNUP_WITHOUT_INVITE = parsedConfig.flags
|
||||
?.disable_signup_without_invite
|
||||
? "true"
|
||||
: "false";
|
||||
process.env.DISABLE_USER_CREATE_ORG = parsedConfig.data.flags
|
||||
process.env.DISABLE_USER_CREATE_ORG = parsedConfig.flags
|
||||
?.disable_user_create_org
|
||||
? "true"
|
||||
: "false";
|
||||
process.env.RESOURCE_ACCESS_TOKEN_PARAM =
|
||||
parsedConfig.data.server.resource_access_token_param;
|
||||
parsedConfig.server.resource_access_token_param;
|
||||
process.env.RESOURCE_ACCESS_TOKEN_HEADERS_ID =
|
||||
parsedConfig.data.server.resource_access_token_headers.id;
|
||||
parsedConfig.server.resource_access_token_headers.id;
|
||||
process.env.RESOURCE_ACCESS_TOKEN_HEADERS_TOKEN =
|
||||
parsedConfig.data.server.resource_access_token_headers.token;
|
||||
parsedConfig.server.resource_access_token_headers.token;
|
||||
process.env.RESOURCE_SESSION_REQUEST_PARAM =
|
||||
parsedConfig.data.server.resource_session_request_param;
|
||||
process.env.FLAGS_ALLOW_BASE_DOMAIN_RESOURCES = parsedConfig.data.flags
|
||||
parsedConfig.server.resource_session_request_param;
|
||||
process.env.FLAGS_ALLOW_BASE_DOMAIN_RESOURCES = parsedConfig.flags
|
||||
?.allow_base_domain_resources
|
||||
? "true"
|
||||
: "false";
|
||||
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
|
||||
process.env.DASHBOARD_URL = parsedConfig.app.dashboard_url;
|
||||
|
||||
if (!this.isDev) {
|
||||
license.setServerSecret(parsedConfig.server.secret);
|
||||
|
||||
this.checkKeyStatus();
|
||||
|
||||
this.rawConfig = parsedConfig;
|
||||
}
|
||||
|
||||
private async checkKeyStatus() {
|
||||
const licenseStatus = await license.check();
|
||||
if (!licenseStatus.isHostLicensed) {
|
||||
this.checkSupporterKey();
|
||||
}
|
||||
|
||||
this.rawConfig = parsedConfig.data;
|
||||
}
|
||||
|
||||
public getRawConfig() {
|
||||
@@ -307,7 +121,7 @@ export class Config {
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://api.dev.fossorial.io/api/v1/license/validate",
|
||||
"https://api.fossorial.io/api/v1/license/validate",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// This is a placeholder value replaced by the build process
|
||||
export const APP_VERSION = "1.2.0";
|
||||
export const APP_VERSION = "1.5.1";
|
||||
|
||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||
export const __DIRNAME = path.dirname(__FILENAME);
|
||||
|
||||
12
server/lib/crypto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import CryptoJS from "crypto-js";
|
||||
|
||||
export function encrypt(value: string, key: string): string {
|
||||
const ciphertext = CryptoJS.AES.encrypt(value, key).toString();
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
export function decrypt(encryptedValue: string, key: string): string {
|
||||
const bytes = CryptoJS.AES.decrypt(encryptedValue, key);
|
||||
const originalText = bytes.toString(CryptoJS.enc.Utf8);
|
||||
return originalText;
|
||||
}
|
||||
8
server/lib/idp/generateRedirectUrl.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import config from "@server/lib/config";
|
||||
|
||||
export function generateOidcRedirectUrl(idpId: number) {
|
||||
const dashboardUrl = config.getRawConfig().app.dashboard_url;
|
||||
const redirectPath = `/auth/idp/${idpId}/oidc/callback`;
|
||||
const redirectUrl = new URL(redirectPath, dashboardUrl).toString();
|
||||
return redirectUrl;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ function detectIpVersion(ip: string): IPVersion {
|
||||
*/
|
||||
function ipToBigInt(ip: string): bigint {
|
||||
const version = detectIpVersion(ip);
|
||||
|
||||
|
||||
if (version === 4) {
|
||||
return ip.split('.')
|
||||
.reduce((acc, octet) => {
|
||||
@@ -105,7 +105,7 @@ export function cidrToRange(cidr: string): IPRange {
|
||||
const version = detectIpVersion(ip);
|
||||
const prefixBits = parseInt(prefix);
|
||||
const ipBigInt = ipToBigInt(ip);
|
||||
|
||||
|
||||
// Validate prefix length
|
||||
const maxPrefix = version === 4 ? 32 : 128;
|
||||
if (prefixBits < 0 || prefixBits > maxPrefix) {
|
||||
@@ -116,7 +116,7 @@ export function cidrToRange(cidr: string): IPRange {
|
||||
const mask = BigInt.asUintN(version === 4 ? 64 : 128, (BigInt(1) << shiftBits) - BigInt(1));
|
||||
const start = ipBigInt & ~mask;
|
||||
const end = start | mask;
|
||||
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
@@ -136,17 +136,17 @@ export function findNextAvailableCidr(
|
||||
if (!startCidr && existingCidrs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// If no existing CIDRs, use the IP version from startCidr
|
||||
const version = startCidr
|
||||
const version = startCidr
|
||||
? detectIpVersion(startCidr.split('/')[0])
|
||||
: 4; // Default to IPv4 if no startCidr provided
|
||||
|
||||
|
||||
// Use appropriate default startCidr if none provided
|
||||
startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0");
|
||||
|
||||
|
||||
// If there are existing CIDRs, ensure all are same version
|
||||
if (existingCidrs.length > 0 &&
|
||||
if (existingCidrs.length > 0 &&
|
||||
existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) {
|
||||
throw new Error('All CIDRs must be of the same IP version');
|
||||
}
|
||||
@@ -196,12 +196,14 @@ export function findNextAvailableCidr(
|
||||
export function isIpInCidr(ip: string, cidr: string): boolean {
|
||||
const ipVersion = detectIpVersion(ip);
|
||||
const cidrVersion = detectIpVersion(cidr.split('/')[0]);
|
||||
|
||||
|
||||
// If IP versions don't match, the IP cannot be in the CIDR range
|
||||
if (ipVersion !== cidrVersion) {
|
||||
throw new Error('IP address and CIDR must be of the same version');
|
||||
// throw new Erorr
|
||||
return false;
|
||||
}
|
||||
|
||||
const ipBigInt = ipToBigInt(ip);
|
||||
const range = cidrToRange(cidr);
|
||||
return ipBigInt >= range.start && ipBigInt <= range.end;
|
||||
}
|
||||
}
|
||||
|
||||
264
server/lib/readConfigFile.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import { configFilePath1, configFilePath2 } from "./consts";
|
||||
import { z } from "zod";
|
||||
import stoi from "./stoi";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||
|
||||
const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
|
||||
return process.env[envVar] ?? valFromYaml;
|
||||
};
|
||||
|
||||
export const configSchema = z.object({
|
||||
app: z.object({
|
||||
dashboard_url: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.pipe(z.string().url())
|
||||
.transform((url) => url.toLowerCase()),
|
||||
log_level: z
|
||||
.enum(["debug", "info", "warn", "error"])
|
||||
.optional()
|
||||
.default("info"),
|
||||
save_logs: z.boolean().optional().default(false),
|
||||
log_failed_attempts: z.boolean().optional().default(false)
|
||||
}),
|
||||
domains: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
base_domain: z
|
||||
.string()
|
||||
.nonempty("base_domain must not be empty")
|
||||
.transform((url) => url.toLowerCase()),
|
||||
cert_resolver: z.string().optional().default("letsencrypt"),
|
||||
prefer_wildcard_cert: z.boolean().optional().default(false)
|
||||
})
|
||||
)
|
||||
.refine(
|
||||
(domains) => {
|
||||
const keys = Object.keys(domains);
|
||||
|
||||
if (keys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "At least one domain must be defined"
|
||||
}
|
||||
),
|
||||
server: z.object({
|
||||
integration_port: portSchema
|
||||
.optional()
|
||||
.default(3003)
|
||||
.transform(stoi)
|
||||
.pipe(portSchema.optional()),
|
||||
external_port: portSchema
|
||||
.optional()
|
||||
.default(3000)
|
||||
.transform(stoi)
|
||||
.pipe(portSchema),
|
||||
internal_port: portSchema
|
||||
.optional()
|
||||
.default(3001)
|
||||
.transform(stoi)
|
||||
.pipe(portSchema),
|
||||
next_port: portSchema
|
||||
.optional()
|
||||
.default(3002)
|
||||
.transform(stoi)
|
||||
.pipe(portSchema),
|
||||
internal_hostname: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("pangolin")
|
||||
.transform((url) => url.toLowerCase()),
|
||||
session_cookie_name: z.string().optional().default("p_session_token"),
|
||||
resource_access_token_param: z.string().optional().default("p_token"),
|
||||
resource_access_token_headers: z
|
||||
.object({
|
||||
id: z.string().optional().default("P-Access-Token-Id"),
|
||||
token: z.string().optional().default("P-Access-Token")
|
||||
})
|
||||
.optional()
|
||||
.default({}),
|
||||
resource_session_request_param: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("resource_session_request_param"),
|
||||
dashboard_session_length_hours: z
|
||||
.number()
|
||||
.positive()
|
||||
.gt(0)
|
||||
.optional()
|
||||
.default(720),
|
||||
resource_session_length_hours: z
|
||||
.number()
|
||||
.positive()
|
||||
.gt(0)
|
||||
.optional()
|
||||
.default(720),
|
||||
cors: z
|
||||
.object({
|
||||
origins: z.array(z.string()).optional(),
|
||||
methods: z.array(z.string()).optional(),
|
||||
allowed_headers: z.array(z.string()).optional(),
|
||||
credentials: z.boolean().optional()
|
||||
})
|
||||
.optional(),
|
||||
trust_proxy: z.boolean().optional().default(true),
|
||||
secret: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("SERVER_SECRET"))
|
||||
.pipe(z.string().min(8))
|
||||
}),
|
||||
postgres: z
|
||||
.object({
|
||||
connection_string: z.string().optional()
|
||||
})
|
||||
.default({}),
|
||||
traefik: z
|
||||
.object({
|
||||
http_entrypoint: z.string().optional().default("web"),
|
||||
https_entrypoint: z.string().optional().default("websecure"),
|
||||
additional_middlewares: z.array(z.string()).optional()
|
||||
})
|
||||
.optional()
|
||||
.default({}),
|
||||
gerbil: z
|
||||
.object({
|
||||
start_port: portSchema
|
||||
.optional()
|
||||
.default(51820)
|
||||
.transform(stoi)
|
||||
.pipe(portSchema),
|
||||
base_endpoint: z
|
||||
.string()
|
||||
.optional()
|
||||
.pipe(z.string())
|
||||
.transform((url) => url.toLowerCase()),
|
||||
use_subdomain: z.boolean().optional().default(false),
|
||||
subnet_group: z.string().optional().default("100.89.137.0/20"),
|
||||
block_size: z.number().positive().gt(0).optional().default(24),
|
||||
site_block_size: z.number().positive().gt(0).optional().default(30)
|
||||
})
|
||||
.optional()
|
||||
.default({}),
|
||||
rate_limits: z
|
||||
.object({
|
||||
global: z
|
||||
.object({
|
||||
window_minutes: z
|
||||
.number()
|
||||
.positive()
|
||||
.gt(0)
|
||||
.optional()
|
||||
.default(1),
|
||||
max_requests: z
|
||||
.number()
|
||||
.positive()
|
||||
.gt(0)
|
||||
.optional()
|
||||
.default(500)
|
||||
})
|
||||
.optional()
|
||||
.default({}),
|
||||
auth: z
|
||||
.object({
|
||||
window_minutes: z.number().positive().gt(0),
|
||||
max_requests: z.number().positive().gt(0)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.optional()
|
||||
.default({}),
|
||||
email: z
|
||||
.object({
|
||||
smtp_host: z.string().optional(),
|
||||
smtp_port: portSchema.optional(),
|
||||
smtp_user: z.string().optional(),
|
||||
smtp_pass: z.string().optional(),
|
||||
smtp_secure: z.boolean().optional(),
|
||||
smtp_tls_reject_unauthorized: z.boolean().optional(),
|
||||
no_reply: z.string().email().optional()
|
||||
})
|
||||
.optional(),
|
||||
users: z.object({
|
||||
server_admin: z.object({
|
||||
email: z
|
||||
.string()
|
||||
.email()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("USERS_SERVERADMIN_EMAIL"))
|
||||
.pipe(z.string().email())
|
||||
.transform((v) => v.toLowerCase()),
|
||||
password: passwordSchema
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD"))
|
||||
.pipe(passwordSchema)
|
||||
})
|
||||
}),
|
||||
flags: z
|
||||
.object({
|
||||
require_email_verification: z.boolean().optional(),
|
||||
disable_signup_without_invite: z.boolean().optional(),
|
||||
disable_user_create_org: z.boolean().optional(),
|
||||
allow_raw_resources: z.boolean().optional(),
|
||||
allow_base_domain_resources: z.boolean().optional(),
|
||||
allow_local_sites: z.boolean().optional(),
|
||||
enable_integration_api: z.boolean().optional()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
||||
export function readConfigFile() {
|
||||
const loadConfig = (configPath: string) => {
|
||||
try {
|
||||
const yamlContent = fs.readFileSync(configPath, "utf8");
|
||||
const config = yaml.load(yamlContent);
|
||||
return config;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(
|
||||
`Error loading configuration file: ${error.message}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
let environment: any;
|
||||
if (fs.existsSync(configFilePath1)) {
|
||||
environment = loadConfig(configFilePath1);
|
||||
} else if (fs.existsSync(configFilePath2)) {
|
||||
environment = loadConfig(configFilePath2);
|
||||
}
|
||||
|
||||
if (process.env.APP_BASE_DOMAIN) {
|
||||
console.log(
|
||||
"You're using deprecated environment variables. Transition to the configuration file. https://docs.fossorial.io/"
|
||||
);
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(
|
||||
"No configuration file found. Please create one. https://docs.fossorial.io/"
|
||||
);
|
||||
}
|
||||
|
||||
const parsedConfig = configSchema.safeParse(environment);
|
||||
|
||||
if (!parsedConfig.success) {
|
||||
const errors = fromError(parsedConfig.error);
|
||||
throw new Error(`Invalid configuration file: ${errors}`);
|
||||
}
|
||||
|
||||
return parsedConfig.data;
|
||||
}
|
||||
@@ -9,3 +9,10 @@ export const subdomainSchema = z
|
||||
.min(1, "Subdomain must be at least 1 character long")
|
||||
.transform((val) => val.toLowerCase());
|
||||
|
||||
export const tlsNameSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$|^$/,
|
||||
"Invalid subdomain format"
|
||||
)
|
||||
.transform((val) => val.toLowerCase());
|
||||
@@ -9,6 +9,10 @@ export function isValidIP(ip: string): boolean {
|
||||
}
|
||||
|
||||
export function isValidUrlGlobPattern(pattern: string): boolean {
|
||||
if (pattern === "/") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove leading slash if present
|
||||
pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern;
|
||||
|
||||
|
||||
488
server/license/license.ts
Normal file
@@ -0,0 +1,488 @@
|
||||
import { db } from "@server/db";
|
||||
import { hostMeta, licenseKey, sites } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import NodeCache from "node-cache";
|
||||
import { validateJWT } from "./licenseJwt";
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import moment from "moment";
|
||||
import { setHostMeta } from "@server/setup/setHostMeta";
|
||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||
|
||||
const keyTypes = ["HOST", "SITES"] as const;
|
||||
type KeyType = (typeof keyTypes)[number];
|
||||
|
||||
const keyTiers = ["PROFESSIONAL", "ENTERPRISE"] as const;
|
||||
type KeyTier = (typeof keyTiers)[number];
|
||||
|
||||
export type LicenseStatus = {
|
||||
isHostLicensed: boolean; // Are there any license keys?
|
||||
isLicenseValid: boolean; // Is the license key valid?
|
||||
hostId: string; // Host ID
|
||||
maxSites?: number;
|
||||
usedSites?: number;
|
||||
tier?: KeyTier;
|
||||
};
|
||||
|
||||
export type LicenseKeyCache = {
|
||||
licenseKey: string;
|
||||
licenseKeyEncrypted: string;
|
||||
valid: boolean;
|
||||
iat?: Date;
|
||||
type?: KeyType;
|
||||
tier?: KeyTier;
|
||||
numSites?: number;
|
||||
};
|
||||
|
||||
type ActivateLicenseKeyAPIResponse = {
|
||||
data: {
|
||||
instanceId: string;
|
||||
};
|
||||
success: boolean;
|
||||
error: string;
|
||||
message: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
type ValidateLicenseAPIResponse = {
|
||||
data: {
|
||||
licenseKeys: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
success: boolean;
|
||||
error: string;
|
||||
message: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
type TokenPayload = {
|
||||
valid: boolean;
|
||||
type: KeyType;
|
||||
tier: KeyTier;
|
||||
quantity: number;
|
||||
terminateAt: string; // ISO
|
||||
iat: number; // Issued at
|
||||
};
|
||||
|
||||
export class License {
|
||||
private phoneHomeInterval = 6 * 60 * 60; // 6 hours = 6 * 60 * 60 = 21600 seconds
|
||||
private validationServerUrl =
|
||||
"https://api.fossorial.io/api/v1/license/professional/validate";
|
||||
private activationServerUrl =
|
||||
"https://api.fossorial.io/api/v1/license/professional/activate";
|
||||
|
||||
private statusCache = new NodeCache({ stdTTL: this.phoneHomeInterval });
|
||||
private licenseKeyCache = new NodeCache();
|
||||
|
||||
private ephemeralKey!: string;
|
||||
private statusKey = "status";
|
||||
private serverSecret!: string;
|
||||
|
||||
private publicKey = `-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx9RKc8cw+G8r7h/xeozF
|
||||
FNkRDggQfYO6Ae+EWHGujZ9WYAZ10spLh9F/zoLhhr3XhsjpoRXwMfgNuO5HstWf
|
||||
CYM20I0l7EUUMWEyWd4tZLd+5XQ4jY5xWOCWyFJAGQSp7flcRmxdfde+l+xg9eKl
|
||||
apbY84aVp09/GqM96hCS+CsQZrhohu/aOqYVB/eAhF01qsbmiZ7Y3WtdhTldveYt
|
||||
h4mZWGmjf8d/aEgePf/tk1gp0BUxf+Ae5yqoAqU+6aiFbjJ7q1kgxc18PWFGfE9y
|
||||
zSk+OZk887N5ThQ52154+oOUCMMR2Y3t5OH1hVZod51vuY2u5LsQXsf+87PwB91y
|
||||
LQIDAQAB
|
||||
-----END PUBLIC KEY-----`;
|
||||
|
||||
constructor(private hostId: string) {
|
||||
this.ephemeralKey = Buffer.from(
|
||||
JSON.stringify({ ts: new Date().toISOString() })
|
||||
).toString("base64");
|
||||
|
||||
setInterval(
|
||||
async () => {
|
||||
await this.check();
|
||||
},
|
||||
1000 * 60 * 60
|
||||
); // 1 hour = 60 * 60 = 3600 seconds
|
||||
}
|
||||
|
||||
public listKeys(): LicenseKeyCache[] {
|
||||
const keys = this.licenseKeyCache.keys();
|
||||
return keys.map((key) => {
|
||||
return this.licenseKeyCache.get<LicenseKeyCache>(key)!;
|
||||
});
|
||||
}
|
||||
|
||||
public setServerSecret(secret: string) {
|
||||
this.serverSecret = secret;
|
||||
}
|
||||
|
||||
public async forceRecheck() {
|
||||
this.statusCache.flushAll();
|
||||
this.licenseKeyCache.flushAll();
|
||||
|
||||
return await this.check();
|
||||
}
|
||||
|
||||
public async isUnlocked(): Promise<boolean> {
|
||||
const status = await this.check();
|
||||
if (status.isHostLicensed) {
|
||||
if (status.isLicenseValid) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async check(): Promise<LicenseStatus> {
|
||||
// Set used sites
|
||||
const [siteCount] = await db
|
||||
.select({
|
||||
value: count()
|
||||
})
|
||||
.from(sites);
|
||||
|
||||
const status: LicenseStatus = {
|
||||
hostId: this.hostId,
|
||||
isHostLicensed: true,
|
||||
isLicenseValid: false,
|
||||
maxSites: undefined,
|
||||
usedSites: siteCount.value
|
||||
};
|
||||
|
||||
try {
|
||||
if (this.statusCache.has(this.statusKey)) {
|
||||
const res = this.statusCache.get("status") as LicenseStatus;
|
||||
res.usedSites = status.usedSites;
|
||||
return res;
|
||||
}
|
||||
|
||||
// Invalidate all
|
||||
this.licenseKeyCache.flushAll();
|
||||
|
||||
const allKeysRes = await db.select().from(licenseKey);
|
||||
|
||||
if (allKeysRes.length === 0) {
|
||||
status.isHostLicensed = false;
|
||||
return status;
|
||||
}
|
||||
|
||||
let foundHostKey = false;
|
||||
// Validate stored license keys
|
||||
for (const key of allKeysRes) {
|
||||
try {
|
||||
// Decrypt the license key and token
|
||||
const decryptedKey = decrypt(
|
||||
key.licenseKeyId,
|
||||
this.serverSecret
|
||||
);
|
||||
const decryptedToken = decrypt(
|
||||
key.token,
|
||||
this.serverSecret
|
||||
);
|
||||
|
||||
const payload = validateJWT<TokenPayload>(
|
||||
decryptedToken,
|
||||
this.publicKey
|
||||
);
|
||||
|
||||
this.licenseKeyCache.set<LicenseKeyCache>(decryptedKey, {
|
||||
licenseKey: decryptedKey,
|
||||
licenseKeyEncrypted: key.licenseKeyId,
|
||||
valid: payload.valid,
|
||||
type: payload.type,
|
||||
tier: payload.tier,
|
||||
numSites: payload.quantity,
|
||||
iat: new Date(payload.iat * 1000)
|
||||
});
|
||||
|
||||
if (payload.type === "HOST") {
|
||||
foundHostKey = true;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Error validating license key: ${key.licenseKeyId}`
|
||||
);
|
||||
logger.error(e);
|
||||
|
||||
this.licenseKeyCache.set<LicenseKeyCache>(
|
||||
key.licenseKeyId,
|
||||
{
|
||||
licenseKey: key.licenseKeyId,
|
||||
licenseKeyEncrypted: key.licenseKeyId,
|
||||
valid: false
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundHostKey && allKeysRes.length) {
|
||||
logger.debug("No host license key found");
|
||||
status.isHostLicensed = false;
|
||||
}
|
||||
|
||||
const keys = allKeysRes.map((key) => ({
|
||||
licenseKey: decrypt(key.licenseKeyId, this.serverSecret),
|
||||
instanceId: decrypt(key.instanceId, this.serverSecret)
|
||||
}));
|
||||
|
||||
let apiResponse: ValidateLicenseAPIResponse | undefined;
|
||||
try {
|
||||
// Phone home to validate license keys
|
||||
apiResponse = await this.phoneHome(keys);
|
||||
|
||||
if (!apiResponse?.success) {
|
||||
throw new Error(apiResponse?.error);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("Error communicating with license server:");
|
||||
logger.error(e);
|
||||
}
|
||||
|
||||
logger.debug("Validate response", apiResponse);
|
||||
|
||||
// Check and update all license keys with server response
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const cached = this.licenseKeyCache.get<LicenseKeyCache>(
|
||||
key.licenseKey
|
||||
)!;
|
||||
const licenseKeyRes =
|
||||
apiResponse?.data?.licenseKeys[key.licenseKey];
|
||||
|
||||
if (!apiResponse || !licenseKeyRes) {
|
||||
logger.debug(
|
||||
`No response from server for license key: ${key.licenseKey}`
|
||||
);
|
||||
if (cached.iat) {
|
||||
const exp = moment(cached.iat)
|
||||
.add(7, "days")
|
||||
.toDate();
|
||||
if (exp > new Date()) {
|
||||
logger.debug(
|
||||
`Using cached license key: ${key.licenseKey}, valid ${cached.valid}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Can't trust license key: ${key.licenseKey}`
|
||||
);
|
||||
cached.valid = false;
|
||||
this.licenseKeyCache.set<LicenseKeyCache>(
|
||||
key.licenseKey,
|
||||
cached
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = validateJWT<TokenPayload>(
|
||||
licenseKeyRes,
|
||||
this.publicKey
|
||||
);
|
||||
cached.valid = payload.valid;
|
||||
cached.type = payload.type;
|
||||
cached.tier = payload.tier;
|
||||
cached.numSites = payload.quantity;
|
||||
cached.iat = new Date(payload.iat * 1000);
|
||||
|
||||
// Encrypt the updated token before storing
|
||||
const encryptedKey = encrypt(
|
||||
key.licenseKey,
|
||||
this.serverSecret
|
||||
);
|
||||
const encryptedToken = encrypt(
|
||||
licenseKeyRes,
|
||||
this.serverSecret
|
||||
);
|
||||
|
||||
await db
|
||||
.update(licenseKey)
|
||||
.set({
|
||||
token: encryptedToken
|
||||
})
|
||||
.where(eq(licenseKey.licenseKeyId, encryptedKey));
|
||||
|
||||
this.licenseKeyCache.set<LicenseKeyCache>(
|
||||
key.licenseKey,
|
||||
cached
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(`Error validating license key: ${key}`);
|
||||
logger.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute host status
|
||||
for (const key of keys) {
|
||||
const cached = this.licenseKeyCache.get<LicenseKeyCache>(
|
||||
key.licenseKey
|
||||
)!;
|
||||
|
||||
logger.debug("Checking key", cached);
|
||||
|
||||
if (cached.type === "HOST") {
|
||||
status.isLicenseValid = cached.valid;
|
||||
status.tier = cached.tier;
|
||||
}
|
||||
|
||||
if (!cached.valid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!status.maxSites) {
|
||||
status.maxSites = 0;
|
||||
}
|
||||
|
||||
status.maxSites += cached.numSites || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error checking license status:");
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
this.statusCache.set(this.statusKey, status);
|
||||
return status;
|
||||
}
|
||||
|
||||
public async activateLicenseKey(key: string) {
|
||||
// Encrypt the license key before storing
|
||||
const encryptedKey = encrypt(key, this.serverSecret);
|
||||
|
||||
const [existingKey] = await db
|
||||
.select()
|
||||
.from(licenseKey)
|
||||
.where(eq(licenseKey.licenseKeyId, encryptedKey))
|
||||
.limit(1);
|
||||
|
||||
if (existingKey) {
|
||||
throw new Error("License key already exists");
|
||||
}
|
||||
|
||||
let instanceId: string | undefined;
|
||||
try {
|
||||
// Call activate
|
||||
const apiResponse = await fetch(this.activationServerUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
licenseKey: key,
|
||||
instanceName: this.hostId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await apiResponse.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(`${data.message || data.error}`);
|
||||
}
|
||||
|
||||
const response = data as ActivateLicenseKeyAPIResponse;
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("No response from server");
|
||||
}
|
||||
|
||||
if (!response.data.instanceId) {
|
||||
throw new Error("No instance ID in response");
|
||||
}
|
||||
|
||||
instanceId = response.data.instanceId;
|
||||
} catch (error) {
|
||||
throw Error(`Error activating license key: ${error}`);
|
||||
}
|
||||
|
||||
// Phone home to validate license key
|
||||
const keys = [
|
||||
{
|
||||
licenseKey: key,
|
||||
instanceId: instanceId!
|
||||
}
|
||||
];
|
||||
|
||||
let validateResponse: ValidateLicenseAPIResponse;
|
||||
try {
|
||||
validateResponse = await this.phoneHome(keys);
|
||||
|
||||
if (!validateResponse) {
|
||||
throw new Error("No response from server");
|
||||
}
|
||||
|
||||
if (!validateResponse.success) {
|
||||
throw new Error(validateResponse.error);
|
||||
}
|
||||
|
||||
// Validate the license key
|
||||
const licenseKeyRes = validateResponse.data.licenseKeys[key];
|
||||
if (!licenseKeyRes) {
|
||||
throw new Error("Invalid license key");
|
||||
}
|
||||
|
||||
const payload = validateJWT<TokenPayload>(
|
||||
licenseKeyRes,
|
||||
this.publicKey
|
||||
);
|
||||
|
||||
if (!payload.valid) {
|
||||
throw new Error("Invalid license key");
|
||||
}
|
||||
|
||||
const encryptedToken = encrypt(licenseKeyRes, this.serverSecret);
|
||||
// Encrypt the instanceId before storing
|
||||
const encryptedInstanceId = encrypt(instanceId!, this.serverSecret);
|
||||
|
||||
// Store the license key in the database
|
||||
await db.insert(licenseKey).values({
|
||||
licenseKeyId: encryptedKey,
|
||||
token: encryptedToken,
|
||||
instanceId: encryptedInstanceId
|
||||
});
|
||||
} catch (error) {
|
||||
throw Error(`Error validating license key: ${error}`);
|
||||
}
|
||||
|
||||
// Invalidate the cache and re-compute the status
|
||||
return await this.forceRecheck();
|
||||
}
|
||||
|
||||
private async phoneHome(
|
||||
keys: {
|
||||
licenseKey: string;
|
||||
instanceId: string;
|
||||
}[]
|
||||
): Promise<ValidateLicenseAPIResponse> {
|
||||
// Decrypt the instanceIds before sending to the server
|
||||
const decryptedKeys = keys.map((key) => ({
|
||||
licenseKey: key.licenseKey,
|
||||
instanceId: key.instanceId
|
||||
? decrypt(key.instanceId, this.serverSecret)
|
||||
: key.instanceId
|
||||
}));
|
||||
|
||||
const response = await fetch(this.validationServerUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
licenseKeys: decryptedKeys,
|
||||
ephemeralKey: this.ephemeralKey,
|
||||
instanceName: this.hostId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return data as ValidateLicenseAPIResponse;
|
||||
}
|
||||
}
|
||||
|
||||
await setHostMeta();
|
||||
|
||||
const [info] = await db.select().from(hostMeta).limit(1);
|
||||
|
||||
if (!info) {
|
||||
throw new Error("Host information not found");
|
||||
}
|
||||
|
||||
export const license = new License(info.hostMetaId);
|
||||
|
||||
export default license;
|
||||
109
server/license/licenseJwt.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as crypto from "crypto";
|
||||
|
||||
/**
|
||||
* Validates a JWT using a public key
|
||||
* @param token - The JWT to validate
|
||||
* @param publicKey - The public key used for verification (PEM format)
|
||||
* @returns The decoded payload if validation succeeds, throws an error otherwise
|
||||
*/
|
||||
function validateJWT<Payload>(
|
||||
token: string,
|
||||
publicKey: string
|
||||
): Payload {
|
||||
// Split the JWT into its three parts
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Invalid JWT format");
|
||||
}
|
||||
|
||||
const [encodedHeader, encodedPayload, signature] = parts;
|
||||
|
||||
// Decode the header to get the algorithm
|
||||
const header = JSON.parse(Buffer.from(encodedHeader, "base64").toString());
|
||||
const algorithm = header.alg;
|
||||
|
||||
// Verify the signature
|
||||
const signatureInput = `${encodedHeader}.${encodedPayload}`;
|
||||
const isValid = verify(signatureInput, signature, publicKey, algorithm);
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error("Invalid signature");
|
||||
}
|
||||
|
||||
// Decode the payload
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(encodedPayload, "base64").toString()
|
||||
);
|
||||
|
||||
// Check if the token has expired
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp && payload.exp < now) {
|
||||
throw new Error("Token has expired");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the signature of a JWT
|
||||
*/
|
||||
function verify(
|
||||
input: string,
|
||||
signature: string,
|
||||
publicKey: string,
|
||||
algorithm: string
|
||||
): boolean {
|
||||
let verifyAlgorithm: string;
|
||||
|
||||
// Map JWT algorithm name to Node.js crypto algorithm name
|
||||
switch (algorithm) {
|
||||
case "RS256":
|
||||
verifyAlgorithm = "RSA-SHA256";
|
||||
break;
|
||||
case "RS384":
|
||||
verifyAlgorithm = "RSA-SHA384";
|
||||
break;
|
||||
case "RS512":
|
||||
verifyAlgorithm = "RSA-SHA512";
|
||||
break;
|
||||
case "ES256":
|
||||
verifyAlgorithm = "SHA256";
|
||||
break;
|
||||
case "ES384":
|
||||
verifyAlgorithm = "SHA384";
|
||||
break;
|
||||
case "ES512":
|
||||
verifyAlgorithm = "SHA512";
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported algorithm: ${algorithm}`);
|
||||
}
|
||||
|
||||
// Convert base64url signature to standard base64
|
||||
const base64Signature = base64URLToBase64(signature);
|
||||
|
||||
// Verify the signature
|
||||
const verifier = crypto.createVerify(verifyAlgorithm);
|
||||
verifier.update(input);
|
||||
return verifier.verify(publicKey, base64Signature, "base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts base64url format to standard base64
|
||||
*/
|
||||
function base64URLToBase64(base64url: string): string {
|
||||
// Add padding if needed
|
||||
let base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
const pad = base64.length % 4;
|
||||
if (pad) {
|
||||
if (pad === 1) {
|
||||
throw new Error("Invalid base64url string");
|
||||
}
|
||||
base64 += "=".repeat(4 - pad);
|
||||
}
|
||||
|
||||
return base64;
|
||||
}
|
||||
|
||||
export { validateJWT };
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { userOrgs, orgs } from "@server/db/schemas";
|
||||
import { userOrgs, orgs } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
@@ -14,4 +14,9 @@ export * from "./verifyAdmin";
|
||||
export * from "./verifySetResourceUsers";
|
||||
export * from "./verifyUserInRole";
|
||||
export * from "./verifyAccessTokenAccess";
|
||||
export * from "./verifyUserIsServerAdmin";
|
||||
export * from "./verifyUserIsServerAdmin";
|
||||
export * from "./verifyIsLoggedInUser";
|
||||
export * from "./integration";
|
||||
export * from "./verifyValidLicense";
|
||||
export * from "./verifyUserHasAction";
|
||||
export * from "./verifyApiKeyAccess";
|
||||
|
||||
12
server/middlewares/integration/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export * from "./verifyApiKey";
|
||||
export * from "./verifyApiKeyOrgAccess";
|
||||
export * from "./verifyApiKeyHasAction";
|
||||
export * from "./verifyApiKeySiteAccess";
|
||||
export * from "./verifyApiKeyResourceAccess";
|
||||
export * from "./verifyApiKeyTargetAccess";
|
||||
export * from "./verifyApiKeyRoleAccess";
|
||||
export * from "./verifyApiKeyUserAccess";
|
||||
export * from "./verifyApiKeySetResourceUsers";
|
||||
export * from "./verifyAccessTokenAccess";
|
||||
export * from "./verifyApiKeyIsRoot";
|
||||
export * from "./verifyApiKeyApiKeyAccess";
|
||||
110
server/middlewares/integration/verifyAccessTokenAccess.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { resourceAccessToken, resources, apiKeyOrg } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export async function verifyApiKeyAccessTokenAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const apiKey = req.apiKey;
|
||||
const accessTokenId = req.params.accessTokenId;
|
||||
|
||||
if (!apiKey) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
const [accessToken] = await db
|
||||
.select()
|
||||
.from(resourceAccessToken)
|
||||
.where(eq(resourceAccessToken.accessTokenId, accessTokenId))
|
||||
.limit(1);
|
||||
|
||||
if (!accessToken) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Access token with ID ${accessTokenId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const resourceId = accessToken.resourceId;
|
||||
|
||||
if (!resourceId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Access token with ID ${accessTokenId} does not have a resource ID`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!resource.orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Resource with ID ${resourceId} does not have an organization ID`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Verify that the API key is linked to the resource's organization
|
||||
if (!req.apiKeyOrg) {
|
||||
const apiKeyOrgResult = await db
|
||||
.select()
|
||||
.from(apiKeyOrg)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||
eq(apiKeyOrg.orgId, resource.orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (apiKeyOrgResult.length > 0) {
|
||||
req.apiKeyOrg = apiKeyOrgResult[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Key does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return next();
|
||||
} catch (e) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying access token access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
60
server/middlewares/integration/verifyApiKey.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import { db } from "@server/db";
|
||||
import { apiKeys } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
|
||||
export async function verifyApiKey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> {
|
||||
try {
|
||||
const authHeader = req.headers["authorization"];
|
||||
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "API key required")
|
||||
);
|
||||
}
|
||||
|
||||
const key = authHeader.split(" ")[1]; // Get the token part after "Bearer"
|
||||
const [apiKeyId, apiKeySecret] = key.split(".");
|
||||
|
||||
const [apiKey] = await db
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.apiKeyId, apiKeyId))
|
||||
.limit(1);
|
||||
|
||||
if (!apiKey) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Invalid API key")
|
||||
);
|
||||
}
|
||||
|
||||
const secretHash = apiKey.apiKeyHash;
|
||||
const valid = await verifyPassword(apiKeySecret, secretHash);
|
||||
|
||||
if (!valid) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Invalid API key")
|
||||
);
|
||||
}
|
||||
|
||||
req.apiKey = apiKey;
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred checking API key"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
81
server/middlewares/integration/verifyApiKeyApiKeyAccess.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { apiKeys, apiKeyOrg } from "@server/db";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export async function verifyApiKeyApiKeyAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const {apiKey: callerApiKey } = req;
|
||||
|
||||
const apiKeyId =
|
||||
req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId;
|
||||
const orgId = req.params.orgId;
|
||||
|
||||
if (!callerApiKey) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
if (!apiKeyId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID")
|
||||
);
|
||||
}
|
||||
|
||||
const [callerApiKeyOrg] = await db
|
||||
.select()
|
||||
.from(apiKeyOrg)
|
||||
.where(
|
||||
and(eq(apiKeys.apiKeyId, callerApiKey.apiKeyId), eq(apiKeyOrg.orgId, orgId))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!callerApiKeyOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`API key with ID ${apiKeyId} does not have an organization ID`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [otherApiKeyOrg] = await db
|
||||
.select()
|
||||
.from(apiKeyOrg)
|
||||
.where(
|
||||
and(eq(apiKeys.apiKeyId, apiKeyId), eq(apiKeyOrg.orgId, orgId))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!otherApiKeyOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
`API key with ID ${apiKeyId} does not have access to organization with ID ${orgId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying key access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
56
server/middlewares/integration/verifyApiKeyHasAction.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import logger from "@server/logger";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import { db } from "@server/db";
|
||||
import { apiKeyActions } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
export function verifyApiKeyHasAction(action: ActionsEnum) {
|
||||
return async function (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
if (!req.apiKey) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"API Key not authenticated"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [actionRes] = await db
|
||||
.select()
|
||||
.from(apiKeyActions)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKeyActions.apiKeyId, req.apiKey.apiKeyId),
|
||||
eq(apiKeyActions.actionId, action)
|
||||
)
|
||||
);
|
||||
|
||||
if (!actionRes) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Key does not have permission perform this action"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
logger.error("Error verifying key action access:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying key action access"
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
39
server/middlewares/integration/verifyApiKeyIsRoot.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
|
||||
export async function verifyApiKeyIsRoot(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { apiKey } = req;
|
||||
|
||||
if (!apiKey) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (!apiKey.isRoot) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Key does not have root access"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred checking API key"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
61
server/middlewares/integration/verifyApiKeyOrgAccess.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { apiKeyOrg } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export async function verifyApiKeyOrgAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const apiKeyId = req.apiKey?.apiKeyId;
|
||||
const orgId = req.params.orgId;
|
||||
|
||||
if (!apiKeyId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg) {
|
||||
const apiKeyOrgRes = await db
|
||||
.select()
|
||||
.from(apiKeyOrg)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKeyOrg.apiKeyId, apiKeyId),
|
||||
eq(apiKeyOrg.orgId, orgId)
|
||||
)
|
||||
);
|
||||
req.apiKeyOrg = apiKeyOrgRes[0];
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg) {
|
||||
next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Key does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (e) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying organization access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
85
server/middlewares/integration/verifyApiKeyResourceAccess.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { resources, apiKeyOrg } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export async function verifyApiKeyResourceAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const apiKey = req.apiKey;
|
||||
const resourceId =
|
||||
req.params.resourceId || req.body.resourceId || req.query.resourceId;
|
||||
|
||||
if (!apiKey) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Retrieve the resource
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!resource.orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Resource with ID ${resourceId} does not have an organization ID`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Verify that the API key is linked to the resource's organization
|
||||
if (!req.apiKeyOrg) {
|
||||
const apiKeyOrgResult = await db
|
||||
.select()
|
||||
.from(apiKeyOrg)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||
eq(apiKeyOrg.orgId, resource.orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (apiKeyOrgResult.length > 0) {
|
||||
req.apiKeyOrg = apiKeyOrgResult[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Key does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying resource access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
127
server/middlewares/integration/verifyApiKeyRoleAccess.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { roles, apiKeyOrg } from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export async function verifyApiKeyRoleAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const apiKey = req.apiKey;
|
||||
const singleRoleId = parseInt(
|
||||
req.params.roleId || req.body.roleId || req.query.roleId
|
||||
);
|
||||
|
||||
if (!apiKey) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
const { roleIds } = req.body;
|
||||
const allRoleIds =
|
||||
roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]);
|
||||
|
||||
if (allRoleIds.length === 0) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const rolesData = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(inArray(roles.roleId, allRoleIds));
|
||||
|
||||
if (rolesData.length !== allRoleIds.length) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"One or more roles not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const orgIds = new Set(rolesData.map((role) => role.orgId));
|
||||
|
||||
for (const role of rolesData) {
|
||||
const apiKeyOrgAccess = await db
|
||||
.select()
|
||||
.from(apiKeyOrg)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||
eq(apiKeyOrg.orgId, role.orgId!)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (apiKeyOrgAccess.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
`Key does not have access to organization for role ID ${role.roleId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (orgIds.size > 1) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Roles must belong to the same organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const orgId = orgIds.values().next().value;
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Roles do not have an organization ID"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg) {
|
||||
// Retrieve the API key's organization link if not already set
|
||||
const apiKeyOrgRes = await db
|
||||
.select()
|
||||
.from(apiKeyOrg)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||
eq(apiKeyOrg.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (apiKeyOrgRes.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Key does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
req.apiKeyOrg = apiKeyOrgRes[0];
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
logger.error("Error verifying role access:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying role access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { userOrgs } from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export async function verifyApiKeySetResourceUsers(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const apiKey = req.apiKey;
|
||||
const userIds = req.body.userIds;
|
||||
|
||||
if (!apiKey) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Key does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!userIds) {
|
||||
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user IDs"));
|
||||
}
|
||||
|
||||
if (userIds.length === 0) {
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
const orgId = req.apiKeyOrg.orgId;
|
||||
const userOrgsData = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
inArray(userOrgs.userId, userIds),
|
||||
eq(userOrgs.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (userOrgsData.length !== userIds.length) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Key does not have access to one or more specified users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error checking if key has access to the specified users"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
89
server/middlewares/integration/verifyApiKeySiteAccess.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import {
|
||||
sites,
|
||||
apiKeyOrg
|
||||
} from "@server/db";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export async function verifyApiKeySiteAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const apiKey = req.apiKey;
|
||||
const siteId = parseInt(
|
||||
req.params.siteId || req.body.siteId || req.query.siteId
|
||||
);
|
||||
|
||||
if (!apiKey) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (isNaN(siteId)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid site ID")
|
||||
);
|
||||
}
|
||||
|
||||
const site = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (site.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site with ID ${siteId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!site[0].orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Site with ID ${siteId} does not have an organization ID`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg) {
|
||||
const apiKeyOrgRes = await db
|
||||
.select()
|
||||
.from(apiKeyOrg)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||
eq(apiKeyOrg.orgId, site[0].orgId)
|
||||
)
|
||||
);
|
||||
req.apiKeyOrg = apiKeyOrgRes[0];
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Key does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying site access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
112
server/middlewares/integration/verifyApiKeyTargetAccess.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { resources, targets, apiKeyOrg } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export async function verifyApiKeyTargetAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const apiKey = req.apiKey;
|
||||
const targetId = parseInt(req.params.targetId);
|
||||
|
||||
if (!apiKey) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (isNaN(targetId)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID")
|
||||
);
|
||||
}
|
||||
|
||||
const [target] = await db
|
||||
.select()
|
||||
.from(targets)
|
||||
.where(eq(targets.targetId, targetId))
|
||||
.limit(1);
|
||||
|
||||
if (!target) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Target with ID ${targetId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const resourceId = target.resourceId;
|
||||
if (!resourceId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Target with ID ${targetId} does not have a resource ID`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!resource.orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Resource with ID ${resourceId} does not have an organization ID`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg) {
|
||||
const apiKeyOrgResult = await db
|
||||
.select()
|
||||
.from(apiKeyOrg)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||
eq(apiKeyOrg.orgId, resource.orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (apiKeyOrgResult.length > 0) {
|
||||
req.apiKeyOrg = apiKeyOrgResult[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Key does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying target access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
67
server/middlewares/integration/verifyApiKeyUserAccess.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { userOrgs } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export async function verifyApiKeyUserAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const apiKey = req.apiKey;
|
||||
const reqUserId =
|
||||
req.params.userId || req.body.userId || req.query.userId;
|
||||
|
||||
if (!apiKey) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (!reqUserId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid user ID")
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg || !req.apiKeyOrg.orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Key does not have organization access"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const orgId = req.apiKeyOrg.orgId;
|
||||
|
||||
const [userOrgRecord] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(eq(userOrgs.userId, reqUserId), eq(userOrgs.orgId, orgId))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!userOrgRecord) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Key does not have access to this user"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error checking if key has access to this user"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { resourceAccessToken, resources, userOrgs } from "@server/db/schemas";
|
||||
import { resourceAccessToken, resources, userOrgs } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { roles, userOrgs } from "@server/db/schemas";
|
||||
import { roles, userOrgs } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
99
server/middlewares/verifyApiKeyAccess.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { userOrgs, apiKeys, apiKeyOrg } from "@server/db";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export async function verifyApiKeyAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const apiKeyId =
|
||||
req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId;
|
||||
const orgId = req.params.orgId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
if (!apiKeyId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID")
|
||||
);
|
||||
}
|
||||
|
||||
const [apiKey] = await db
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.innerJoin(apiKeyOrg, eq(apiKeys.apiKeyId, apiKeyOrg.apiKeyId))
|
||||
.where(
|
||||
and(eq(apiKeys.apiKeyId, apiKeyId), eq(apiKeyOrg.orgId, orgId))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!apiKey.apiKeys) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`API key with ID ${apiKeyId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!apiKeyOrg.orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`API key with ID ${apiKeyId} does not have an organization ID`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.userOrg) {
|
||||
const userOrgRole = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, userId),
|
||||
eq(userOrgs.orgId, apiKeyOrg.orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
req.userOrg = userOrgRole[0];
|
||||
}
|
||||
|
||||
if (!req.userOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const userOrgRoleId = req.userOrg.roleId;
|
||||
req.userOrgRoleId = userOrgRoleId;
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying key access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
44
server/middlewares/verifyIsLoggedInUser.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export async function verifyIsLoggedInUser(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const reqUserId =
|
||||
req.params.userId || req.body.userId || req.query.userId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
// allow server admins to access any user
|
||||
if (req.user?.serverAdmin) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (reqUserId !== userId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User only has access to their own account"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error checking if user has access to this user"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||