mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-20 11:56:38 +00:00
Compare commits
164 Commits
1.0.0-beta
...
1.0.0-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1702bf99a | ||
|
|
a35e24bc0e | ||
|
|
c230e034cf | ||
|
|
06ceff7427 | ||
|
|
19273ddbd5 | ||
|
|
fdf1dfdeba | ||
|
|
f14ecf50e4 | ||
|
|
c244ef387b | ||
|
|
8165051dd8 | ||
|
|
6fba13c8d1 | ||
|
|
3c99fbb1ef | ||
|
|
5b44ffa2fb | ||
|
|
6e6992e19f | ||
|
|
4bce210ff5 | ||
|
|
bbc1a9eac4 | ||
|
|
5e92aebd20 | ||
|
|
2428738fa6 | ||
|
|
34e3fe690d | ||
|
|
c415ceef8d | ||
|
|
73798f9e61 | ||
|
|
9694261f3e | ||
|
|
874c67345e | ||
|
|
42434ca832 | ||
|
|
4a6da91faf | ||
|
|
8f96d0795c | ||
|
|
da3c8823f8 | ||
|
|
3cd20cab55 | ||
|
|
b1fa980f56 | ||
|
|
ef0bc9a764 | ||
|
|
dc2ec5b73b | ||
|
|
d8a089fbc2 | ||
|
|
00a0d89d6c | ||
|
|
2f49be69fe | ||
|
|
b92639647a | ||
|
|
befdc3a002 | ||
|
|
3c7025a327 | ||
|
|
58a084426b | ||
|
|
d070415515 | ||
|
|
3fa7132534 | ||
|
|
feeeba5cee | ||
|
|
9e5d5e8990 | ||
|
|
c51f1cb6a2 | ||
|
|
786551d86a | ||
|
|
0e73365106 | ||
|
|
b6963a9c35 | ||
|
|
bc0b467f1a | ||
|
|
7cf798851c | ||
|
|
e475c1ea50 | ||
|
|
0840c166ab | ||
|
|
65a537a670 | ||
|
|
a7c99b016c | ||
|
|
6a8132546e | ||
|
|
94ce5edc61 | ||
|
|
889f8e1394 | ||
|
|
9d36198459 | ||
|
|
673635a585 | ||
|
|
53660a163c | ||
|
|
b5420a40ab | ||
|
|
962c5fb886 | ||
|
|
7d6dd9e9fd | ||
|
|
dc9b1f1efd | ||
|
|
3257c39fca | ||
|
|
8b43c6f9c5 | ||
|
|
8b5cac40e0 | ||
|
|
722b877ea5 | ||
|
|
a9477d7eb9 | ||
|
|
bb5573a8f4 | ||
|
|
81571a8fb7 | ||
|
|
57cd776c34 | ||
|
|
5c507cc0ec | ||
|
|
55c0953fde | ||
|
|
844b12d363 | ||
|
|
f40d91ff9e | ||
|
|
f5e894e06a | ||
|
|
8fe479f809 | ||
|
|
9b9c343e2d | ||
|
|
cb1ccbe945 | ||
|
|
5de6028136 | ||
|
|
e226a5e86b | ||
|
|
f0ecfbb403 | ||
|
|
985418b9af | ||
|
|
197c797264 | ||
|
|
16b131970b | ||
|
|
4541880d57 | ||
|
|
3e41e3d725 | ||
|
|
1bad0c538b | ||
|
|
61e6fb3126 | ||
|
|
f80171ad53 | ||
|
|
2b6552319c | ||
|
|
5ce6cb01ff | ||
|
|
69621a430d | ||
|
|
4f0b45dd9f | ||
|
|
bdf72662bf | ||
|
|
34c8c0db70 | ||
|
|
44e7bf1199 | ||
|
|
f4ae2188e0 | ||
|
|
20f659db89 | ||
|
|
0e04e82b88 | ||
|
|
f874449d36 | ||
|
|
397036640e | ||
|
|
60110350aa | ||
|
|
a57f0ab360 | ||
|
|
e0dd3c34b2 | ||
|
|
472b0d7086 | ||
|
|
0bd8217d9e | ||
|
|
fdb1ab4bd9 | ||
|
|
61b34c8b16 | ||
|
|
9f1f2910e4 | ||
|
|
6050a0a7d7 | ||
|
|
72f1686395 | ||
|
|
d284d36c24 | ||
|
|
6cc6b0c239 | ||
|
|
8e5330fb82 | ||
|
|
2d0a367f1a | ||
|
|
02b5f4d390 | ||
|
|
d1fead5050 | ||
|
|
9a831e8e34 | ||
|
|
5f92b0bbc1 | ||
|
|
19232a81ef | ||
|
|
d1278c252b | ||
|
|
273d9675bf | ||
|
|
b4620cfea6 | ||
|
|
2c8f824240 | ||
|
|
7c34f76695 | ||
|
|
72d7ecb2ed | ||
|
|
75e70b5477 | ||
|
|
4eca127781 | ||
|
|
d27ecaae5e | ||
|
|
f0898613a2 | ||
|
|
40a2933e25 | ||
|
|
a208ab36b8 | ||
|
|
680c665242 | ||
|
|
6b141c3ea0 | ||
|
|
a039168217 | ||
|
|
e4fe749251 | ||
|
|
ed5e6ec0f7 | ||
|
|
1aec431c36 | ||
|
|
cb87463a69 | ||
|
|
4b5c74e8d6 | ||
|
|
ab18e15a71 | ||
|
|
7ff5376d13 | ||
|
|
516c68224a | ||
|
|
7b93fbeba3 | ||
|
|
f958067139 | ||
|
|
4e606836a1 | ||
|
|
5da5ee3581 | ||
|
|
302ac2e644 | ||
|
|
baab56b6d8 | ||
|
|
79c4f13440 | ||
|
|
7b3db11b82 | ||
|
|
3ffca75915 | ||
|
|
f72dd3471e | ||
|
|
3f55103542 | ||
|
|
b39fe87eea | ||
|
|
bfc81e52b0 | ||
|
|
54f5d159a5 | ||
|
|
a2ed7c7117 | ||
|
|
161e87dbda | ||
|
|
4c7581df4f | ||
|
|
bfd1b21f9c | ||
|
|
84ee25e441 | ||
|
|
47683f2b8c | ||
|
|
81f1f48045 | ||
|
|
f5fda5d8ea |
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: [fosrl]
|
||||||
78
.github/workflows/cicd.yml
vendored
Normal file
78
.github/workflows/cicd.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
name: CI/CD Pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Build and Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract tag name
|
||||||
|
id: get-tag
|
||||||
|
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Install Go
|
||||||
|
uses: actions/setup-go@v4
|
||||||
|
with:
|
||||||
|
go-version: 1.23.0
|
||||||
|
|
||||||
|
- name: Update version in package.json
|
||||||
|
run: |
|
||||||
|
TAG=${{ env.TAG }}
|
||||||
|
sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts
|
||||||
|
cat server/lib/consts.ts
|
||||||
|
|
||||||
|
- name: Pull latest Gerbil version
|
||||||
|
id: get-gerbil-tag
|
||||||
|
run: |
|
||||||
|
LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name')
|
||||||
|
echo "LATEST_GERBIL_TAG=$LATEST_TAG" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Pull latest Badger version
|
||||||
|
id: get-badger-tag
|
||||||
|
run: |
|
||||||
|
LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name')
|
||||||
|
echo "LATEST_BADGER_TAG=$LATEST_TAG" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Update install/main.go
|
||||||
|
run: |
|
||||||
|
PANGOLIN_VERSION=${{ env.TAG }}
|
||||||
|
GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }}
|
||||||
|
BADGER_VERSION=${{ env.LATEST_BADGER_TAG }}
|
||||||
|
sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$PANGOLIN_VERSION\"/" install/main.go
|
||||||
|
sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$GERBIL_VERSION\"/" install/main.go
|
||||||
|
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$BADGER_VERSION\"/" install/main.go
|
||||||
|
echo "Updated install/main.go with Pangolin version $PANGOLIN_VERSION, Gerbil version $GERBIL_VERSION, and Badger version $BADGER_VERSION"
|
||||||
|
cat install/main.go
|
||||||
|
|
||||||
|
- name: Build installer
|
||||||
|
working-directory: install
|
||||||
|
run: |
|
||||||
|
make release
|
||||||
|
|
||||||
|
- name: Upload artifacts from /install/bin
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: install-bin
|
||||||
|
path: install/bin/
|
||||||
|
|
||||||
|
- name: Build and push Docker images
|
||||||
|
run: |
|
||||||
|
TAG=${{ env.TAG }}
|
||||||
|
make build-release tag=$TAG
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -31,3 +31,5 @@ dist
|
|||||||
installer
|
installer
|
||||||
*.tar
|
*.tar
|
||||||
bin
|
bin
|
||||||
|
.secrets
|
||||||
|
test_event.json
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ COPY --from=builder /app/dist ./dist
|
|||||||
COPY --from=builder /app/init ./dist/init
|
COPY --from=builder /app/init ./dist/init
|
||||||
|
|
||||||
COPY config/config.example.yml ./dist/config.example.yml
|
COPY config/config.example.yml ./dist/config.example.yml
|
||||||
|
COPY config/traefik/traefik_config.example.yml ./dist/traefik_config.example.yml
|
||||||
|
COPY config/traefik/dynamic_config.example.yml ./dist/dynamic_config.example.yml
|
||||||
COPY server/db/names.json ./dist/names.json
|
COPY server/db/names.json ./dist/names.json
|
||||||
|
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
|||||||
3
Makefile
3
Makefile
@@ -12,9 +12,6 @@ build-arm:
|
|||||||
build-x86:
|
build-x86:
|
||||||
docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest .
|
docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest .
|
||||||
|
|
||||||
build-x86-ecr:
|
|
||||||
docker buildx build --platform linux/amd64 -t 216989133116.dkr.ecr.us-east-1.amazonaws.com/pangolin:latest --push .
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
docker build -t fosrl/pangolin:latest .
|
docker build -t fosrl/pangolin:latest .
|
||||||
|
|
||||||
|
|||||||
62
README.md
62
README.md
@@ -1,4 +1,5 @@
|
|||||||
# Pangolin
|
<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://docs.fossorial.io/)
|
||||||
[](https://hub.docker.com/r/fosrl/pangolin)
|
[](https://hub.docker.com/r/fosrl/pangolin)
|
||||||
@@ -6,14 +7,28 @@
|
|||||||
[](https://discord.gg/HCJR8Xhme4)
|
[](https://discord.gg/HCJR8Xhme4)
|
||||||
[](https://www.youtube.com/@fossorial-app)
|
[](https://www.youtube.com/@fossorial-app)
|
||||||
|
|
||||||
Pangolin is a self-hosted tunneled reverse proxy management server with identity and access management, designed to securely expose private resources through use with the Traefik reverse proxy and WireGuard tunnel clients like Newt. With Pangolin, you retain full control over your infrastructure while providing a user-friendly and feature-rich solution for managing proxies, authentication, and access, and simplifying complex network setups, all with a clean and simple UI.
|
</div>
|
||||||
|
|
||||||
### Installation and Documentation
|
<div align="center">
|
||||||
|
<h5>
|
||||||
|
<a href="https://docs.fossorial.io/Getting%20Started/quick-install">
|
||||||
|
Install Guide
|
||||||
|
</a>
|
||||||
|
<span> | </span>
|
||||||
|
<a href="https://docs.fossorial.io">
|
||||||
|
Full Documentation
|
||||||
|
</a>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
- [Installation Instructions](https://docs.fossorial.io/Getting%20Started/quick-install)
|
<h3 align="center">Tunneled Mesh Reverse Proxy Server with Access Control</h3>
|
||||||
- [Full Documentation](https://docs.fossorial.io)
|
<div align="center">
|
||||||
|
|
||||||
## Preview
|
_Your own self-hosted zero trust tunnel._
|
||||||
|
|
||||||
|
</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/sites.png" alt="Preview"/>
|
||||||
|
|
||||||
@@ -23,15 +38,18 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
|||||||
|
|
||||||
### Reverse Proxy Through WireGuard Tunnel
|
### Reverse Proxy Through WireGuard Tunnel
|
||||||
|
|
||||||
- Expose private resources on your network **without opening ports**.
|
- 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).
|
- 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.
|
- Built-in support for any WireGuard client.
|
||||||
- Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/).
|
- Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/).
|
||||||
|
- Support for HTTP/HTTPS and **raw TCP/UDP services**.
|
||||||
|
- Load balancing.
|
||||||
|
|
||||||
### Identity & Access Management
|
### Identity & Access Management
|
||||||
|
|
||||||
- Centralized authentication system using platform SSO. **Users will only have to manage one login.**
|
- Centralized authentication system using platform SSO. **Users will only have to manage one login.**
|
||||||
- Totp with backup codes for two-factor authentication.
|
- **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.
|
- Create organizations, each with multiple sites, users, and roles.
|
||||||
- **Role-based access control** to manage resource access permissions.
|
- **Role-based access control** to manage resource access permissions.
|
||||||
- Additional authentication options include:
|
- Additional authentication options include:
|
||||||
@@ -49,20 +67,18 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
|||||||
|
|
||||||
### Easy Deployment
|
### Easy Deployment
|
||||||
|
|
||||||
|
- Run on any cloud provider or on-premises.
|
||||||
- Docker Compose based setup for simplified deployment.
|
- Docker Compose based setup for simplified deployment.
|
||||||
- Future-proof installation script for streamlined setup and feature additions.
|
- Future-proof installation script for streamlined setup and feature additions.
|
||||||
- Run on any VPS.
|
|
||||||
- Use your preferred WireGuard client to connect, or use Newt, our custom user space client for the best experience.
|
- Use your preferred WireGuard client to connect, or use Newt, our custom user space client for the best experience.
|
||||||
|
|
||||||
### Modular Design
|
### Modular Design
|
||||||
|
|
||||||
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [Fail2Ban](https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban) or [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin), which integrate seamlessly.
|
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [Fail2Ban](https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban) or [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin).
|
||||||
- Attach as many sites to the central server as you wish.
|
- Attach as many sites to the central server as you wish.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
Pangolin has a straightforward and simple dashboard UI:
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -88,22 +104,23 @@ Pangolin has a straightforward and simple dashboard UI:
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Workflow Example
|
## Deployment and Usage Example
|
||||||
|
|
||||||
### Deployment and Usage Example
|
|
||||||
|
|
||||||
1. **Deploy the Central Server**:
|
1. **Deploy the Central Server**:
|
||||||
|
|
||||||
- Deploy the Docker Compose stack containing Pangolin, Gerbil, and Traefik onto a VPS hosted on a cloud platform like 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 Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
|
||||||
|
|
||||||
2. **Domain Configuration**:
|
2. **Domain Configuration**:
|
||||||
|
|
||||||
- Point your domain name to the VPS and configure Pangolin with your preferred settings.
|
- Point your domain name to the VPS and configure Pangolin with your preferred settings.
|
||||||
|
|
||||||
3. **Connect Private Sites**:
|
3. **Connect Private Sites**:
|
||||||
|
|
||||||
- Install Newt or use another WireGuard client on private sites.
|
- Install Newt or use another WireGuard client on private sites.
|
||||||
- Automatically establish a connection from these sites to the central server.
|
- Automatically establish a connection from these sites to the central server.
|
||||||
|
|
||||||
4. **Configure Users & Roles**
|
4. **Configure Users & Roles**
|
||||||
|
|
||||||
- Define organizations and invite users.
|
- Define organizations and invite users.
|
||||||
- Implement user- or role-based permissions to control resource access.
|
- Implement user- or role-based permissions to control resource access.
|
||||||
|
|
||||||
@@ -115,14 +132,19 @@ Pangolin has a straightforward and simple dashboard UI:
|
|||||||
|
|
||||||
## Similar Projects and Inspirations
|
## Similar Projects and Inspirations
|
||||||
|
|
||||||
Pangolin was inspired by several existing projects and concepts:
|
**Cloudflare Tunnels**:
|
||||||
|
|
||||||
- **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**:
|
**Authentik and Authelia**:
|
||||||
These projects inspired Pangolin’s centralized authentication system for proxies, enabling robust user and role management.
|
These projects inspired Pangolin’s centralized authentication system for proxies, enabling robust user and role management.
|
||||||
|
|
||||||
|
## Project Development / Roadmap
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Pangolin is under heavy development. The roadmap is subject to change as we fix bugs, add new features, and make improvements.
|
||||||
|
|
||||||
|
View the [project board](https://github.com/orgs/fosrl/projects/1) for more detailed info.
|
||||||
|
|
||||||
## Licensing
|
## Licensing
|
||||||
|
|
||||||
Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us.
|
Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us.
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
app:
|
app:
|
||||||
dashboard_url: http://localhost
|
dashboard_url: "http://localhost:3002"
|
||||||
base_domain: localhost
|
base_domain: "localhost"
|
||||||
log_level: debug
|
log_level: "info"
|
||||||
save_logs: false
|
save_logs: false
|
||||||
|
|
||||||
server:
|
server:
|
||||||
external_port: 3000
|
external_port: 3000
|
||||||
internal_port: 3001
|
internal_port: 3001
|
||||||
next_port: 3002
|
next_port: 3002
|
||||||
internal_hostname: localhost
|
internal_hostname: "pangolin"
|
||||||
secure_cookies: false
|
session_cookie_name: "p_session_token"
|
||||||
session_cookie_name: p_session
|
resource_access_token_param: "p_token"
|
||||||
resource_session_cookie_name: p_resource_session
|
resource_session_request_param: "p_session_request"
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
cert_resolver: letsencrypt
|
cert_resolver: "letsencrypt"
|
||||||
http_entrypoint: web
|
http_entrypoint: "web"
|
||||||
https_entrypoint: websecure
|
https_entrypoint: "websecure"
|
||||||
|
|
||||||
gerbil:
|
gerbil:
|
||||||
start_port: 51820
|
start_port: 51820
|
||||||
base_endpoint: localhost
|
base_endpoint: "localhost"
|
||||||
block_size: 24
|
block_size: 24
|
||||||
site_block_size: 30
|
site_block_size: 30
|
||||||
subnet_group: 100.89.137.0/20
|
subnet_group: 100.89.137.0/20
|
||||||
@@ -33,8 +33,12 @@ rate_limits:
|
|||||||
|
|
||||||
users:
|
users:
|
||||||
server_admin:
|
server_admin:
|
||||||
email: admin@example.com
|
email: "admin@example.com"
|
||||||
password: Password123!
|
password: "Password123!"
|
||||||
|
|
||||||
flags:
|
flags:
|
||||||
require_email_verification: false
|
require_email_verification: false
|
||||||
|
disable_signup_without_invite: true
|
||||||
|
disable_user_create_org: true
|
||||||
|
allow_raw_resources: true
|
||||||
|
allow_base_domain_resources: true
|
||||||
|
|||||||
53
config/traefik/dynamic_config.example.yml
Normal file
53
config/traefik/dynamic_config.example.yml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
http:
|
||||||
|
middlewares:
|
||||||
|
redirect-to-https:
|
||||||
|
redirectScheme:
|
||||||
|
scheme: https
|
||||||
|
|
||||||
|
routers:
|
||||||
|
# HTTP to HTTPS redirect router
|
||||||
|
main-app-router-redirect:
|
||||||
|
rule: "Host(`{{.DashboardDomain}}`)"
|
||||||
|
service: next-service
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
middlewares:
|
||||||
|
- redirect-to-https
|
||||||
|
|
||||||
|
# Next.js router (handles everything except API and WebSocket paths)
|
||||||
|
next-router:
|
||||||
|
rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)"
|
||||||
|
service: next-service
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
|
||||||
|
# API router (handles /api/v1 paths)
|
||||||
|
api-router:
|
||||||
|
rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)"
|
||||||
|
service: api-service
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
|
||||||
|
# WebSocket router
|
||||||
|
ws-router:
|
||||||
|
rule: "Host(`{{.DashboardDomain}}`)"
|
||||||
|
service: api-service
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
|
||||||
|
services:
|
||||||
|
next-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://pangolin:{{.NEXT_PORT}}" # Next.js server
|
||||||
|
|
||||||
|
api-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://pangolin:{{.EXTERNAL_PORT}}" # API/WebSocket server
|
||||||
44
config/traefik/traefik_config.example.yml
Normal file
44
config/traefik/traefik_config.example.yml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
api:
|
||||||
|
insecure: true
|
||||||
|
dashboard: true
|
||||||
|
|
||||||
|
providers:
|
||||||
|
http:
|
||||||
|
endpoint: "http://pangolin:{{.INTERNAL_PORT}}/api/v1/traefik-config"
|
||||||
|
pollInterval: "5s"
|
||||||
|
file:
|
||||||
|
filename: "/etc/traefik/dynamic_config.yml"
|
||||||
|
|
||||||
|
experimental:
|
||||||
|
plugins:
|
||||||
|
badger:
|
||||||
|
moduleName: "github.com/fosrl/badger"
|
||||||
|
version: "v1.0.0-beta.3"
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: "INFO"
|
||||||
|
format: "common"
|
||||||
|
|
||||||
|
certificatesResolvers:
|
||||||
|
letsencrypt:
|
||||||
|
acme:
|
||||||
|
httpChallenge:
|
||||||
|
entryPoint: web
|
||||||
|
email: "{{.LetsEncryptEmail}}"
|
||||||
|
storage: "/letsencrypt/acme.json"
|
||||||
|
caServer: "https://acme-v02.api.letsencrypt.org/directory"
|
||||||
|
|
||||||
|
entryPoints:
|
||||||
|
web:
|
||||||
|
address: ":80"
|
||||||
|
websecure:
|
||||||
|
address: ":443"
|
||||||
|
transport:
|
||||||
|
respondingTimeouts:
|
||||||
|
readTimeout: "30m"
|
||||||
|
http:
|
||||||
|
tls:
|
||||||
|
certResolver: "letsencrypt"
|
||||||
|
|
||||||
|
serversTransport:
|
||||||
|
insecureSkipVerify: true
|
||||||
@@ -37,7 +37,7 @@ services:
|
|||||||
- 80:80 # Port for traefik because of the network_mode
|
- 80:80 # Port for traefik because of the network_mode
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:v3.1
|
image: traefik:v3.3.3
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: service:gerbil # Ports appear on the gerbil service
|
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||||
@@ -49,3 +49,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./traefik:/etc/traefik:ro # Volume to store the Traefik configuration
|
- ./traefik:/etc/traefik:ro # Volume to store the Traefik configuration
|
||||||
- ./letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
- ./letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
driver: bridge
|
||||||
|
name: pangolin
|
||||||
9
eslint.config.js
Normal file
9
eslint.config.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// eslint.config.js
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
semi: "error",
|
||||||
|
"prefer-const": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
build:
|
build:
|
||||||
@@ -9,6 +8,6 @@ release:
|
|||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm bin/installer
|
rm -f bin/installer
|
||||||
rm bin/installer_linux_amd64
|
rm -f bin/installer_linux_amd64
|
||||||
rm bin/installer_linux_arm64
|
rm -f bin/installer_linux_arm64
|
||||||
|
|||||||
@@ -1,27 +1,32 @@
|
|||||||
app:
|
app:
|
||||||
dashboard_url: https://{{.DashboardDomain}}
|
dashboard_url: "https://{{.DashboardDomain}}"
|
||||||
base_domain: {{.BaseDomain}}
|
base_domain: "{{.BaseDomain}}"
|
||||||
log_level: info
|
log_level: "info"
|
||||||
save_logs: false
|
save_logs: false
|
||||||
|
|
||||||
server:
|
server:
|
||||||
external_port: 3000
|
external_port: 3000
|
||||||
internal_port: 3001
|
internal_port: 3001
|
||||||
next_port: 3002
|
next_port: 3002
|
||||||
internal_hostname: pangolin
|
internal_hostname: "pangolin"
|
||||||
secure_cookies: false
|
session_cookie_name: "p_session_token"
|
||||||
session_cookie_name: p_session
|
resource_access_token_param: "p_token"
|
||||||
resource_session_cookie_name: p_resource_session
|
resource_session_request_param: "p_session_request"
|
||||||
|
cors:
|
||||||
|
origins: ["https://{{.DashboardDomain}}"]
|
||||||
|
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||||
|
headers: ["X-CSRF-Token", "Content-Type"]
|
||||||
|
credentials: false
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
cert_resolver: letsencrypt
|
cert_resolver: "letsencrypt"
|
||||||
http_entrypoint: web
|
http_entrypoint: "web"
|
||||||
https_entrypoint: websecure
|
https_entrypoint: "websecure"
|
||||||
prefer_wildcard_cert: false
|
prefer_wildcard_cert: false
|
||||||
|
|
||||||
gerbil:
|
gerbil:
|
||||||
start_port: 51820
|
start_port: 51820
|
||||||
base_endpoint: {{.DashboardDomain}}
|
base_endpoint: "{{.DashboardDomain}}"
|
||||||
use_subdomain: false
|
use_subdomain: false
|
||||||
block_size: 24
|
block_size: 24
|
||||||
site_block_size: 30
|
site_block_size: 30
|
||||||
@@ -33,18 +38,20 @@ rate_limits:
|
|||||||
max_requests: 100
|
max_requests: 100
|
||||||
{{if .EnableEmail}}
|
{{if .EnableEmail}}
|
||||||
email:
|
email:
|
||||||
smtp_host: {{.EmailSMTPHost}}
|
smtp_host: "{{.EmailSMTPHost}}"
|
||||||
smtp_port: {{.EmailSMTPPort}}
|
smtp_port: {{.EmailSMTPPort}}
|
||||||
smtp_user: {{.EmailSMTPUser}}
|
smtp_user: "{{.EmailSMTPUser}}"
|
||||||
smtp_pass: {{.EmailSMTPPass}}
|
smtp_pass: "{{.EmailSMTPPass}}"
|
||||||
no_reply: {{.EmailNoReply}}
|
no_reply: "{{.EmailNoReply}}"
|
||||||
{{end}}
|
{{end}}
|
||||||
users:
|
users:
|
||||||
server_admin:
|
server_admin:
|
||||||
email: {{.AdminUserEmail}}
|
email: "{{.AdminUserEmail}}"
|
||||||
password: {{.AdminUserPassword}}
|
password: "{{.AdminUserPassword}}"
|
||||||
|
|
||||||
flags:
|
flags:
|
||||||
require_email_verification: {{.EnableEmail}}
|
require_email_verification: {{.EnableEmail}}
|
||||||
disable_signup_without_invite: {{.DisableSignupWithoutInvite}}
|
disable_signup_without_invite: {{.DisableSignupWithoutInvite}}
|
||||||
disable_user_create_org: {{.DisableUserCreateOrg}}
|
disable_user_create_org: {{.DisableUserCreateOrg}}
|
||||||
|
allow_raw_resources: true
|
||||||
|
allow_base_domain_resources: true
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
pangolin:
|
pangolin:
|
||||||
image: fosrl/pangolin:latest
|
image: fosrl/pangolin:{{.PangolinVersion}}
|
||||||
container_name: pangolin
|
container_name: pangolin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
@@ -11,8 +11,9 @@ services:
|
|||||||
timeout: "3s"
|
timeout: "3s"
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
{{if .InstallGerbil}}
|
||||||
gerbil:
|
gerbil:
|
||||||
image: fosrl/gerbil:latest
|
image: fosrl/gerbil:{{.GerbilVersion}}
|
||||||
container_name: gerbil
|
container_name: gerbil
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -32,12 +33,20 @@ services:
|
|||||||
- 51820:51820/udp
|
- 51820:51820/udp
|
||||||
- 443:443 # Port for traefik because of the network_mode
|
- 443:443 # Port for traefik because of the network_mode
|
||||||
- 80:80 # Port for traefik because of the network_mode
|
- 80:80 # Port for traefik because of the network_mode
|
||||||
|
{{end}}
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:v3.1
|
image: traefik:v3.3.3
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
{{if .InstallGerbil}}
|
||||||
network_mode: service:gerbil # Ports appear on the gerbil service
|
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||||
|
{{end}}
|
||||||
|
{{if not .InstallGerbil}}
|
||||||
|
ports:
|
||||||
|
- 443:443
|
||||||
|
- 80:80
|
||||||
|
{{end}}
|
||||||
depends_on:
|
depends_on:
|
||||||
pangolin:
|
pangolin:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -46,3 +55,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
|
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
|
||||||
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
driver: bridge
|
||||||
|
name: pangolin
|
||||||
@@ -3,7 +3,6 @@ http:
|
|||||||
redirect-to-https:
|
redirect-to-https:
|
||||||
redirectScheme:
|
redirectScheme:
|
||||||
scheme: https
|
scheme: https
|
||||||
permanent: true
|
|
||||||
|
|
||||||
routers:
|
routers:
|
||||||
# HTTP to HTTPS redirect router
|
# HTTP to HTTPS redirect router
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ experimental:
|
|||||||
plugins:
|
plugins:
|
||||||
badger:
|
badger:
|
||||||
moduleName: "github.com/fosrl/badger"
|
moduleName: "github.com/fosrl/badger"
|
||||||
version: "v1.0.0-beta.1"
|
version: "{{.BadgerVersion}}"
|
||||||
|
|
||||||
log:
|
log:
|
||||||
level: "INFO"
|
level: "INFO"
|
||||||
@@ -33,6 +33,9 @@ entryPoints:
|
|||||||
address: ":80"
|
address: ":80"
|
||||||
websecure:
|
websecure:
|
||||||
address: ":443"
|
address: ":443"
|
||||||
|
transport:
|
||||||
|
respondingTimeouts:
|
||||||
|
readTimeout: "30m"
|
||||||
http:
|
http:
|
||||||
tls:
|
tls:
|
||||||
certResolver: "letsencrypt"
|
certResolver: "letsencrypt"
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
module installer
|
module installer
|
||||||
|
|
||||||
go 1.23.0
|
go 1.23.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
golang.org/x/sys v0.29.0 // indirect
|
||||||
|
golang.org/x/term v0.28.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
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=
|
||||||
|
|||||||
161
install/main.go
161
install/main.go
@@ -10,27 +10,41 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"text/template"
|
"text/template"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD
|
||||||
|
func loadVersions(config *Config) {
|
||||||
|
config.PangolinVersion = "replaceme"
|
||||||
|
config.GerbilVersion = "replaceme"
|
||||||
|
config.BadgerVersion = "replaceme"
|
||||||
|
}
|
||||||
|
|
||||||
//go:embed fs/*
|
//go:embed fs/*
|
||||||
var configFiles embed.FS
|
var configFiles embed.FS
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
BaseDomain string `yaml:"baseDomain"`
|
PangolinVersion string
|
||||||
DashboardDomain string `yaml:"dashboardUrl"`
|
GerbilVersion string
|
||||||
LetsEncryptEmail string `yaml:"letsEncryptEmail"`
|
BadgerVersion string
|
||||||
AdminUserEmail string `yaml:"adminUserEmail"`
|
BaseDomain string
|
||||||
AdminUserPassword string `yaml:"adminUserPassword"`
|
DashboardDomain string
|
||||||
DisableSignupWithoutInvite bool `yaml:"disableSignupWithoutInvite"`
|
LetsEncryptEmail string
|
||||||
DisableUserCreateOrg bool `yaml:"disableUserCreateOrg"`
|
AdminUserEmail string
|
||||||
EnableEmail bool `yaml:"enableEmail"`
|
AdminUserPassword string
|
||||||
EmailSMTPHost string `yaml:"emailSMTPHost"`
|
DisableSignupWithoutInvite bool
|
||||||
EmailSMTPPort int `yaml:"emailSMTPPort"`
|
DisableUserCreateOrg bool
|
||||||
EmailSMTPUser string `yaml:"emailSMTPUser"`
|
EnableEmail bool
|
||||||
EmailSMTPPass string `yaml:"emailSMTPPass"`
|
EmailSMTPHost string
|
||||||
EmailNoReply string `yaml:"emailNoReply"`
|
EmailSMTPPort int
|
||||||
|
EmailSMTPUser string
|
||||||
|
EmailSMTPPass string
|
||||||
|
EmailNoReply string
|
||||||
|
InstallGerbil bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -45,13 +59,16 @@ func main() {
|
|||||||
// check if there is already a config file
|
// check if there is already a config file
|
||||||
if _, err := os.Stat("config/config.yml"); err != nil {
|
if _, err := os.Stat("config/config.yml"); err != nil {
|
||||||
config := collectUserInput(reader)
|
config := collectUserInput(reader)
|
||||||
|
|
||||||
|
loadVersions(&config)
|
||||||
|
|
||||||
if err := createConfigFiles(config); err != nil {
|
if err := createConfigFiles(config); err != nil {
|
||||||
fmt.Printf("Error creating config files: %v\n", err)
|
fmt.Printf("Error creating config files: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isDockerInstalled() && runtime.GOOS == "linux" {
|
if !isDockerInstalled() && runtime.GOOS == "linux" {
|
||||||
if shouldInstallDocker() {
|
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
|
||||||
installDocker()
|
installDocker()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,6 +99,24 @@ func readString(reader *bufio.Reader, prompt string, defaultValue string) string
|
|||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readPassword(prompt string) string {
|
||||||
|
fmt.Print(prompt + ": ")
|
||||||
|
|
||||||
|
// Read password without echo
|
||||||
|
password, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
|
fmt.Println() // Add a newline since ReadPassword doesn't add one
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
input := strings.TrimSpace(string(password))
|
||||||
|
if input == "" {
|
||||||
|
return readPassword(prompt)
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
|
func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
|
||||||
defaultStr := "no"
|
defaultStr := "no"
|
||||||
if defaultValue {
|
if defaultValue {
|
||||||
@@ -109,21 +144,29 @@ func collectUserInput(reader *bufio.Reader) Config {
|
|||||||
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
|
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.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.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)
|
||||||
|
|
||||||
// Admin user configuration
|
// Admin user configuration
|
||||||
fmt.Println("\n=== Admin User Configuration ===")
|
fmt.Println("\n=== Admin User Configuration ===")
|
||||||
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain)
|
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain)
|
||||||
for {
|
for {
|
||||||
config.AdminUserPassword = readString(reader, "Enter admin user password", "")
|
pass1 := readPassword("Create admin user password")
|
||||||
if valid, message := validatePassword(config.AdminUserPassword); valid {
|
pass2 := readPassword("Confirm admin user password")
|
||||||
break
|
|
||||||
|
if pass1 != pass2 {
|
||||||
|
fmt.Println("Passwords do not match")
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Invalid password:", message)
|
config.AdminUserPassword = pass1
|
||||||
fmt.Println("Password requirements:")
|
if valid, message := validatePassword(config.AdminUserPassword); valid {
|
||||||
fmt.Println("- At least one uppercase English letter")
|
break
|
||||||
fmt.Println("- At least one lowercase English letter")
|
} else {
|
||||||
fmt.Println("- At least one digit")
|
fmt.Println("Invalid password:", message)
|
||||||
fmt.Println("- At least one special character")
|
fmt.Println("Password requirements:")
|
||||||
|
fmt.Println("- At least one uppercase English letter")
|
||||||
|
fmt.Println("- At least one lowercase English letter")
|
||||||
|
fmt.Println("- At least one digit")
|
||||||
|
fmt.Println("- At least one special character")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +274,11 @@ func createConfigFiles(config Config) error {
|
|||||||
// Get the relative path by removing the "fs/" prefix
|
// Get the relative path by removing the "fs/" prefix
|
||||||
relPath := strings.TrimPrefix(path, "fs/")
|
relPath := strings.TrimPrefix(path, "fs/")
|
||||||
|
|
||||||
|
// skip .DS_Store
|
||||||
|
if strings.Contains(relPath, ".DS_Store") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Create the full output path under "config/"
|
// Create the full output path under "config/"
|
||||||
outPath := filepath.Join("config", relPath)
|
outPath := filepath.Join("config", relPath)
|
||||||
|
|
||||||
@@ -302,13 +350,6 @@ func createConfigFiles(config Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldInstallDocker() bool {
|
|
||||||
reader := bufio.NewReader(os.Stdin)
|
|
||||||
fmt.Print("Would you like to install Docker? (yes/no): ")
|
|
||||||
response, _ := reader.ReadString('\n')
|
|
||||||
return strings.ToLower(strings.TrimSpace(response)) == "yes"
|
|
||||||
}
|
|
||||||
|
|
||||||
func installDocker() error {
|
func installDocker() error {
|
||||||
// Detect Linux distribution
|
// Detect Linux distribution
|
||||||
cmd := exec.Command("cat", "/etc/os-release")
|
cmd := exec.Command("cat", "/etc/os-release")
|
||||||
@@ -399,29 +440,53 @@ func isDockerInstalled() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getCommandString(useNewStyle bool) string {
|
||||||
|
if useNewStyle {
|
||||||
|
return "'docker compose'"
|
||||||
|
}
|
||||||
|
return "'docker-compose'"
|
||||||
|
}
|
||||||
|
|
||||||
func pullAndStartContainers() error {
|
func pullAndStartContainers() error {
|
||||||
fmt.Println("Starting containers...")
|
fmt.Println("Starting containers...")
|
||||||
|
|
||||||
// First try docker compose (new style)
|
// Check which docker compose command is available
|
||||||
cmd := exec.Command("docker", "compose", "-f", "docker-compose.yml", "pull")
|
var useNewStyle bool
|
||||||
cmd.Stdout = os.Stdout
|
checkCmd := exec.Command("docker", "compose", "version")
|
||||||
cmd.Stderr = os.Stderr
|
if err := checkCmd.Run(); err == nil {
|
||||||
err := cmd.Run()
|
useNewStyle = true
|
||||||
|
} else {
|
||||||
if err != nil {
|
// Check if docker-compose (old style) is available
|
||||||
fmt.Println("Failed to start containers using docker compose, falling back to docker-compose command")
|
checkCmd = exec.Command("docker-compose", "version")
|
||||||
os.Exit(1)
|
if err := checkCmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd = exec.Command("docker", "compose", "-f", "docker-compose.yml", "up", "-d")
|
// Helper function to execute docker compose commands
|
||||||
cmd.Stdout = os.Stdout
|
executeCommand := func(args ...string) error {
|
||||||
cmd.Stderr = os.Stderr
|
var cmd *exec.Cmd
|
||||||
err = cmd.Run()
|
if useNewStyle {
|
||||||
|
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||||
if err != nil {
|
} else {
|
||||||
fmt.Println("Failed to start containers using docker-compose command")
|
cmd = exec.Command("docker-compose", args...)
|
||||||
os.Exit(1)
|
}
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 fmt.Errorf("failed to start containers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
267
internationalization/de.md
Normal file
267
internationalization/de.md
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
## Login site
|
||||||
|
|
||||||
|
| EN | DE | Notes |
|
||||||
|
| --------------------- | ---------------------------------- | ----------- |
|
||||||
|
| Welcome to Pangolin | Willkommen bei Pangolin | |
|
||||||
|
| Log in to get started | Melden Sie sich an, um zu beginnen | |
|
||||||
|
| Email | E-Mail | |
|
||||||
|
| Enter your email | Geben Sie Ihre E-Mail-Adresse ein | placeholder |
|
||||||
|
| Password | Passwort | |
|
||||||
|
| Enter your password | Geben Sie Ihr Passwort ein | placeholder |
|
||||||
|
| Forgot your password? | Passwort vergessen? | |
|
||||||
|
| Log in | Anmelden | |
|
||||||
|
|
||||||
|
# Ogranization site after successful login
|
||||||
|
|
||||||
|
| EN | DE | Notes |
|
||||||
|
| ----------------------------------------- | -------------------------------------------- | ----- |
|
||||||
|
| Welcome to Pangolin | Willkommen bei Pangolin | |
|
||||||
|
| You're a member of {number} organization. | Sie sind Mitglied von {number} Organisation. | |
|
||||||
|
|
||||||
|
## Shared Header, Navbar and Footer
|
||||||
|
##### Header
|
||||||
|
|
||||||
|
| EN | DE | Notes |
|
||||||
|
| ------------------- | ------------------- | ----- |
|
||||||
|
| Documentation | Dokumentation | |
|
||||||
|
| Support | Support | |
|
||||||
|
| Organization {name} | Organisation {name} | |
|
||||||
|
##### Organization selector
|
||||||
|
|
||||||
|
| EN | DE | Notes |
|
||||||
|
| ---------------- | ----------------- | ----- |
|
||||||
|
| Search… | Suchen… | |
|
||||||
|
| Create | Erstellen | |
|
||||||
|
| New Organization | Neue Organisation | |
|
||||||
|
| Organizations | Organisationen | |
|
||||||
|
|
||||||
|
##### Navbar
|
||||||
|
|
||||||
|
| EN | DE | Notes |
|
||||||
|
| --------------- | ----------------- | ----- |
|
||||||
|
| Sites | Websites | |
|
||||||
|
| Resources | Ressourcen | |
|
||||||
|
| User & Roles | Benutzer & Rollen | |
|
||||||
|
| Shareable Links | Teilbare Links | |
|
||||||
|
| General | Allgemein | |
|
||||||
|
##### Footer
|
||||||
|
| EN | DE | |
|
||||||
|
| ------------------------- | --------------------------- | ------------------- |
|
||||||
|
| Page {number} of {number} | Seite {number} von {number} | |
|
||||||
|
| Rows per page | Zeilen pro Seite | |
|
||||||
|
| Pangolin | Pangolin | unten auf der Seite |
|
||||||
|
| Built by Fossorial | Erstellt von Fossorial | unten auf der Seite |
|
||||||
|
| Open Source | Open Source | unten auf der Seite |
|
||||||
|
| Documentation | Dokumentation | unten auf der Seite |
|
||||||
|
| {version} | {version} | unten auf der Seite |
|
||||||
|
|
||||||
|
## Main “Sites”
|
||||||
|
##### “Hero” section
|
||||||
|
|
||||||
|
| EN | DE | Notes |
|
||||||
|
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||||
|
| Newt (Recommended) | Newt (empfohlen) | |
|
||||||
|
| 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. | Für das beste Benutzererlebnis verwenden Sie Newt. Es nutzt WireGuard im Hintergrund und ermöglicht es Ihnen, auf Ihre privaten Ressourcen über ihre LAN-Adresse in Ihrem privaten Netzwerk direkt aus dem Pangolin-Dashboard zuzugreifen. | |
|
||||||
|
| Runs in Docker | Läuft in Docker | |
|
||||||
|
| Runs in shell on macOS, Linux, and Windows | Läuft in der Shell auf macOS, Linux und Windows | |
|
||||||
|
| Install Newt | Newt installieren | |
|
||||||
|
| Basic WireGuard<br> | Verwenden Sie einen beliebigen WireGuard-Client, um eine Verbindung herzustellen. Sie müssen auf Ihre internen Ressourcen über die Peer-IP-Adresse zugreifen. | |
|
||||||
|
| Compatible with all WireGuard clients<br> | Kompatibel mit allen WireGuard-Clients<br> | |
|
||||||
|
| Manual configuration required | Manuelle Konfiguration erforderlich<br> | |
|
||||||
|
##### Content
|
||||||
|
|
||||||
|
| EN | DE | Notes |
|
||||||
|
| --------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------- |
|
||||||
|
| Manage Sites | Seiten verwalten | |
|
||||||
|
| Allow connectivity to your network through secure tunnels | Ermöglichen Sie die Verbindung zu Ihrem Netzwerk über ein sicheren Tunnel | |
|
||||||
|
| Search sites | Seiten suchen | placeholder |
|
||||||
|
| Add Site | Seite hinzufügen | |
|
||||||
|
| Name | Name | table header |
|
||||||
|
| Online | Status | table header |
|
||||||
|
| Site | Seite | table header |
|
||||||
|
| Data In | Eingehende Daten | table header |
|
||||||
|
| Data Out | Ausgehende Daten | table header |
|
||||||
|
| Connection Type | Verbindungstyp | table header |
|
||||||
|
| Online | Online | site state |
|
||||||
|
| Offline | Offline | site state |
|
||||||
|
| Edit → | Bearbeiten → | |
|
||||||
|
| View settings | Einstellungen anzeigen | Popup after clicking “…” on site |
|
||||||
|
| Delete | Löschen | Popup after clicking “…” on site |
|
||||||
|
##### Add Site Popup
|
||||||
|
|
||||||
|
| EN | DE | Notes |
|
||||||
|
| ------------------------------------------------------ | ----------------------------------------------------------- | ----------- |
|
||||||
|
| Create Site | Seite erstellen | |
|
||||||
|
| Create a new site to start connection for this site | Erstellen Sie eine neue Seite, um die Verbindung zu starten | |
|
||||||
|
| Name | Name | |
|
||||||
|
| Site name | Seiten-Name | placeholder |
|
||||||
|
| This is the name that will be displayed for this site. | So wird Ihre Seite angezeigt | desc |
|
||||||
|
| Method | Methode | |
|
||||||
|
| Local | Lokal | |
|
||||||
|
| Newt | Newt | |
|
||||||
|
| WireGuard | WireGuard | |
|
||||||
|
| This is how you will expose connections. | So werden Verbindungen freigegeben. | |
|
||||||
|
| You will only be able to see the configuration once. | Diese Konfiguration können Sie nur einmal sehen. | |
|
||||||
|
| Learn how to install Newt on your system | Erfahren Sie, wie Sie Newt auf Ihrem System installieren | |
|
||||||
|
| I have copied the config | Ich habe die Konfiguration kopiert | |
|
||||||
|
| Create Site | Website erstellen | |
|
||||||
|
| Close | Schließen | |
|
||||||
|
|
||||||
|
## Main “Resources”
|
||||||
|
|
||||||
|
##### “Hero” section
|
||||||
|
|
||||||
|
| EN | DE | Notes |
|
||||||
|
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||||
|
| Resources | Ressourcen | |
|
||||||
|
| 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. | 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. | |
|
||||||
|
| Secure connectivity with WireGuard encryption | Sichere Verbindung mit WireGuard-Verschlüsselung | |
|
||||||
|
| Configure multiple authentication methods | Konfigurieren Sie mehrere Authentifizierungsmethoden | |
|
||||||
|
| User and role-based access control | Benutzer- und rollenbasierte Zugriffskontrolle | |
|
||||||
|
##### Content
|
||||||
|
|
||||||
|
| EN | DE | Notes |
|
||||||
|
| -------------------------------------------------- | ---------------------------------------------------------- | -------------------- |
|
||||||
|
| Manage Resources | Ressourcen verwalten | |
|
||||||
|
| Create secure proxies to your private applications | Erstellen Sie sichere Proxys für Ihre privaten Anwendungen | |
|
||||||
|
| Search resources | Ressourcen durchsuchen | placeholder |
|
||||||
|
| Name | Name | |
|
||||||
|
| Site | Website | |
|
||||||
|
| Full URL | Vollständige URL | |
|
||||||
|
| Authentication | Authentifizierung | |
|
||||||
|
| Not Protected | Nicht geschützt | authentication state |
|
||||||
|
| Protected | Geschützt | authentication state |
|
||||||
|
| Edit → | Bearbeiten → | |
|
||||||
|
| Add Resource | Ressource hinzufügen | |
|
||||||
|
##### Add Resource Popup
|
||||||
|
|
||||||
|
| EN | DE | Notes |
|
||||||
|
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------- |
|
||||||
|
| Create Resource | Ressource erstellen | |
|
||||||
|
| Create a new resource to proxy request to your app | Erstellen Sie eine neue Ressource, um Anfragen an Ihre App zu proxen | |
|
||||||
|
| Name | Name | |
|
||||||
|
| My Resource | Neue Ressource | name placeholder |
|
||||||
|
| This is the name that will be displayed for this resource. | Dies ist der Name, der für diese Ressource angezeigt wird | |
|
||||||
|
| Subdomain | Subdomain | |
|
||||||
|
| Enter subdomain | Subdomain eingeben | |
|
||||||
|
| This is the fully qualified domain name that will be used to access the resource. | Dies ist der vollständige Domainname, der für den Zugriff auf die Ressource verwendet wird. | |
|
||||||
|
| Site | Website | |
|
||||||
|
| Search site… | Website suchen… | Site selector popup |
|
||||||
|
| This is the site that will be used in the dashboard. | Dies ist die Website, die im Dashboard verwendet wird. | |
|
||||||
|
| Create Resource | Ressource erstellen | |
|
||||||
|
| Close | Schließen | |
|
||||||
|
|
||||||
|
|
||||||
|
## Main “User & Roles”
|
||||||
|
##### Content
|
||||||
|
|
||||||
|
| EN | DE | Notes |
|
||||||
|
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------- |
|
||||||
|
| Manage User & Roles | Benutzer & Rollen verwalten | |
|
||||||
|
| Invite users and add them to roles to manage access to your organization | Laden Sie Benutzer ein und weisen Sie ihnen Rollen zu, um den Zugriff auf Ihre Organisation zu verwalten | |
|
||||||
|
| Users | Benutzer | sidebar item |
|
||||||
|
| Roles | Rollen | sidebar item |
|
||||||
|
| **User tab** | | |
|
||||||
|
| Search users | Benutzer suchen | placeholder |
|
||||||
|
| Invite User | Benutzer einladen | addbutton |
|
||||||
|
| Email | E-Mail | table header |
|
||||||
|
| Status | Status | table header |
|
||||||
|
| Role | Rolle | table header |
|
||||||
|
| Confirmed | Bestätigt | account status |
|
||||||
|
| Not confirmed (?) | Nicht bestätigt (?) | unknown for me account status |
|
||||||
|
| Owner | Besitzer | role |
|
||||||
|
| Admin | Administrator | role |
|
||||||
|
| Member | Mitglied | role |
|
||||||
|
| **Roles Tab** | | |
|
||||||
|
| Search roles | Rollen suchen | placeholder |
|
||||||
|
| Add Role | Rolle hinzufügen | addbutton |
|
||||||
|
| Name | Name | table header |
|
||||||
|
| Description | Beschreibung | table header |
|
||||||
|
| Admin | Administrator | role |
|
||||||
|
| Member | Mitglied | role |
|
||||||
|
| Admin role with the most permissions | Administratorrolle mit den meisten Berechtigungen | admin role desc |
|
||||||
|
| Members can only view resources | Mitglieder können nur Ressourcen anzeigen | member role desc |
|
||||||
|
|
||||||
|
##### Invite User popup
|
||||||
|
|
||||||
|
| EN | DE | Notes |
|
||||||
|
| ----------------- | ------------------------------------------------------- | ----------- |
|
||||||
|
| Invite User | Geben Sie neuen Benutzern Zugriff auf Ihre Organisation | |
|
||||||
|
| Email | E-Mail | |
|
||||||
|
| Enter an email | E-Mail eingeben | placeholder |
|
||||||
|
| Role | Rolle | |
|
||||||
|
| Select role | Rolle auswählen | placeholder |
|
||||||
|
| Gültig für | Gültig bis | |
|
||||||
|
| 1 day | Tag | |
|
||||||
|
| 2 days | 2 Tage | |
|
||||||
|
| 3 days | 3 Tage | |
|
||||||
|
| 4 days | 4 Tage | |
|
||||||
|
| 5 days | 5 Tage | |
|
||||||
|
| 6 days | 6 Tage | |
|
||||||
|
| 7 days | 7 Tage | |
|
||||||
|
| Create Invitation | Einladung erstellen | |
|
||||||
|
| Close | Schließen | |
|
||||||
|
|
||||||
|
|
||||||
|
## Main “Shareable Links”
|
||||||
|
##### “Hero” section
|
||||||
|
|
||||||
|
| EN | DE | Notes |
|
||||||
|
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||||
|
| Shareable Links | Teilbare Links | |
|
||||||
|
| 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. | Erstellen Sie teilbare Links zu Ihren Ressourcen. Links bieten temporären oder unbegrenzten Zugriff auf Ihre Ressource. Sie können die Gültigkeitsdauer des Links beim Erstellen konfigurieren. | |
|
||||||
|
| Easy to create and share | Einfach zu erstellen und zu teilen | |
|
||||||
|
| Configurable expiration duration | Konfigurierbare Gültigkeitsdauer | |
|
||||||
|
| Secure and revocable | Sicher und widerrufbar | |
|
||||||
|
##### Content
|
||||||
|
|
||||||
|
| EN | DE | Notes |
|
||||||
|
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------- |
|
||||||
|
| Manage Shareable Links | Teilbare Links verwalten | |
|
||||||
|
| Create shareable links to grant temporary or permanent access to your resources | Erstellen Sie teilbare Links, um temporären oder permanenten Zugriff auf Ihre Ressourcen zu gewähren | |
|
||||||
|
| Search links | Links suchen | placeholder |
|
||||||
|
| Create Share Link | Neuen Link erstellen | addbutton |
|
||||||
|
| Resource | Ressource | table header |
|
||||||
|
| Title | Titel | table header |
|
||||||
|
| Created | Erstellt | table header |
|
||||||
|
| Expires | Gültig bis | table header |
|
||||||
|
| No links. Create one to get started. | Keine Links. Erstellen Sie einen, um zu beginnen. | table placeholder |
|
||||||
|
|
||||||
|
##### Create Shareable Link popup
|
||||||
|
|
||||||
|
| EN | DE | Notes |
|
||||||
|
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------- |
|
||||||
|
| Create Shareable Link | Teilbaren Link erstellen | |
|
||||||
|
| Anyone with this link can access the resource | Jeder mit diesem Link kann auf die Ressource zugreifen | |
|
||||||
|
| Resource | Ressource | |
|
||||||
|
| Select resource | Ressource auswählen | |
|
||||||
|
| Search resources… | Ressourcen suchen… | resource selector popup |
|
||||||
|
| Title (optional) | Titel (optional) | |
|
||||||
|
| Enter title | Titel eingeben | placeholder |
|
||||||
|
| Expire in | Gültig bis | |
|
||||||
|
| Minutes | Minuten | |
|
||||||
|
| Hours | Stunden | |
|
||||||
|
| Days | Tage | |
|
||||||
|
| Months | Monate | |
|
||||||
|
| Years | Jahre | |
|
||||||
|
| Never expire | Nie ablaufen | |
|
||||||
|
| 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. | Die Gültigkeitsdauer bestimmt, wie lange der Link nutzbar ist und Zugriff auf die Ressource bietet. Nach Ablauf dieser Zeit funktioniert der Link nicht mehr, und Benutzer, die diesen Link verwendet haben, verlieren den Zugriff auf die Ressource. | |
|
||||||
|
| Create Link | Link erstellen | |
|
||||||
|
| Close | Schließen | |
|
||||||
|
|
||||||
|
|
||||||
|
## Main “General”
|
||||||
|
|
||||||
|
| EN | DE | Notes |
|
||||||
|
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------ |
|
||||||
|
| General | Allgemein | |
|
||||||
|
| Configure your organization’s general settings | Konfigurieren Sie die allgemeinen Einstellungen Ihrer Organisation | |
|
||||||
|
| General | Allgemein | sidebar item |
|
||||||
|
| Organization Settings | Organisationseinstellungen | |
|
||||||
|
| Manage your organization details and configuration | Verwalten Sie die Details und Konfiguration Ihrer Organisation | |
|
||||||
|
| Name | Name | |
|
||||||
|
| This is the display name of the org | Dies ist der Anzeigename Ihrer Organisation | |
|
||||||
|
| Save Settings | Einstellungen speichern | |
|
||||||
|
| Danger Zone | Gefahrenzone | |
|
||||||
|
| Once you delete this org, there is no going back. Please be certain. | Wenn Sie diese Organisation löschen, gibt es kein Zurück. Bitte seien Sie sicher. | |
|
||||||
|
| Delete Organization Data | Organisationsdaten löschen | |
|
||||||
287
internationalization/pl.md
Normal file
287
internationalization/pl.md
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
## Authentication Site
|
||||||
|
|
||||||
|
|
||||||
|
| EN | PL | Notes |
|
||||||
|
| -------------------------------------------------------- | ------------------------------------------------------------ | ---------- |
|
||||||
|
| Powered by [Pangolin](https://github.com/fosrl/pangolin) | Zasilane przez [Pangolin](https://github.com/fosrl/pangolin) | |
|
||||||
|
| Authentication Required | Wymagane uwierzytelnienie | |
|
||||||
|
| Choose your preferred method to access {resource} | Wybierz preferowaną metodę dostępu do {resource} | |
|
||||||
|
| PIN | PIN | |
|
||||||
|
| User | Zaloguj | |
|
||||||
|
| 6-digit PIN Code | 6-cyfrowy kod PIN | pin login |
|
||||||
|
| Login in with PIN | Zaloguj się PIN’em | pin login |
|
||||||
|
| Email | Email | user login |
|
||||||
|
| Enter your email | Wprowadź swój email | user login |
|
||||||
|
| Password | Hasło | user login |
|
||||||
|
| Enter your password | Wprowadź swoje hasło | user login |
|
||||||
|
| Forgot your password? | Zapomniałeś hasła? | user login |
|
||||||
|
| Log in | Zaloguj | user login |
|
||||||
|
|
||||||
|
|
||||||
|
## Login site
|
||||||
|
|
||||||
|
| EN | PL | Notes |
|
||||||
|
| --------------------- | ------------------------------ | ----------- |
|
||||||
|
| Welcome to Pangolin | Witaj w Pangolin | |
|
||||||
|
| Log in to get started | Zaloguj się, aby rozpocząć<br> | |
|
||||||
|
| Email | Email | |
|
||||||
|
| Enter your email | Wprowadź swój adres e-mail<br> | placeholder |
|
||||||
|
| Password | Hasło | |
|
||||||
|
| Enter your password | Wprowadź swoje hasło | placeholder |
|
||||||
|
| Forgot your password? | Nie pamiętasz hasła? | |
|
||||||
|
| Log in | Zaloguj | |
|
||||||
|
|
||||||
|
# Ogranization site after successful login
|
||||||
|
|
||||||
|
|
||||||
|
| EN | PL | Notes |
|
||||||
|
| ----------------------------------------- | ------------------------------------------ | ----- |
|
||||||
|
| Welcome to Pangolin | Witaj w Pangolin | |
|
||||||
|
| You're a member of {number} organization. | Jesteś użytkownikiem {number} organizacji. | |
|
||||||
|
|
||||||
|
## Shared Header, Navbar and Footer
|
||||||
|
##### Header
|
||||||
|
|
||||||
|
| EN | PL | Notes |
|
||||||
|
| ------------------- | ------------------ | ----- |
|
||||||
|
| Documentation | Dokumentacja | |
|
||||||
|
| Support | Wsparcie | |
|
||||||
|
| Organization {name} | Organizacja {name} | |
|
||||||
|
##### Organization selector
|
||||||
|
|
||||||
|
| EN | PL | Notes |
|
||||||
|
| ---------------- | ---------------- | ----- |
|
||||||
|
| Search… | Szukaj… | |
|
||||||
|
| Create | Utwórz | |
|
||||||
|
| New Organization | Nowa organizacja | |
|
||||||
|
| Organizations | Organizacje | |
|
||||||
|
|
||||||
|
##### Navbar
|
||||||
|
|
||||||
|
| EN | PL | Notes |
|
||||||
|
| --------------- | ---------------------- | ----- |
|
||||||
|
| Sites | Witryny | |
|
||||||
|
| Resources | Zasoby | |
|
||||||
|
| User & Roles | Użytkownicy i Role | |
|
||||||
|
| Shareable Links | Łącza do udostępniania | |
|
||||||
|
| General | Ogólne | |
|
||||||
|
##### Footer
|
||||||
|
| EN | PL | |
|
||||||
|
| ------------------------- | -------------------------- | -------------- |
|
||||||
|
| Page {number} of {number} | Strona {number} z {number} | |
|
||||||
|
| Rows per page | Wierszy na stronę | |
|
||||||
|
| Pangolin | Pangolin | bottom of site |
|
||||||
|
| Built by Fossorial | Stworzone przez Fossorial | bottom of site |
|
||||||
|
| Open Source | Open source | bottom of site |
|
||||||
|
| Documentation | Dokumentacja | bottom of site |
|
||||||
|
| {version} | {version} | bottom of site |
|
||||||
|
## Main “Sites”
|
||||||
|
##### “Hero” section
|
||||||
|
|
||||||
|
| EN | PL | Notes |
|
||||||
|
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- |
|
||||||
|
| Newt (Recommended) | Newt (zalecane) | |
|
||||||
|
| 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. | Aby zapewnić najlepsze doświadczenie użytkownika, korzystaj z Newt. Wykorzystuje on technologię WireGuard w tle i pozwala na dostęp do Twoich prywatnych zasobów za pomocą ich adresu LAN w prywatnej sieci bezpośrednio z poziomu pulpitu nawigacyjnego Pangolin. | |
|
||||||
|
| Runs in Docker | Działa w Dockerze | |
|
||||||
|
| Runs in shell on macOS, Linux, and Windows | Działa w powłoce na systemach macOS, Linux i Windows | |
|
||||||
|
| Install Newt | Zainstaluj Newt | |
|
||||||
|
| Podstawowy WireGuard<br> | Użyj dowolnego klienta WireGuard, aby się połączyć. Będziesz musiał uzyskiwać dostęp do swoich wewnętrznych zasobów za pomocą adresu IP równorzędnego | |
|
||||||
|
| Compatible with all WireGuard clients<br> | Kompatybilny ze wszystkimi klientami WireGuard<br> | |
|
||||||
|
| Manual configuration required | Wymagana ręczna konfiguracja<br> | |
|
||||||
|
##### Content
|
||||||
|
|
||||||
|
| EN | PL | Notes |
|
||||||
|
| --------------------------------------------------------- | ------------------------------------------------------------------------ | -------------------------------- |
|
||||||
|
| Manage Sites | Zarządzanie witrynami | |
|
||||||
|
| Allow connectivity to your network through secure tunnels | Zezwalaj na łączność z Twoją siecią za pośrednictwem bezpiecznych tuneli | |
|
||||||
|
| Search sites | Szukaj witryny | placeholder |
|
||||||
|
| Add Site | Dodaj witrynę | |
|
||||||
|
| Name | Nazwa | table header |
|
||||||
|
| Online | Status | table header |
|
||||||
|
| Site | Witryna | table header |
|
||||||
|
| Data In | Dane wchodzące | table header |
|
||||||
|
| Data Out | Dane wychodzące | table header |
|
||||||
|
| Connection Type | Typ połączenia | table header |
|
||||||
|
| Online | Online | site state |
|
||||||
|
| Offline | Poza siecią | site state |
|
||||||
|
| Edit → | Edytuj → | |
|
||||||
|
| View settings | Pokaż ustawienia | Popup after clicking “…” on site |
|
||||||
|
| Delete | Usuń | Popup after clicking “…” on site |
|
||||||
|
##### Add Site Popup
|
||||||
|
|
||||||
|
| EN | PL | Notes |
|
||||||
|
| ------------------------------------------------------ | --------------------------------------------------- | ----------- |
|
||||||
|
| Create Site | Utwórz witrynę | |
|
||||||
|
| Create a new site to start connection for this site | Utwórz nową witrynę aby rozpocząć połączenie | |
|
||||||
|
| Name | Nazwa | |
|
||||||
|
| Site name | Nazwa witryny | placeholder |
|
||||||
|
| This is the name that will be displayed for this site. | Tak będzie wyświetlana twoja witryna | desc |
|
||||||
|
| Method | Metoda | |
|
||||||
|
| Local | Lokalna | |
|
||||||
|
| Newt | Newt | |
|
||||||
|
| WireGuard | WireGuard | |
|
||||||
|
| This is how you will expose connections. | Tak będą eksponowane połączenie. | |
|
||||||
|
| You will only be able to see the configuration once. | Tą konfigurację możesz zobaczyć tylko raz. | |
|
||||||
|
| Learn how to install Newt on your system | Dowiedz się jak zainstalować Newt na twoim systemie | |
|
||||||
|
| I have copied the config | Skopiowałem konfigurację | |
|
||||||
|
| Create Site | Utwórz witrynę | |
|
||||||
|
| Close | Zamknij | |
|
||||||
|
|
||||||
|
## Main “Resources”
|
||||||
|
|
||||||
|
##### “Hero” section
|
||||||
|
|
||||||
|
| EN | PL | Notes |
|
||||||
|
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- |
|
||||||
|
| Resources | Zasoby | |
|
||||||
|
| Zasoby to serwery proxy dla aplikacji działających w Twojej prywatnej sieci. Utwórz zasób dla dowolnej aplikacji HTTP lub HTTPS w swojej prywatnej sieci. Każdy zasób musi być połączony z witryną, aby umożliwić prywatne i bezpieczne połączenie przez szyfrowany tunel WireGuard. | Zasoby to serwery proxy dla aplikacji działających w Twojej prywatnej sieci. Utwórz zasób dla dowolnej aplikacji HTTP lub HTTPS w swojej prywatnej sieci. Każdy zasób musi być połączony z witryną, aby umożliwić prywatne i bezpieczne połączenie przez szyfrowany tunel WireGuard. | |
|
||||||
|
| Secure connectivity with WireGuard encryption | Bezpieczna łączność z szyfrowaniem WireGuard | |
|
||||||
|
| Configure multiple authentication methods | Konfigurowanie wielu metod uwierzytelniania | |
|
||||||
|
| User and role-based access control | Kontrola dostępu oparta na użytkownikach i rolach | |
|
||||||
|
##### Content
|
||||||
|
|
||||||
|
| EN | PL | Notes |
|
||||||
|
| -------------------------------------------------- | -------------------------------------------------------------- | -------------------- |
|
||||||
|
| Manage Resources | Zarządzaj zasobami | |
|
||||||
|
| Create secure proxies to your private applications | Twórz bezpieczne serwery proxy dla swoich prywatnych aplikacji | |
|
||||||
|
| Search resources | Szukaj w zasobach | placeholder |
|
||||||
|
| Name | Nazwa | |
|
||||||
|
| Site | Witryna | |
|
||||||
|
| Full URL | Pełny URL | |
|
||||||
|
| Authentication | Uwierzytelnianie | |
|
||||||
|
| Not Protected | Niezabezpieczony | authentication state |
|
||||||
|
| Protected | Zabezpieczony | authentication state |
|
||||||
|
| Edit → | Edytuj → | |
|
||||||
|
| Add Resource | Dodaj zasób | |
|
||||||
|
##### Add Resource Popup
|
||||||
|
|
||||||
|
| EN | PL | Notes |
|
||||||
|
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------------- |
|
||||||
|
| Create Resource | Utwórz zasób | |
|
||||||
|
| Create a new resource to proxy request to your app | Utwórz nowy zasób, aby przekazywać żądania do swojej aplikacji | |
|
||||||
|
| Name | Nazwa | |
|
||||||
|
| My Resource | Nowy zasób | name placeholder |
|
||||||
|
| This is the name that will be displayed for this resource. | To jest nazwa, która będzie wyświetlana dla tego zasobu | |
|
||||||
|
| Subdomain | Subdomena | |
|
||||||
|
| Enter subdomain | Wprowadź subdomenę | |
|
||||||
|
| This is the fully qualified domain name that will be used to access the resource. | To jest pełna nazwa domeny, która będzie używana do dostępu do zasobu. | |
|
||||||
|
| Site | Witryna | |
|
||||||
|
| Search site… | Szukaj witryny… | Site selector popup |
|
||||||
|
| This is the site that will be used in the dashboard. | To jest witryna, która będzie używana w pulpicie nawigacyjnym. | |
|
||||||
|
| Create Resource | Utwórz zasób | |
|
||||||
|
| Close | Zamknij | |
|
||||||
|
|
||||||
|
|
||||||
|
## Main “User & Roles”
|
||||||
|
##### Content
|
||||||
|
|
||||||
|
| EN | PL | Notes |
|
||||||
|
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ----------------------------- |
|
||||||
|
| Manage User & Roles | Zarządzanie użytkownikami i rolami | |
|
||||||
|
| Invite users and add them to roles to manage access to your organization | Zaproś użytkowników i przypisz im role, aby zarządzać dostępem do Twojej organizacji | |
|
||||||
|
| Users | Użytkownicy | sidebar item |
|
||||||
|
| Roles | Role | sidebar item |
|
||||||
|
| **User tab** | | |
|
||||||
|
| Search users | Wyszukaj użytkownika | placeholder |
|
||||||
|
| Invite User | Zaproś użytkownika | addbutton |
|
||||||
|
| Email | Email | table header |
|
||||||
|
| Status | Status | table header |
|
||||||
|
| Role | Rola | table header |
|
||||||
|
| Confirmed | Zatwierdzony | account status |
|
||||||
|
| Not confirmed (?) | Niezatwierdzony (?) | unknown for me account status |
|
||||||
|
| Owner | Właściciel | role |
|
||||||
|
| Admin | Administrator | role |
|
||||||
|
| Member | Użytkownik | role |
|
||||||
|
| **Roles Tab** | | |
|
||||||
|
| Search roles | Wyszukaj role | placeholder |
|
||||||
|
| Add Role | Dodaj role | addbutton |
|
||||||
|
| Name | Nazwa | table header |
|
||||||
|
| Description | Opis | table header |
|
||||||
|
| Admin | Administrator | role |
|
||||||
|
| Member | Użytkownik | role |
|
||||||
|
| Admin role with the most permissions | Rola administratora z najszerszymi uprawnieniami | admin role desc |
|
||||||
|
| Members can only view resources | Członkowie mogą jedynie przeglądać zasoby | member role desc |
|
||||||
|
|
||||||
|
##### Invite User popup
|
||||||
|
|
||||||
|
| EN | PL | Notes |
|
||||||
|
| ----------------- | ------------------------------------------ | ----------- |
|
||||||
|
| Invite User | Give new users access to your organization | |
|
||||||
|
| Email | Email | |
|
||||||
|
| Enter an email | Wprowadź email | placeholder |
|
||||||
|
| Role | Rola | |
|
||||||
|
| Select role | Wybierz role | placeholder |
|
||||||
|
| Vaild for | Ważne do | |
|
||||||
|
| 1 day | Dzień | |
|
||||||
|
| 2 days | 2 dni | |
|
||||||
|
| 3 days | 3 dni | |
|
||||||
|
| 4 days | 4 dni | |
|
||||||
|
| 5 days | 5 dni | |
|
||||||
|
| 6 days | 6 dni | |
|
||||||
|
| 7 days | 7 dni | |
|
||||||
|
| Create Invitation | Utwórz zaproszenie | |
|
||||||
|
| Close | Zamknij | |
|
||||||
|
|
||||||
|
|
||||||
|
## Main “Shareable Links”
|
||||||
|
##### “Hero” section
|
||||||
|
|
||||||
|
| EN | PL | Notes |
|
||||||
|
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
|
||||||
|
| Shareable Links | Łącza do udostępniania | |
|
||||||
|
| 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. | Twórz linki do udostępniania swoich zasobów. Linki zapewniają tymczasowy lub nieograniczony dostęp do zasobu. Możesz skonfigurować czas wygaśnięcia linku podczas jego tworzenia. | |
|
||||||
|
| Easy to create and share | Łatwe tworzenie i udostępnianie | |
|
||||||
|
| Configurable expiration duration | Konfigurowalny czas wygaśnięcia | |
|
||||||
|
| Secure and revocable | Bezpieczne i odwołalne | |
|
||||||
|
##### Content
|
||||||
|
|
||||||
|
| EN | PL | Notes |
|
||||||
|
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------- |
|
||||||
|
| Manage Shareable Links | Zarządzaj łączami do udostępniania | |
|
||||||
|
| Create shareable links to grant temporary or permament access to your resources | Utwórz łącze do udostępniania w celu przyznania tymczasowego lub stałego dostępu do zasobów | |
|
||||||
|
| Search links | Szukaj łączy | placeholder |
|
||||||
|
| Create Share Link | Utwórz nowe łącze | addbutton |
|
||||||
|
| Resource | Zasób | table header |
|
||||||
|
| Title | Tytuł | table header |
|
||||||
|
| Created | Utworzone | table header |
|
||||||
|
| Expires | Wygasa | table header |
|
||||||
|
| No links. Create one to get started. | Brak łączy. Utwórz, aby rozpocząć. | table placeholder |
|
||||||
|
|
||||||
|
##### Create Shareable Link popup
|
||||||
|
|
||||||
|
| EN | PL | Notes |
|
||||||
|
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
|
||||||
|
| Create Shareable Link | Utwórz łącze do udostępnienia | |
|
||||||
|
| Anyone with this link can access the resource | Każdy kto ma ten link może korzystać z zasobu | |
|
||||||
|
| Resource | Zasób | |
|
||||||
|
| Select resource | Wybierz zasób | |
|
||||||
|
| Search resources… | Szukaj zasobów… | resource selector popup |
|
||||||
|
| Title (optional) | Tytuł (opcjonalny) | |
|
||||||
|
| Enter title | Wprowadź tytuł | placeholder |
|
||||||
|
| Expire in | Wygasa za | |
|
||||||
|
| Minutes | Minut | |
|
||||||
|
| Hours | Godzin | |
|
||||||
|
| Days | Dni | |
|
||||||
|
| Months | Miesięcy | |
|
||||||
|
| Years | Lat | |
|
||||||
|
| Never expire | Nie wygasa | |
|
||||||
|
| 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. | Czas wygaśnięcia to okres, przez który link będzie aktywny i zapewni dostęp do zasobu. Po upływie tego czasu link przestanie działać, a użytkownicy, którzy go użyli, stracą dostęp do zasobu. | |
|
||||||
|
| Create Link | Utwórz łącze | |
|
||||||
|
| Close | Zamknij | |
|
||||||
|
|
||||||
|
|
||||||
|
## Main “General”
|
||||||
|
|
||||||
|
| EN | PL | Notes |
|
||||||
|
| -------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------ |
|
||||||
|
| General | Ogólne | |
|
||||||
|
| Configure your organization’s general settings | Zarządzaj ogólnymi ustawieniami twoich organizacji | |
|
||||||
|
| General | Ogólne | sidebar item |
|
||||||
|
| Organization Settings | Ustawienia organizacji | |
|
||||||
|
| Manage your organization details and configuration | Zarządzaj szczegółami i konfiguracją organizacji | |
|
||||||
|
| Name | Nazwa | |
|
||||||
|
| This is the display name of the org | To jest wyświetlana nazwa Twojej organizacji | |
|
||||||
|
| Save Settings | Zapisz ustawienia | |
|
||||||
|
| Danger Zone | Niebezpieczna strefa | |
|
||||||
|
| Once you delete this org, there is no going back. Please be certain. | Jeśli usuniesz swoją tą organizację, nie ma odwrotu. Bądź ostrożny! | |
|
||||||
|
| Delete Organization Data | Usuń dane organizacji | |
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@fosrl/pangolin",
|
"name": "@fosrl/pangolin",
|
||||||
"version": "1.0.0-beta.4",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
|
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
"@oslojs/encoding": "1.1.0",
|
"@oslojs/encoding": "1.1.0",
|
||||||
"@radix-ui/react-avatar": "1.1.2",
|
"@radix-ui/react-avatar": "1.1.2",
|
||||||
"@radix-ui/react-checkbox": "1.1.3",
|
"@radix-ui/react-checkbox": "1.1.3",
|
||||||
|
"@radix-ui/react-collapsible": "1.1.2",
|
||||||
"@radix-ui/react-dialog": "1.1.4",
|
"@radix-ui/react-dialog": "1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "2.1.4",
|
"@radix-ui/react-dropdown-menu": "2.1.4",
|
||||||
"@radix-ui/react-icons": "1.3.2",
|
"@radix-ui/react-icons": "1.3.2",
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"next": "15.1.3",
|
"next": "15.1.3",
|
||||||
"next-themes": "0.4.4",
|
"next-themes": "0.4.4",
|
||||||
|
"node-cache": "5.1.2",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"nodemailer": "6.9.16",
|
"nodemailer": "6.9.16",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
|
|||||||
BIN
public/logo/word_mark.png
Normal file
BIN
public/logo/word_mark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -20,23 +20,32 @@ const externalPort = config.getRawConfig().server.external_port;
|
|||||||
export function createApiServer() {
|
export function createApiServer() {
|
||||||
const apiServer = express();
|
const apiServer = express();
|
||||||
|
|
||||||
// Middleware setup
|
if (config.getRawConfig().server.trust_proxy) {
|
||||||
apiServer.set("trust proxy", 1);
|
apiServer.set("trust proxy", 1);
|
||||||
if (dev) {
|
}
|
||||||
apiServer.use(
|
|
||||||
cors({
|
|
||||||
origin: `http://localhost:${config.getRawConfig().server.next_port}`,
|
|
||||||
credentials: true
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const corsOptions = {
|
|
||||||
origin: config.getRawConfig().app.dashboard_url,
|
|
||||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
||||||
allowedHeaders: ["Content-Type", "X-CSRF-Token"]
|
|
||||||
};
|
|
||||||
|
|
||||||
apiServer.use(cors(corsOptions));
|
const corsConfig = config.getRawConfig().server.cors;
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
...(corsConfig?.origins
|
||||||
|
? { origin: corsConfig.origins }
|
||||||
|
: {
|
||||||
|
origin: (origin: any, callback: any) => {
|
||||||
|
callback(null, true);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
...(corsConfig?.methods && { methods: corsConfig.methods }),
|
||||||
|
...(corsConfig?.allowed_headers && {
|
||||||
|
allowedHeaders: corsConfig.allowed_headers
|
||||||
|
}),
|
||||||
|
credentials: !(corsConfig?.credentials === false)
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug("Using CORS options", options);
|
||||||
|
|
||||||
|
apiServer.use(cors(options));
|
||||||
|
|
||||||
|
if (!dev) {
|
||||||
apiServer.use(helmet());
|
apiServer.use(helmet());
|
||||||
apiServer.use(csrfProtectionMiddleware);
|
apiServer.use(csrfProtectionMiddleware);
|
||||||
}
|
}
|
||||||
@@ -47,7 +56,8 @@ export function createApiServer() {
|
|||||||
if (!dev) {
|
if (!dev) {
|
||||||
apiServer.use(
|
apiServer.use(
|
||||||
rateLimitMiddleware({
|
rateLimitMiddleware({
|
||||||
windowMin: config.getRawConfig().rate_limits.global.window_minutes,
|
windowMin:
|
||||||
|
config.getRawConfig().rate_limits.global.window_minutes,
|
||||||
max: config.getRawConfig().rate_limits.global.max_requests,
|
max: config.getRawConfig().rate_limits.global.max_requests,
|
||||||
type: "IP_AND_PATH"
|
type: "IP_AND_PATH"
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -51,13 +51,17 @@ export enum ActionsEnum {
|
|||||||
// removeUserAction = "removeUserAction",
|
// removeUserAction = "removeUserAction",
|
||||||
// removeUserSite = "removeUserSite",
|
// removeUserSite = "removeUserSite",
|
||||||
getOrgUser = "getOrgUser",
|
getOrgUser = "getOrgUser",
|
||||||
"setResourcePassword" = "setResourcePassword",
|
setResourcePassword = "setResourcePassword",
|
||||||
"setResourcePincode" = "setResourcePincode",
|
setResourcePincode = "setResourcePincode",
|
||||||
"setResourceWhitelist" = "setResourceWhitelist",
|
setResourceWhitelist = "setResourceWhitelist",
|
||||||
"getResourceWhitelist" = "getResourceWhitelist",
|
getResourceWhitelist = "getResourceWhitelist",
|
||||||
"generateAccessToken" = "generateAccessToken",
|
generateAccessToken = "generateAccessToken",
|
||||||
"deleteAcessToken" = "deleteAcessToken",
|
deleteAcessToken = "deleteAcessToken",
|
||||||
"listAccessTokens" = "listAccessTokens"
|
listAccessTokens = "listAccessTokens",
|
||||||
|
createResourceRule = "createResourceRule",
|
||||||
|
deleteResourceRule = "deleteResourceRule",
|
||||||
|
listResourceRules = "listResourceRules",
|
||||||
|
updateResourceRule = "updateResourceRule",
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|||||||
45
server/auth/canUserAccessResource.ts
Normal file
45
server/auth/canUserAccessResource.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import db from "@server/db";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { roleResources, userResources } from "@server/db/schema";
|
||||||
|
|
||||||
|
export async function canUserAccessResource({
|
||||||
|
userId,
|
||||||
|
resourceId,
|
||||||
|
roleId
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
resourceId: number;
|
||||||
|
roleId: number;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const roleResourceAccess = await db
|
||||||
|
.select()
|
||||||
|
.from(roleResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleResources.resourceId, resourceId),
|
||||||
|
eq(roleResources.roleId, roleId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (roleResourceAccess.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userResourceAccess = await db
|
||||||
|
.select()
|
||||||
|
.from(userResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userResources.userId, userId),
|
||||||
|
eq(userResources.resourceId, resourceId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (userResourceAccess.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import {
|
|
||||||
encodeBase32LowerCaseNoPadding,
|
|
||||||
encodeHexLowerCase,
|
|
||||||
} from "@oslojs/encoding";
|
|
||||||
import { sha256 } from "@oslojs/crypto/sha2";
|
|
||||||
import { Session, sessions, User, users } from "@server/db/schema";
|
|
||||||
import db from "@server/db";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import config from "@server/lib/config";
|
|
||||||
import type { RandomReader } from "@oslojs/crypto/random";
|
|
||||||
import { generateRandomString } from "@oslojs/crypto/random";
|
|
||||||
|
|
||||||
export const SESSION_COOKIE_NAME = config.getRawConfig().server.session_cookie_name;
|
|
||||||
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
|
|
||||||
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
|
|
||||||
export const COOKIE_DOMAIN = "." + config.getBaseDomain();
|
|
||||||
|
|
||||||
export function generateSessionToken(): string {
|
|
||||||
const bytes = new Uint8Array(20);
|
|
||||||
crypto.getRandomValues(bytes);
|
|
||||||
const token = encodeBase32LowerCaseNoPadding(bytes);
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createSession(
|
|
||||||
token: string,
|
|
||||||
userId: string,
|
|
||||||
): Promise<Session> {
|
|
||||||
const sessionId = encodeHexLowerCase(
|
|
||||||
sha256(new TextEncoder().encode(token)),
|
|
||||||
);
|
|
||||||
const session: Session = {
|
|
||||||
sessionId: sessionId,
|
|
||||||
userId,
|
|
||||||
expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(),
|
|
||||||
};
|
|
||||||
await db.insert(sessions).values(session);
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function validateSessionToken(
|
|
||||||
token: string,
|
|
||||||
): Promise<SessionValidationResult> {
|
|
||||||
const sessionId = encodeHexLowerCase(
|
|
||||||
sha256(new TextEncoder().encode(token)),
|
|
||||||
);
|
|
||||||
const result = await db
|
|
||||||
.select({ user: users, session: sessions })
|
|
||||||
.from(sessions)
|
|
||||||
.innerJoin(users, eq(sessions.userId, users.userId))
|
|
||||||
.where(eq(sessions.sessionId, sessionId));
|
|
||||||
if (result.length < 1) {
|
|
||||||
return { session: null, user: null };
|
|
||||||
}
|
|
||||||
const { user, session } = result[0];
|
|
||||||
if (Date.now() >= session.expiresAt) {
|
|
||||||
await db
|
|
||||||
.delete(sessions)
|
|
||||||
.where(eq(sessions.sessionId, session.sessionId));
|
|
||||||
return { session: null, user: null };
|
|
||||||
}
|
|
||||||
if (Date.now() >= session.expiresAt - SESSION_COOKIE_EXPIRES / 2) {
|
|
||||||
session.expiresAt = new Date(
|
|
||||||
Date.now() + SESSION_COOKIE_EXPIRES,
|
|
||||||
).getTime();
|
|
||||||
await db
|
|
||||||
.update(sessions)
|
|
||||||
.set({
|
|
||||||
expiresAt: session.expiresAt,
|
|
||||||
})
|
|
||||||
.where(eq(sessions.sessionId, session.sessionId));
|
|
||||||
}
|
|
||||||
return { session, user };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function invalidateSession(sessionId: string): Promise<void> {
|
|
||||||
await db.delete(sessions).where(eq(sessions.sessionId, sessionId));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function invalidateAllSessions(userId: string): Promise<void> {
|
|
||||||
await db.delete(sessions).where(eq(sessions.userId, userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeSessionCookie(token: string): string {
|
|
||||||
if (SECURE_COOKIES) {
|
|
||||||
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
|
||||||
} else {
|
|
||||||
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBlankSessionTokenCookie(): string {
|
|
||||||
if (SECURE_COOKIES) {
|
|
||||||
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
|
||||||
} else {
|
|
||||||
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const random: RandomReader = {
|
|
||||||
read(bytes: Uint8Array): void {
|
|
||||||
crypto.getRandomValues(bytes);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function generateId(length: number): string {
|
|
||||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
||||||
return generateRandomString(random, alphabet, length);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateIdFromEntropySize(size: number): string {
|
|
||||||
const buffer = crypto.getRandomValues(new Uint8Array(size));
|
|
||||||
return encodeBase32LowerCaseNoPadding(buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionValidationResult =
|
|
||||||
| { session: Session; user: User }
|
|
||||||
| { session: null; user: null };
|
|
||||||
@@ -3,8 +3,8 @@ import z from "zod";
|
|||||||
export const passwordSchema = z
|
export const passwordSchema = z
|
||||||
.string()
|
.string()
|
||||||
.min(8, { message: "Password must be at least 8 characters long" })
|
.min(8, { message: "Password must be at least 8 characters long" })
|
||||||
.max(64, { message: "Password must be at most 64 characters long" })
|
.max(128, { message: "Password must be at most 128 characters long" })
|
||||||
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$/, {
|
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[~!`@#$%^&*()_\-+={}[\]|\\:;"'<>,.\/?]).*$/, {
|
||||||
message: `Your password must meet the following conditions:
|
message: `Your password must meet the following conditions:
|
||||||
at least one uppercase English letter,
|
at least one uppercase English letter,
|
||||||
at least one lowercase English letter,
|
at least one lowercase English letter,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function sendResourceOtpEmail(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
to: email,
|
to: email,
|
||||||
from: config.getRawConfig().email?.no_reply,
|
from: config.getNoReplyEmail(),
|
||||||
subject: `Your one-time code to access ${resourceName}`
|
subject: `Your one-time code to access ${resourceName}`
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export async function sendEmailVerificationCode(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
to: email,
|
to: email,
|
||||||
from: config.getRawConfig().email?.no_reply,
|
from: config.getNoReplyEmail(),
|
||||||
subject: "Verify your email address"
|
subject: "Verify your email address"
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,31 @@
|
|||||||
import {
|
import {
|
||||||
encodeBase32LowerCaseNoPadding,
|
encodeBase32LowerCaseNoPadding,
|
||||||
encodeHexLowerCase,
|
encodeHexLowerCase
|
||||||
} from "@oslojs/encoding";
|
} from "@oslojs/encoding";
|
||||||
import { sha256 } from "@oslojs/crypto/sha2";
|
import { sha256 } from "@oslojs/crypto/sha2";
|
||||||
import { Session, sessions, User, users } from "@server/db/schema";
|
import {
|
||||||
|
resourceSessions,
|
||||||
|
Session,
|
||||||
|
sessions,
|
||||||
|
User,
|
||||||
|
users
|
||||||
|
} from "@server/db/schema";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import type { RandomReader } from "@oslojs/crypto/random";
|
import type { RandomReader } from "@oslojs/crypto/random";
|
||||||
import { generateRandomString } from "@oslojs/crypto/random";
|
import { generateRandomString } from "@oslojs/crypto/random";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export const SESSION_COOKIE_NAME = config.getRawConfig().server.session_cookie_name;
|
export const SESSION_COOKIE_NAME =
|
||||||
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
|
config.getRawConfig().server.session_cookie_name;
|
||||||
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
|
export const SESSION_COOKIE_EXPIRES =
|
||||||
export const COOKIE_DOMAIN = "." + config.getBaseDomain();
|
1000 *
|
||||||
|
60 *
|
||||||
|
60 *
|
||||||
|
config.getRawConfig().server.dashboard_session_length_hours;
|
||||||
|
export const COOKIE_DOMAIN =
|
||||||
|
"." + new URL(config.getRawConfig().app.dashboard_url).hostname;
|
||||||
|
|
||||||
export function generateSessionToken(): string {
|
export function generateSessionToken(): string {
|
||||||
const bytes = new Uint8Array(20);
|
const bytes = new Uint8Array(20);
|
||||||
@@ -24,25 +36,25 @@ export function generateSessionToken(): string {
|
|||||||
|
|
||||||
export async function createSession(
|
export async function createSession(
|
||||||
token: string,
|
token: string,
|
||||||
userId: string,
|
userId: string
|
||||||
): Promise<Session> {
|
): Promise<Session> {
|
||||||
const sessionId = encodeHexLowerCase(
|
const sessionId = encodeHexLowerCase(
|
||||||
sha256(new TextEncoder().encode(token)),
|
sha256(new TextEncoder().encode(token))
|
||||||
);
|
);
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
userId,
|
userId,
|
||||||
expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(),
|
expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime()
|
||||||
};
|
};
|
||||||
await db.insert(sessions).values(session);
|
await db.insert(sessions).values(session);
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateSessionToken(
|
export async function validateSessionToken(
|
||||||
token: string,
|
token: string
|
||||||
): Promise<SessionValidationResult> {
|
): Promise<SessionValidationResult> {
|
||||||
const sessionId = encodeHexLowerCase(
|
const sessionId = encodeHexLowerCase(
|
||||||
sha256(new TextEncoder().encode(token)),
|
sha256(new TextEncoder().encode(token))
|
||||||
);
|
);
|
||||||
const result = await db
|
const result = await db
|
||||||
.select({ user: users, session: sessions })
|
.select({ user: users, session: sessions })
|
||||||
@@ -61,19 +73,29 @@ export async function validateSessionToken(
|
|||||||
}
|
}
|
||||||
if (Date.now() >= session.expiresAt - SESSION_COOKIE_EXPIRES / 2) {
|
if (Date.now() >= session.expiresAt - SESSION_COOKIE_EXPIRES / 2) {
|
||||||
session.expiresAt = new Date(
|
session.expiresAt = new Date(
|
||||||
Date.now() + SESSION_COOKIE_EXPIRES,
|
Date.now() + SESSION_COOKIE_EXPIRES
|
||||||
).getTime();
|
).getTime();
|
||||||
await db
|
await db.transaction(async (trx) => {
|
||||||
.update(sessions)
|
await trx
|
||||||
.set({
|
.update(sessions)
|
||||||
expiresAt: session.expiresAt,
|
.set({
|
||||||
})
|
expiresAt: session.expiresAt
|
||||||
.where(eq(sessions.sessionId, session.sessionId));
|
})
|
||||||
|
.where(eq(sessions.sessionId, session.sessionId));
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.update(resourceSessions)
|
||||||
|
.set({
|
||||||
|
expiresAt: session.expiresAt
|
||||||
|
})
|
||||||
|
.where(eq(resourceSessions.userSessionId, session.sessionId));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return { session, user };
|
return { session, user };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function invalidateSession(sessionId: string): Promise<void> {
|
export async function invalidateSession(sessionId: string): Promise<void> {
|
||||||
|
await db.delete(resourceSessions).where(eq(resourceSessions.userSessionId, sessionId));
|
||||||
await db.delete(sessions).where(eq(sessions.sessionId, sessionId));
|
await db.delete(sessions).where(eq(sessions.sessionId, sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,26 +103,29 @@ export async function invalidateAllSessions(userId: string): Promise<void> {
|
|||||||
await db.delete(sessions).where(eq(sessions.userId, userId));
|
await db.delete(sessions).where(eq(sessions.userId, userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeSessionCookie(token: string): string {
|
export function serializeSessionCookie(
|
||||||
if (SECURE_COOKIES) {
|
token: string,
|
||||||
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
isSecure: boolean
|
||||||
|
): string {
|
||||||
|
if (isSecure) {
|
||||||
|
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
||||||
} else {
|
} else {
|
||||||
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
|
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBlankSessionTokenCookie(): string {
|
export function createBlankSessionTokenCookie(isSecure: boolean): string {
|
||||||
if (SECURE_COOKIES) {
|
if (isSecure) {
|
||||||
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
||||||
} else {
|
} else {
|
||||||
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
|
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/;`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const random: RandomReader = {
|
const random: RandomReader = {
|
||||||
read(bytes: Uint8Array): void {
|
read(bytes: Uint8Array): void {
|
||||||
crypto.getRandomValues(bytes);
|
crypto.getRandomValues(bytes);
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export function generateId(length: number): string {
|
export function generateId(length: number): string {
|
||||||
|
|||||||
@@ -6,19 +6,19 @@ import { eq, and } from "drizzle-orm";
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export const SESSION_COOKIE_NAME =
|
export const SESSION_COOKIE_NAME =
|
||||||
config.getRawConfig().server.resource_session_cookie_name;
|
config.getRawConfig().server.session_cookie_name;
|
||||||
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
|
export const SESSION_COOKIE_EXPIRES =
|
||||||
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
|
1000 * 60 * 60 * config.getRawConfig().server.resource_session_length_hours;
|
||||||
export const COOKIE_DOMAIN = "." + config.getBaseDomain();
|
|
||||||
|
|
||||||
export async function createResourceSession(opts: {
|
export async function createResourceSession(opts: {
|
||||||
token: string;
|
token: string;
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
passwordId?: number;
|
isRequestToken?: boolean;
|
||||||
pincodeId?: number;
|
passwordId?: number | null;
|
||||||
whitelistId?: number;
|
pincodeId?: number | null;
|
||||||
accessTokenId?: string;
|
userSessionId?: string | null;
|
||||||
usedOtp?: boolean;
|
whitelistId?: number | null;
|
||||||
|
accessTokenId?: string | null;
|
||||||
doNotExtend?: boolean;
|
doNotExtend?: boolean;
|
||||||
expiresAt?: number | null;
|
expiresAt?: number | null;
|
||||||
sessionLength?: number | null;
|
sessionLength?: number | null;
|
||||||
@@ -27,7 +27,8 @@ export async function createResourceSession(opts: {
|
|||||||
!opts.passwordId &&
|
!opts.passwordId &&
|
||||||
!opts.pincodeId &&
|
!opts.pincodeId &&
|
||||||
!opts.whitelistId &&
|
!opts.whitelistId &&
|
||||||
!opts.accessTokenId
|
!opts.accessTokenId &&
|
||||||
|
!opts.userSessionId
|
||||||
) {
|
) {
|
||||||
throw new Error("Auth method must be provided");
|
throw new Error("Auth method must be provided");
|
||||||
}
|
}
|
||||||
@@ -47,7 +48,9 @@ export async function createResourceSession(opts: {
|
|||||||
pincodeId: opts.pincodeId || null,
|
pincodeId: opts.pincodeId || null,
|
||||||
whitelistId: opts.whitelistId || null,
|
whitelistId: opts.whitelistId || null,
|
||||||
doNotExtend: opts.doNotExtend || false,
|
doNotExtend: opts.doNotExtend || false,
|
||||||
accessTokenId: opts.accessTokenId || null
|
accessTokenId: opts.accessTokenId || null,
|
||||||
|
isRequestToken: opts.isRequestToken || false,
|
||||||
|
userSessionId: opts.userSessionId || null
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.insert(resourceSessions).values(session);
|
await db.insert(resourceSessions).values(session);
|
||||||
@@ -162,22 +165,26 @@ export async function invalidateAllSessions(
|
|||||||
|
|
||||||
export function serializeResourceSessionCookie(
|
export function serializeResourceSessionCookie(
|
||||||
cookieName: string,
|
cookieName: string,
|
||||||
token: string
|
domain: string,
|
||||||
|
token: string,
|
||||||
|
isHttp: boolean = false
|
||||||
): string {
|
): string {
|
||||||
if (SECURE_COOKIES) {
|
if (!isHttp) {
|
||||||
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
return `${cookieName}_s=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${"." + domain}`;
|
||||||
} else {
|
} else {
|
||||||
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
|
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${"." + domain}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBlankResourceSessionTokenCookie(
|
export function createBlankResourceSessionTokenCookie(
|
||||||
cookieName: string
|
cookieName: string,
|
||||||
|
domain: string,
|
||||||
|
isHttp: boolean = false
|
||||||
): string {
|
): string {
|
||||||
if (SECURE_COOKIES) {
|
if (!isHttp) {
|
||||||
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
return `${cookieName}_s=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${"." + domain}`;
|
||||||
} else {
|
} else {
|
||||||
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
|
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${"." + domain}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
67
server/auth/verifyResourceAccessToken.ts
Normal file
67
server/auth/verifyResourceAccessToken.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import db from "@server/db";
|
||||||
|
import {
|
||||||
|
Resource,
|
||||||
|
ResourceAccessToken,
|
||||||
|
resourceAccessToken,
|
||||||
|
} from "@server/db/schema";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { isWithinExpirationDate } from "oslo";
|
||||||
|
import { verifyPassword } from "./password";
|
||||||
|
|
||||||
|
export async function verifyResourceAccessToken({
|
||||||
|
resource,
|
||||||
|
accessTokenId,
|
||||||
|
accessToken
|
||||||
|
}: {
|
||||||
|
resource: Resource;
|
||||||
|
accessTokenId: string;
|
||||||
|
accessToken: string;
|
||||||
|
}): Promise<{
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
tokenItem?: ResourceAccessToken;
|
||||||
|
}> {
|
||||||
|
const [result] = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceAccessToken)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceAccessToken.resourceId, resource.resourceId),
|
||||||
|
eq(resourceAccessToken.accessTokenId, accessTokenId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const tokenItem = result;
|
||||||
|
|
||||||
|
if (!tokenItem) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "Access token does not exist for resource"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const validCode = await verifyPassword(accessToken, tokenItem.tokenHash);
|
||||||
|
|
||||||
|
if (!validCode) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "Invalid access token"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
tokenItem.expiresAt &&
|
||||||
|
!isWithinExpirationDate(new Date(tokenItem.expiresAt))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "Access token has expired"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
tokenItem
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,10 +4,13 @@ import * as schema from "@server/db/schema";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import { APP_PATH } from "@server/lib/consts";
|
import { APP_PATH } from "@server/lib/consts";
|
||||||
|
import { existsSync, mkdirSync } from "fs";
|
||||||
|
|
||||||
export const location = path.join(APP_PATH, "db", "db.sqlite");
|
export const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||||
export const exists = await checkFileExists(location);
|
export const exists = await checkFileExists(location);
|
||||||
|
|
||||||
|
bootstrapVolume();
|
||||||
|
|
||||||
const sqlite = new Database(location);
|
const sqlite = new Database(location);
|
||||||
export const db = drizzle(sqlite, { schema });
|
export const db = drizzle(sqlite, { schema });
|
||||||
|
|
||||||
@@ -21,3 +24,29 @@ async function checkFileExists(filePath: string): Promise<boolean> {
|
|||||||
return false;
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,16 +41,21 @@ export const resources = sqliteTable("resources", {
|
|||||||
})
|
})
|
||||||
.notNull(),
|
.notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
subdomain: text("subdomain").notNull(),
|
subdomain: text("subdomain"),
|
||||||
fullDomain: text("fullDomain").notNull().unique(),
|
fullDomain: text("fullDomain"),
|
||||||
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||||
blockAccess: integer("blockAccess", { mode: "boolean" })
|
blockAccess: integer("blockAccess", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
|
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
|
||||||
|
http: integer("http", { mode: "boolean" }).notNull().default(true),
|
||||||
|
protocol: text("protocol").notNull(),
|
||||||
|
proxyPort: integer("proxyPort"),
|
||||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false)
|
.default(false),
|
||||||
|
isBaseDomain: integer("isBaseDomain", { mode: "boolean" }),
|
||||||
|
applyRules: integer("applyRules", { mode: "boolean" }).notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targets = sqliteTable("targets", {
|
export const targets = sqliteTable("targets", {
|
||||||
@@ -61,10 +66,9 @@ export const targets = sqliteTable("targets", {
|
|||||||
})
|
})
|
||||||
.notNull(),
|
.notNull(),
|
||||||
ip: text("ip").notNull(),
|
ip: text("ip").notNull(),
|
||||||
method: text("method").notNull(),
|
method: text("method"),
|
||||||
port: integer("port").notNull(),
|
port: integer("port").notNull(),
|
||||||
internalPort: integer("internalPort"),
|
internalPort: integer("internalPort"),
|
||||||
protocol: text("protocol"),
|
|
||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -313,6 +317,10 @@ export const resourceSessions = sqliteTable("resourceSessions", {
|
|||||||
doNotExtend: integer("doNotExtend", { mode: "boolean" })
|
doNotExtend: integer("doNotExtend", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
isRequestToken: integer("isRequestToken", { mode: "boolean" }),
|
||||||
|
userSessionId: text("userSessionId").references(() => sessions.sessionId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
passwordId: integer("passwordId").references(
|
passwordId: integer("passwordId").references(
|
||||||
() => resourcePassword.passwordId,
|
() => resourcePassword.passwordId,
|
||||||
{
|
{
|
||||||
@@ -364,6 +372,18 @@ export const versionMigrations = sqliteTable("versionMigrations", {
|
|||||||
executedAt: integer("executedAt").notNull()
|
executedAt: integer("executedAt").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const resourceRules = sqliteTable("resourceRules", {
|
||||||
|
ruleId: integer("ruleId").primaryKey({ autoIncrement: true }),
|
||||||
|
resourceId: integer("resourceId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
|
priority: integer("priority").notNull(),
|
||||||
|
action: text("action").notNull(), // ACCEPT, DROP
|
||||||
|
match: text("match").notNull(), // CIDR, PATH, IP
|
||||||
|
value: text("value").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
export type Site = InferSelectModel<typeof sites>;
|
export type Site = InferSelectModel<typeof sites>;
|
||||||
@@ -396,3 +416,4 @@ export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
|
|||||||
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
|
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
|
||||||
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
||||||
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
||||||
|
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
||||||
|
|||||||
@@ -6,26 +6,21 @@ import logger from "@server/logger";
|
|||||||
|
|
||||||
function createEmailClient() {
|
function createEmailClient() {
|
||||||
const emailConfig = config.getRawConfig().email;
|
const emailConfig = config.getRawConfig().email;
|
||||||
if (
|
if (!emailConfig) {
|
||||||
!emailConfig?.smtp_host ||
|
logger.warn(
|
||||||
!emailConfig?.smtp_pass ||
|
"Email SMTP configuration is missing. Emails will not be sent."
|
||||||
!emailConfig?.smtp_port ||
|
);
|
||||||
!emailConfig?.smtp_user
|
return;
|
||||||
) {
|
}
|
||||||
logger.warn(
|
|
||||||
"Email SMTP configuration is missing. Emails will not be sent.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return nodemailer.createTransport({
|
return nodemailer.createTransport({
|
||||||
host: emailConfig.smtp_host,
|
host: emailConfig.smtp_host,
|
||||||
port: emailConfig.smtp_port,
|
port: emailConfig.smtp_port,
|
||||||
secure: false,
|
secure: emailConfig.smtp_secure || false,
|
||||||
auth: {
|
auth: {
|
||||||
user: emailConfig.smtp_user,
|
user: emailConfig.smtp_user,
|
||||||
pass: emailConfig.smtp_pass,
|
pass: emailConfig.smtp_pass
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const ResourceOTPCode = ({
|
|||||||
<EmailLetterHead />
|
<EmailLetterHead />
|
||||||
|
|
||||||
<EmailHeading>
|
<EmailHeading>
|
||||||
Your One-Time Password for {resourceName}
|
Your One-Time Code for {resourceName}
|
||||||
</EmailHeading>
|
</EmailHeading>
|
||||||
|
|
||||||
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
|
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { runSetupFunctions } from "./setup";
|
|||||||
import { createApiServer } from "./apiServer";
|
import { createApiServer } from "./apiServer";
|
||||||
import { createNextServer } from "./nextServer";
|
import { createNextServer } from "./nextServer";
|
||||||
import { createInternalServer } from "./internalServer";
|
import { createInternalServer } from "./internalServer";
|
||||||
import { User, UserOrg } from "./db/schema";
|
import { Session, User, UserOrg } from "./db/schema";
|
||||||
|
|
||||||
async function startServers() {
|
async function startServers() {
|
||||||
await runSetupFunctions();
|
await runSetupFunctions();
|
||||||
@@ -24,6 +24,7 @@ declare global {
|
|||||||
namespace Express {
|
namespace Express {
|
||||||
interface Request {
|
interface Request {
|
||||||
user?: User;
|
user?: User;
|
||||||
|
session?: Session;
|
||||||
userOrg?: UserOrg;
|
userOrg?: UserOrg;
|
||||||
userOrgRoleId?: number;
|
userOrgRoleId?: number;
|
||||||
userOrgId?: string;
|
userOrgId?: string;
|
||||||
|
|||||||
@@ -3,9 +3,15 @@ import yaml from "js-yaml";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { __DIRNAME, APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
|
import {
|
||||||
import { loadAppVersion } from "@server/lib/loadAppVersion";
|
__DIRNAME,
|
||||||
|
APP_PATH,
|
||||||
|
APP_VERSION,
|
||||||
|
configFilePath1,
|
||||||
|
configFilePath2
|
||||||
|
} from "@server/lib/consts";
|
||||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
|
import stoi from "./stoi";
|
||||||
|
|
||||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||||
const hostnameSchema = z
|
const hostnameSchema = z
|
||||||
@@ -15,34 +21,89 @@ const hostnameSchema = z
|
|||||||
)
|
)
|
||||||
.or(z.literal("localhost"));
|
.or(z.literal("localhost"));
|
||||||
|
|
||||||
const environmentSchema = z.object({
|
const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
|
||||||
|
return process.env[envVar] ?? valFromYaml;
|
||||||
|
};
|
||||||
|
|
||||||
|
const configSchema = z.object({
|
||||||
app: z.object({
|
app: z.object({
|
||||||
dashboard_url: z
|
dashboard_url: z
|
||||||
.string()
|
.string()
|
||||||
.url()
|
.url()
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("APP_DASHBOARDURL"))
|
||||||
|
.pipe(z.string().url())
|
||||||
|
.transform((url) => url.toLowerCase()),
|
||||||
|
base_domain: hostnameSchema
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("APP_BASEDOMAIN"))
|
||||||
|
.pipe(hostnameSchema)
|
||||||
.transform((url) => url.toLowerCase()),
|
.transform((url) => url.toLowerCase()),
|
||||||
base_domain: hostnameSchema,
|
|
||||||
log_level: z.enum(["debug", "info", "warn", "error"]),
|
log_level: z.enum(["debug", "info", "warn", "error"]),
|
||||||
save_logs: z.boolean()
|
save_logs: z.boolean(),
|
||||||
|
log_failed_attempts: z.boolean().optional()
|
||||||
}),
|
}),
|
||||||
server: z.object({
|
server: z.object({
|
||||||
external_port: portSchema,
|
external_port: portSchema
|
||||||
internal_port: portSchema,
|
.optional()
|
||||||
next_port: portSchema,
|
.transform(getEnvOrYaml("SERVER_EXTERNALPORT"))
|
||||||
|
.transform(stoi)
|
||||||
|
.pipe(portSchema),
|
||||||
|
internal_port: portSchema
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("SERVER_INTERNALPORT"))
|
||||||
|
.transform(stoi)
|
||||||
|
.pipe(portSchema),
|
||||||
|
next_port: portSchema
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("SERVER_NEXTPORT"))
|
||||||
|
.transform(stoi)
|
||||||
|
.pipe(portSchema),
|
||||||
internal_hostname: z.string().transform((url) => url.toLowerCase()),
|
internal_hostname: z.string().transform((url) => url.toLowerCase()),
|
||||||
secure_cookies: z.boolean(),
|
|
||||||
session_cookie_name: z.string(),
|
session_cookie_name: z.string(),
|
||||||
resource_session_cookie_name: z.string()
|
resource_access_token_param: 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({
|
traefik: z.object({
|
||||||
http_entrypoint: z.string(),
|
http_entrypoint: z.string(),
|
||||||
https_entrypoint: z.string().optional(),
|
https_entrypoint: z.string().optional(),
|
||||||
cert_resolver: z.string().optional(),
|
cert_resolver: z.string().optional(),
|
||||||
prefer_wildcard_cert: z.boolean().optional()
|
prefer_wildcard_cert: z.boolean().optional(),
|
||||||
|
additional_middlewares: z.array(z.string()).optional()
|
||||||
}),
|
}),
|
||||||
gerbil: z.object({
|
gerbil: z.object({
|
||||||
start_port: portSchema,
|
start_port: portSchema
|
||||||
base_endpoint: z.string().transform((url) => url.toLowerCase()),
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("GERBIL_STARTPORT"))
|
||||||
|
.transform(stoi)
|
||||||
|
.pipe(portSchema),
|
||||||
|
base_endpoint: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("GERBIL_BASEENDPOINT"))
|
||||||
|
.pipe(z.string())
|
||||||
|
.transform((url) => url.toLowerCase()),
|
||||||
use_subdomain: z.boolean(),
|
use_subdomain: z.boolean(),
|
||||||
subnet_group: z.string(),
|
subnet_group: z.string(),
|
||||||
block_size: z.number().positive().gt(0),
|
block_size: z.number().positive().gt(0),
|
||||||
@@ -62,35 +123,53 @@ const environmentSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
email: z
|
email: z
|
||||||
.object({
|
.object({
|
||||||
smtp_host: z.string(),
|
smtp_host: z.string().optional(),
|
||||||
smtp_port: portSchema,
|
smtp_port: portSchema.optional(),
|
||||||
smtp_user: z.string(),
|
smtp_user: z.string().optional(),
|
||||||
smtp_pass: z.string(),
|
smtp_pass: z.string().optional(),
|
||||||
no_reply: z.string().email()
|
smtp_secure: z.boolean().optional(),
|
||||||
|
no_reply: z.string().email().optional()
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
users: z.object({
|
users: z.object({
|
||||||
server_admin: z.object({
|
server_admin: z.object({
|
||||||
email: z.string().email(),
|
email: z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("USERS_SERVERADMIN_EMAIL"))
|
||||||
|
.pipe(z.string().email())
|
||||||
|
.transform((v) => v.toLowerCase()),
|
||||||
password: passwordSchema
|
password: passwordSchema
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD"))
|
||||||
|
.pipe(passwordSchema)
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
flags: z
|
flags: z
|
||||||
.object({
|
.object({
|
||||||
require_email_verification: z.boolean().optional(),
|
require_email_verification: z.boolean().optional(),
|
||||||
disable_signup_without_invite: z.boolean().optional(),
|
disable_signup_without_invite: z.boolean().optional(),
|
||||||
disable_user_create_org: z.boolean().optional()
|
disable_user_create_org: z.boolean().optional(),
|
||||||
|
allow_raw_resources: z.boolean().optional(),
|
||||||
|
allow_base_domain_resources: z.boolean().optional()
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export class Config {
|
export class Config {
|
||||||
private rawConfig!: z.infer<typeof environmentSchema>;
|
private rawConfig!: z.infer<typeof configSchema>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.loadConfig();
|
this.loadConfig();
|
||||||
|
|
||||||
|
if (process.env.GENERATE_TRAEFIK_CONFIG === "true") {
|
||||||
|
this.createTraefikConfig();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public loadEnvironment() {}
|
||||||
|
|
||||||
public loadConfig() {
|
public loadConfig() {
|
||||||
const loadConfig = (configPath: string) => {
|
const loadConfig = (configPath: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -131,6 +210,9 @@ export class Config {
|
|||||||
);
|
);
|
||||||
environment = loadConfig(configFilePath1);
|
environment = loadConfig(configFilePath1);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.log(
|
||||||
|
"See the docs for information about what to include in the configuration file: https://docs.fossorial.io/Pangolin/Configuration/config"
|
||||||
|
);
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Error creating configuration file from example: ${
|
`Error creating configuration file from example: ${
|
||||||
@@ -151,18 +233,14 @@ export class Config {
|
|||||||
throw new Error("No configuration file found");
|
throw new Error("No configuration file found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedConfig = environmentSchema.safeParse(environment);
|
const parsedConfig = configSchema.safeParse(environment);
|
||||||
|
|
||||||
if (!parsedConfig.success) {
|
if (!parsedConfig.success) {
|
||||||
const errors = fromError(parsedConfig.error);
|
const errors = fromError(parsedConfig.error);
|
||||||
throw new Error(`Invalid configuration file: ${errors}`);
|
throw new Error(`Invalid configuration file: ${errors}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const appVersion = loadAppVersion();
|
process.env.APP_VERSION = APP_VERSION;
|
||||||
if (!appVersion) {
|
|
||||||
throw new Error("Could not load the application version");
|
|
||||||
}
|
|
||||||
process.env.APP_VERSION = appVersion;
|
|
||||||
|
|
||||||
process.env.NEXT_PORT = parsedConfig.data.server.next_port.toString();
|
process.env.NEXT_PORT = parsedConfig.data.server.next_port.toString();
|
||||||
process.env.SERVER_EXTERNAL_PORT =
|
process.env.SERVER_EXTERNAL_PORT =
|
||||||
@@ -173,10 +251,12 @@ export class Config {
|
|||||||
?.require_email_verification
|
?.require_email_verification
|
||||||
? "true"
|
? "true"
|
||||||
: "false";
|
: "false";
|
||||||
|
process.env.FLAGS_ALLOW_RAW_RESOURCES = parsedConfig.data.flags
|
||||||
|
?.allow_raw_resources
|
||||||
|
? "true"
|
||||||
|
: "false";
|
||||||
process.env.SESSION_COOKIE_NAME =
|
process.env.SESSION_COOKIE_NAME =
|
||||||
parsedConfig.data.server.session_cookie_name;
|
parsedConfig.data.server.session_cookie_name;
|
||||||
process.env.RESOURCE_SESSION_COOKIE_NAME =
|
|
||||||
parsedConfig.data.server.resource_session_cookie_name;
|
|
||||||
process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false";
|
process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false";
|
||||||
process.env.DISABLE_SIGNUP_WITHOUT_INVITE = parsedConfig.data.flags
|
process.env.DISABLE_SIGNUP_WITHOUT_INVITE = parsedConfig.data.flags
|
||||||
?.disable_signup_without_invite
|
?.disable_signup_without_invite
|
||||||
@@ -186,6 +266,15 @@ export class Config {
|
|||||||
?.disable_user_create_org
|
?.disable_user_create_org
|
||||||
? "true"
|
? "true"
|
||||||
: "false";
|
: "false";
|
||||||
|
process.env.RESOURCE_ACCESS_TOKEN_PARAM =
|
||||||
|
parsedConfig.data.server.resource_access_token_param;
|
||||||
|
process.env.RESOURCE_SESSION_REQUEST_PARAM =
|
||||||
|
parsedConfig.data.server.resource_session_request_param;
|
||||||
|
process.env.FLAGS_ALLOW_BASE_DOMAIN_RESOURCES = parsedConfig.data.flags
|
||||||
|
?.allow_base_domain_resources
|
||||||
|
? "true"
|
||||||
|
: "false";
|
||||||
|
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
|
||||||
|
|
||||||
this.rawConfig = parsedConfig.data;
|
this.rawConfig = parsedConfig.data;
|
||||||
}
|
}
|
||||||
@@ -197,6 +286,78 @@ export class Config {
|
|||||||
public getBaseDomain(): string {
|
public getBaseDomain(): string {
|
||||||
return this.rawConfig.app.base_domain;
|
return this.rawConfig.app.base_domain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getNoReplyEmail(): string | undefined {
|
||||||
|
return (
|
||||||
|
this.rawConfig.email?.no_reply || this.rawConfig.email?.smtp_user
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createTraefikConfig() {
|
||||||
|
try {
|
||||||
|
// check if traefik_config.yml and dynamic_config.yml exists in APP_PATH/traefik
|
||||||
|
const defaultTraefikConfigPath = path.join(
|
||||||
|
__DIRNAME,
|
||||||
|
"traefik_config.example.yml"
|
||||||
|
);
|
||||||
|
const defaultDynamicConfigPath = path.join(
|
||||||
|
__DIRNAME,
|
||||||
|
"dynamic_config.example.yml"
|
||||||
|
);
|
||||||
|
|
||||||
|
const traefikPath = path.join(APP_PATH, "traefik");
|
||||||
|
if (!fs.existsSync(traefikPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// load default configs
|
||||||
|
let traefikConfig = fs.readFileSync(
|
||||||
|
defaultTraefikConfigPath,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
let dynamicConfig = fs.readFileSync(
|
||||||
|
defaultDynamicConfigPath,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
traefikConfig = traefikConfig
|
||||||
|
.split("{{.LetsEncryptEmail}}")
|
||||||
|
.join(this.rawConfig.users.server_admin.email);
|
||||||
|
traefikConfig = traefikConfig
|
||||||
|
.split("{{.INTERNAL_PORT}}")
|
||||||
|
.join(this.rawConfig.server.internal_port.toString());
|
||||||
|
|
||||||
|
dynamicConfig = dynamicConfig
|
||||||
|
.split("{{.DashboardDomain}}")
|
||||||
|
.join(new URL(this.rawConfig.app.dashboard_url).hostname);
|
||||||
|
dynamicConfig = dynamicConfig
|
||||||
|
.split("{{.NEXT_PORT}}")
|
||||||
|
.join(this.rawConfig.server.next_port.toString());
|
||||||
|
dynamicConfig = dynamicConfig
|
||||||
|
.split("{{.EXTERNAL_PORT}}")
|
||||||
|
.join(this.rawConfig.server.external_port.toString());
|
||||||
|
|
||||||
|
// write thiese to the traefik directory
|
||||||
|
const traefikConfigPath = path.join(
|
||||||
|
traefikPath,
|
||||||
|
"traefik_config.yml"
|
||||||
|
);
|
||||||
|
const dynamicConfigPath = path.join(
|
||||||
|
traefikPath,
|
||||||
|
"dynamic_config.yml"
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(traefikConfigPath, traefikConfig, "utf8");
|
||||||
|
fs.writeFileSync(dynamicConfigPath, dynamicConfig, "utf8");
|
||||||
|
|
||||||
|
console.log("Traefik configuration files created");
|
||||||
|
} catch (e) {
|
||||||
|
console.log(
|
||||||
|
"Failed to generate the Traefik configuration files. Please create them manually."
|
||||||
|
);
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = new Config();
|
export const config = new Config();
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { existsSync } from "fs";
|
|
||||||
|
// This is a placeholder value replaced by the build process
|
||||||
|
export const APP_VERSION = "1.0.0-beta.13";
|
||||||
|
|
||||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||||
export const __DIRNAME = path.dirname(__FILENAME);
|
export const __DIRNAME = path.dirname(__FILENAME);
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import path from "path";
|
|
||||||
import { __DIRNAME } from "@server/lib/consts";
|
|
||||||
import fs from "fs";
|
|
||||||
|
|
||||||
export function loadAppVersion() {
|
|
||||||
const packageJsonPath = path.join("package.json");
|
|
||||||
let packageJson: any;
|
|
||||||
if (fs.existsSync && fs.existsSync(packageJsonPath)) {
|
|
||||||
const packageJsonContent = fs.readFileSync(packageJsonPath, "utf8");
|
|
||||||
packageJson = JSON.parse(packageJsonContent);
|
|
||||||
|
|
||||||
if (packageJson.version) {
|
|
||||||
return packageJson.version;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,3 +8,4 @@ export const subdomainSchema = z
|
|||||||
)
|
)
|
||||||
.min(1, "Subdomain must be at least 1 character long")
|
.min(1, "Subdomain must be at least 1 character long")
|
||||||
.transform((val) => val.toLowerCase());
|
.transform((val) => val.toLowerCase());
|
||||||
|
|
||||||
44
server/lib/validators.ts
Normal file
44
server/lib/validators.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export function isValidCIDR(cidr: string): boolean {
|
||||||
|
return z.string().cidr().safeParse(cidr).success;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidIP(ip: string): boolean {
|
||||||
|
return z.string().ip().safeParse(ip).success;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidUrlGlobPattern(pattern: string): boolean {
|
||||||
|
// Remove leading slash if present
|
||||||
|
pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern;
|
||||||
|
|
||||||
|
// Empty string is not valid
|
||||||
|
if (!pattern) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split path into segments
|
||||||
|
const segments = pattern.split("/");
|
||||||
|
|
||||||
|
// Check each segment
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
const segment = segments[i];
|
||||||
|
|
||||||
|
// Empty segments are not allowed (double slashes)
|
||||||
|
if (!segment && i !== segments.length - 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If segment contains *, it must be exactly *
|
||||||
|
if (segment.includes("*") && segment !== "*") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for invalid characters
|
||||||
|
if (!/^[a-zA-Z0-9_*-]*$/.test(segment)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { resourceAccessToken, resources, userOrgs } from "@server/db/schema";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { canUserAccessResource } from "@server/lib/canUserAccessResource";
|
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
|
||||||
|
|
||||||
export async function verifyAccessTokenAccess(
|
export async function verifyAccessTokenAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export async function verifyAdmin(
|
|||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
const orgId = req.userOrgId;
|
const orgId = req.userOrgId;
|
||||||
|
|
||||||
if (!userId) {
|
if (!orgId) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.UNAUTHORIZED, "User does not have orgId")
|
createHttpError(HttpCode.UNAUTHORIZED, "User does not have orgId")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { resources, targets, userOrgs } from "@server/db/schema";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { canUserAccessResource } from "../lib/canUserAccessResource";
|
import { canUserAccessResource } from "../auth/canUserAccessResource";
|
||||||
|
|
||||||
export async function verifyTargetAccess(
|
export async function verifyTargetAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { verifySession } from "@server/auth/sessions/verifySession";
|
import { verifySession } from "@server/auth/sessions/verifySession";
|
||||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export const verifySessionUserMiddleware = async (
|
export const verifySessionUserMiddleware = async (
|
||||||
req: any,
|
req: any,
|
||||||
@@ -16,6 +17,9 @@ export const verifySessionUserMiddleware = async (
|
|||||||
) => {
|
) => {
|
||||||
const { session, user } = await verifySession(req);
|
const { session, user } = await verifySession(req);
|
||||||
if (!session || !user) {
|
if (!session || !user) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(`User session not found. IP: ${req.ip}.`);
|
||||||
|
}
|
||||||
return next(unauthorized());
|
return next(unauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +29,9 @@ export const verifySessionUserMiddleware = async (
|
|||||||
.where(eq(users.userId, user.userId));
|
.where(eq(users.userId, user.userId));
|
||||||
|
|
||||||
if (!existingUser || !existingUser[0]) {
|
if (!existingUser || !existingUser[0]) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(`User session not found. IP: ${req.ip}.`);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "User does not exist")
|
createHttpError(HttpCode.BAD_REQUEST, "User does not exist")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ export async function disable2fa(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!validOTP) {
|
if (!validOTP) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Two-factor authentication code is incorrect. Email: ${user.email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ import { verifySession } from "@server/auth/sessions/verifySession";
|
|||||||
|
|
||||||
export const loginBodySchema = z
|
export const loginBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
email: z.string().email(),
|
email: z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.transform((v) => v.toLowerCase()),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
code: z.string().optional()
|
code: z.string().optional()
|
||||||
})
|
})
|
||||||
@@ -68,6 +71,11 @@ export async function login(
|
|||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.email, email));
|
.where(eq(users.email, email));
|
||||||
if (!existingUserRes || !existingUserRes.length) {
|
if (!existingUserRes || !existingUserRes.length) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
@@ -83,6 +91,11 @@ export async function login(
|
|||||||
existingUser.passwordHash
|
existingUser.passwordHash
|
||||||
);
|
);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
@@ -109,6 +122,11 @@ export async function login(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!validOTP) {
|
if (!validOTP) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Two-factor code incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
@@ -120,7 +138,8 @@ export async function login(
|
|||||||
|
|
||||||
const token = generateSessionToken();
|
const token = generateSessionToken();
|
||||||
await createSession(token, existingUser.userId);
|
await createSession(token, existingUser.userId);
|
||||||
const cookie = serializeSessionCookie(token);
|
const isSecure = req.protocol === "https";
|
||||||
|
const cookie = serializeSessionCookie(token, isSecure);
|
||||||
|
|
||||||
res.appendHeader("Set-Cookie", cookie);
|
res.appendHeader("Set-Cookie", cookie);
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,23 @@ import response from "@server/lib/response";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import {
|
import {
|
||||||
createBlankSessionTokenCookie,
|
createBlankSessionTokenCookie,
|
||||||
invalidateSession,
|
invalidateSession
|
||||||
SESSION_COOKIE_NAME
|
|
||||||
} from "@server/auth/sessions/app";
|
} from "@server/auth/sessions/app";
|
||||||
|
import { verifySession } from "@server/auth/sessions/verifySession";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export async function logout(
|
export async function logout(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const sessionId = req.cookies[SESSION_COOKIE_NAME];
|
const { user, session } = await verifySession(req);
|
||||||
|
if (!user || !session) {
|
||||||
if (!sessionId) {
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Log out failed because missing or invalid session. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
@@ -26,8 +31,14 @@ export async function logout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invalidateSession(sessionId);
|
try {
|
||||||
res.setHeader("Set-Cookie", createBlankSessionTokenCookie());
|
await invalidateSession(session.sessionId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to invalidate session", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSecure = req.protocol === "https";
|
||||||
|
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
|
||||||
|
|
||||||
return response<null>(res, {
|
return response<null>(res, {
|
||||||
data: null,
|
data: null,
|
||||||
|
|||||||
@@ -8,10 +8,8 @@ import { db } from "@server/db";
|
|||||||
import { passwordResetTokens, users } from "@server/db/schema";
|
import { passwordResetTokens, users } from "@server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
|
import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
|
||||||
import { encodeHex } from "oslo/encoding";
|
|
||||||
import { createDate } from "oslo";
|
import { createDate } from "oslo";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { generateIdFromEntropySize } from "@server/auth/sessions/app";
|
|
||||||
import { TimeSpan } from "oslo";
|
import { TimeSpan } from "oslo";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { sendEmail } from "@server/emails";
|
import { sendEmail } from "@server/emails";
|
||||||
@@ -20,7 +18,10 @@ import { hashPassword } from "@server/auth/password";
|
|||||||
|
|
||||||
export const requestPasswordResetBody = z
|
export const requestPasswordResetBody = z
|
||||||
.object({
|
.object({
|
||||||
email: z.string().email()
|
email: z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.transform((v) => v.toLowerCase())
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -63,10 +64,7 @@ export async function requestPasswordReset(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = generateRandomString(
|
const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
|
||||||
8,
|
|
||||||
alphabet("0-9", "A-Z", "a-z")
|
|
||||||
);
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx
|
await trx
|
||||||
.delete(passwordResetTokens)
|
.delete(passwordResetTokens)
|
||||||
@@ -84,6 +82,12 @@ export async function requestPasswordReset(
|
|||||||
|
|
||||||
const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${email}&token=${token}`;
|
const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${email}&token=${token}`;
|
||||||
|
|
||||||
|
if (!config.getRawConfig().email) {
|
||||||
|
logger.info(
|
||||||
|
`Password reset requested for ${email}. Token: ${token}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
ResetPasswordCode({
|
ResetPasswordCode({
|
||||||
email,
|
email,
|
||||||
@@ -91,7 +95,7 @@ export async function requestPasswordReset(
|
|||||||
link: url
|
link: url
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
from: config.getRawConfig().email?.no_reply,
|
from: config.getNoReplyEmail(),
|
||||||
to: email,
|
to: email,
|
||||||
subject: "Reset your password"
|
subject: "Reset your password"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ import { passwordSchema } from "@server/auth/passwordSchema";
|
|||||||
|
|
||||||
export const resetPasswordBody = z
|
export const resetPasswordBody = z
|
||||||
.object({
|
.object({
|
||||||
email: z.string().email(),
|
email: z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.transform((v) => v.toLowerCase()),
|
||||||
token: z.string(), // reset secret code
|
token: z.string(), // reset secret code
|
||||||
newPassword: passwordSchema,
|
newPassword: passwordSchema,
|
||||||
code: z.string().optional() // 2fa code
|
code: z.string().optional() // 2fa code
|
||||||
@@ -57,6 +60,11 @@ export async function resetPassword(
|
|||||||
.where(eq(passwordResetTokens.email, email));
|
.where(eq(passwordResetTokens.email, email));
|
||||||
|
|
||||||
if (!resetRequest || !resetRequest.length) {
|
if (!resetRequest || !resetRequest.length) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Password reset code is incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
@@ -106,6 +114,11 @@ export async function resetPassword(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!validOTP) {
|
if (!validOTP) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Two-factor authentication code is incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
@@ -121,6 +134,11 @@ export async function resetPassword(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!isTokenValid) {
|
if (!isTokenValid) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Password reset code is incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
@@ -145,7 +163,7 @@ export async function resetPassword(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await sendEmail(ConfirmPasswordReset({ email }), {
|
await sendEmail(ConfirmPasswordReset({ email }), {
|
||||||
from: config.getRawConfig().email?.no_reply,
|
from: config.getNoReplyEmail(),
|
||||||
to: email,
|
to: email,
|
||||||
subject: "Password Reset Confirmation"
|
subject: "Password Reset Confirmation"
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ import { checkValidInvite } from "@server/auth/checkValidInvite";
|
|||||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
|
|
||||||
export const signupBodySchema = z.object({
|
export const signupBodySchema = z.object({
|
||||||
email: z.string().email(),
|
email: z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.transform((v) => v.toLowerCase()),
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
inviteToken: z.string().optional(),
|
inviteToken: z.string().optional(),
|
||||||
inviteId: z.string().optional()
|
inviteId: z.string().optional()
|
||||||
@@ -60,6 +63,11 @@ export async function signup(
|
|||||||
|
|
||||||
if (config.getRawConfig().flags?.disable_signup_without_invite) {
|
if (config.getRawConfig().flags?.disable_signup_without_invite) {
|
||||||
if (!inviteToken || !inviteId) {
|
if (!inviteToken || !inviteId) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Signup blocked without invite. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
@@ -84,6 +92,11 @@ export async function signup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (existingInvite.email !== email) {
|
if (existingInvite.email !== email) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`User attempted to use an invite for another user. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
@@ -158,7 +171,8 @@ export async function signup(
|
|||||||
|
|
||||||
const token = generateSessionToken();
|
const token = generateSessionToken();
|
||||||
await createSession(token, userId);
|
await createSession(token, userId);
|
||||||
const cookie = serializeSessionCookie(token);
|
const isSecure = req.protocol === "https";
|
||||||
|
const cookie = serializeSessionCookie(token, isSecure);
|
||||||
res.appendHeader("Set-Cookie", cookie);
|
res.appendHeader("Set-Cookie", cookie);
|
||||||
|
|
||||||
if (config.getRawConfig().flags?.require_email_verification) {
|
if (config.getRawConfig().flags?.require_email_verification) {
|
||||||
@@ -184,6 +198,11 @@ export async function signup(
|
|||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
|
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Account already exists with that email. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ export async function verifyEmail(
|
|||||||
.where(eq(users.userId, user.userId));
|
.where(eq(users.userId, user.userId));
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Email verification code incorrect. Email: ${user.email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ export async function verifyTotp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Two-factor authentication code is incorrect. Email: ${user.email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|||||||
187
server/routers/badger/exchangeSession.ts
Normal file
187
server/routers/badger/exchangeSession.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { resourceAccessToken, resources, sessions } from "@server/db/schema";
|
||||||
|
import db from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
createResourceSession,
|
||||||
|
serializeResourceSessionCookie,
|
||||||
|
validateResourceSessionToken
|
||||||
|
} from "@server/auth/sessions/resource";
|
||||||
|
import { generateSessionToken, SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/app";
|
||||||
|
import { SESSION_COOKIE_EXPIRES as RESOURCE_SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/resource";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import { response } from "@server/lib";
|
||||||
|
|
||||||
|
const exchangeSessionBodySchema = z.object({
|
||||||
|
requestToken: z.string(),
|
||||||
|
host: z.string(),
|
||||||
|
requestIp: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ExchangeSessionBodySchema = z.infer<
|
||||||
|
typeof exchangeSessionBodySchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ExchangeSessionResponse = {
|
||||||
|
valid: boolean;
|
||||||
|
cookie?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function exchangeSession(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
logger.debug("Exchange session: Badger sent", req.body);
|
||||||
|
|
||||||
|
const parsedBody = exchangeSessionBodySchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { requestToken, host, requestIp } = parsedBody.data;
|
||||||
|
|
||||||
|
const clientIp = requestIp?.split(":")[0];
|
||||||
|
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.fullDomain, host))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with host ${host} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resourceSession: requestSession } =
|
||||||
|
await validateResourceSessionToken(
|
||||||
|
requestToken,
|
||||||
|
resource.resourceId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!requestSession) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Exchange token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requestSession.isRequestToken) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Exchange token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(sessions).where(eq(sessions.sessionId, requestToken));
|
||||||
|
|
||||||
|
const token = generateSessionToken();
|
||||||
|
|
||||||
|
if (requestSession.userSessionId) {
|
||||||
|
const [res] = await db
|
||||||
|
.select()
|
||||||
|
.from(sessions)
|
||||||
|
.where(eq(sessions.sessionId, requestSession.userSessionId))
|
||||||
|
.limit(1);
|
||||||
|
if (res) {
|
||||||
|
await createResourceSession({
|
||||||
|
token,
|
||||||
|
resourceId: resource.resourceId,
|
||||||
|
isRequestToken: false,
|
||||||
|
userSessionId: requestSession.userSessionId,
|
||||||
|
doNotExtend: false,
|
||||||
|
expiresAt: res.expiresAt,
|
||||||
|
sessionLength: SESSION_COOKIE_EXPIRES
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (requestSession.accessTokenId) {
|
||||||
|
const [res] = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceAccessToken)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourceAccessToken.accessTokenId,
|
||||||
|
requestSession.accessTokenId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (res) {
|
||||||
|
await createResourceSession({
|
||||||
|
token,
|
||||||
|
resourceId: resource.resourceId,
|
||||||
|
isRequestToken: false,
|
||||||
|
accessTokenId: requestSession.accessTokenId,
|
||||||
|
doNotExtend: true,
|
||||||
|
expiresAt: res.expiresAt,
|
||||||
|
sessionLength: res.sessionLength
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await createResourceSession({
|
||||||
|
token,
|
||||||
|
resourceId: resource.resourceId,
|
||||||
|
isRequestToken: false,
|
||||||
|
passwordId: requestSession.passwordId,
|
||||||
|
pincodeId: requestSession.pincodeId,
|
||||||
|
userSessionId: requestSession.userSessionId,
|
||||||
|
whitelistId: requestSession.whitelistId,
|
||||||
|
accessTokenId: requestSession.accessTokenId,
|
||||||
|
doNotExtend: false,
|
||||||
|
expiresAt: new Date(
|
||||||
|
Date.now() + SESSION_COOKIE_EXPIRES
|
||||||
|
).getTime(),
|
||||||
|
sessionLength: RESOURCE_SESSION_COOKIE_EXPIRES
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
|
||||||
|
const cookie = serializeResourceSessionCookie(
|
||||||
|
cookieName,
|
||||||
|
resource.fullDomain!,
|
||||||
|
token,
|
||||||
|
!resource.ssl
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug(JSON.stringify("Exchange cookie: " + cookie));
|
||||||
|
return response<ExchangeSessionResponse>(res, {
|
||||||
|
data: { valid: true, cookie },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Session exchanged successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to exchange session"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./verifySession";
|
export * from "./verifySession";
|
||||||
|
export * from "./exchangeSession";
|
||||||
|
|||||||
@@ -1,25 +1,43 @@
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import {
|
||||||
import createHttpError from "http-errors";
|
createResourceSession,
|
||||||
import { z } from "zod";
|
serializeResourceSessionCookie,
|
||||||
import { fromError } from "zod-validation-error";
|
validateResourceSessionToken
|
||||||
import { response } from "@server/lib/response";
|
} from "@server/auth/sessions/resource";
|
||||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import {
|
import {
|
||||||
resourceAccessToken,
|
Resource,
|
||||||
|
ResourceAccessToken,
|
||||||
|
ResourcePassword,
|
||||||
resourcePassword,
|
resourcePassword,
|
||||||
|
ResourcePincode,
|
||||||
resourcePincode,
|
resourcePincode,
|
||||||
|
ResourceRule,
|
||||||
|
resourceRules,
|
||||||
resources,
|
resources,
|
||||||
resourceWhitelist,
|
roleResources,
|
||||||
User,
|
sessions,
|
||||||
userOrgs
|
userOrgs,
|
||||||
|
userResources,
|
||||||
|
users
|
||||||
} from "@server/db/schema";
|
} from "@server/db/schema";
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
|
import { isIpInCidr } from "@server/lib/ip";
|
||||||
import { Resource, roleResources, userResources } from "@server/db/schema";
|
import { response } from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import NodeCache from "node-cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
// We'll see if this speeds anything up
|
||||||
|
const cache = new NodeCache({
|
||||||
|
stdTTL: 5 // seconds
|
||||||
|
});
|
||||||
|
|
||||||
const verifyResourceSessionSchema = z.object({
|
const verifyResourceSessionSchema = z.object({
|
||||||
sessions: z.record(z.string()).optional(),
|
sessions: z.record(z.string()).optional(),
|
||||||
@@ -28,7 +46,9 @@ const verifyResourceSessionSchema = z.object({
|
|||||||
host: z.string(),
|
host: z.string(),
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
method: z.string(),
|
method: z.string(),
|
||||||
tls: z.boolean()
|
accessToken: z.string().optional(),
|
||||||
|
tls: z.boolean(),
|
||||||
|
requestIp: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type VerifyResourceSessionSchema = z.infer<
|
export type VerifyResourceSessionSchema = z.infer<
|
||||||
@@ -45,7 +65,7 @@ export async function verifyResourceSession(
|
|||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
logger.debug("Badger sent", req.body); // remove when done testing
|
logger.debug("Verify session: Badger sent", req.body); // remove when done testing
|
||||||
|
|
||||||
const parsedBody = verifyResourceSessionSchema.safeParse(req.body);
|
const parsedBody = verifyResourceSessionSchema.safeParse(req.body);
|
||||||
|
|
||||||
@@ -59,25 +79,56 @@ export async function verifyResourceSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { sessions, host, originalRequestURL } = parsedBody.data;
|
const {
|
||||||
|
sessions,
|
||||||
|
host,
|
||||||
|
originalRequestURL,
|
||||||
|
requestIp,
|
||||||
|
path,
|
||||||
|
accessToken: token
|
||||||
|
} = parsedBody.data;
|
||||||
|
|
||||||
const [result] = await db
|
const clientIp = requestIp?.split(":")[0];
|
||||||
.select()
|
|
||||||
.from(resources)
|
|
||||||
.leftJoin(
|
|
||||||
resourcePincode,
|
|
||||||
eq(resourcePincode.resourceId, resources.resourceId)
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
resourcePassword,
|
|
||||||
eq(resourcePassword.resourceId, resources.resourceId)
|
|
||||||
)
|
|
||||||
.where(eq(resources.fullDomain, host))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const resource = result?.resources;
|
const resourceCacheKey = `resource:${host}`;
|
||||||
const pincode = result?.resourcePincode;
|
let resourceData:
|
||||||
const password = result?.resourcePassword;
|
| {
|
||||||
|
resource: Resource | null;
|
||||||
|
pincode: ResourcePincode | null;
|
||||||
|
password: ResourcePassword | null;
|
||||||
|
}
|
||||||
|
| undefined = cache.get(resourceCacheKey);
|
||||||
|
|
||||||
|
if (!resourceData) {
|
||||||
|
const [result] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.leftJoin(
|
||||||
|
resourcePincode,
|
||||||
|
eq(resourcePincode.resourceId, resources.resourceId)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
resourcePassword,
|
||||||
|
eq(resourcePassword.resourceId, resources.resourceId)
|
||||||
|
)
|
||||||
|
.where(eq(resources.fullDomain, host))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
logger.debug("Resource not found", host);
|
||||||
|
return notAllowed(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceData = {
|
||||||
|
resource: result.resources,
|
||||||
|
pincode: result.resourcePincode,
|
||||||
|
password: result.resourcePassword
|
||||||
|
};
|
||||||
|
|
||||||
|
cache.set(resourceCacheKey, resourceData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resource, pincode, password } = resourceData;
|
||||||
|
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
logger.debug("Resource not found", host);
|
logger.debug("Resource not found", host);
|
||||||
@@ -101,42 +152,109 @@ export async function verifyResourceSession(
|
|||||||
return allowed(res);
|
return allowed(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
// check the rules
|
||||||
|
if (resource.applyRules) {
|
||||||
|
const action = await checkRules(
|
||||||
|
resource.resourceId,
|
||||||
|
clientIp,
|
||||||
|
path
|
||||||
|
);
|
||||||
|
|
||||||
if (!sessions) {
|
if (action == "ACCEPT") {
|
||||||
return notAllowed(res);
|
logger.debug("Resource allowed by rule");
|
||||||
|
return allowed(res);
|
||||||
|
} else if (action == "DROP") {
|
||||||
|
logger.debug("Resource denied by rule");
|
||||||
|
return notAllowed(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise its undefined and we pass
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionToken = sessions[config.getRawConfig().server.session_cookie_name];
|
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(
|
||||||
|
resource.resourceId
|
||||||
|
)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
||||||
|
|
||||||
// check for unified login
|
// check for access token
|
||||||
if (sso && sessionToken) {
|
let validAccessToken: ResourceAccessToken | undefined;
|
||||||
const { session, user } = await validateSessionToken(sessionToken);
|
if (token) {
|
||||||
if (session && user) {
|
const [accessTokenId, accessToken] = token.split(".");
|
||||||
const isAllowed = await isUserAllowedToAccessResource(
|
const { valid, error, tokenItem } = await verifyResourceAccessToken(
|
||||||
user,
|
{ resource, accessTokenId, accessToken }
|
||||||
resource
|
);
|
||||||
);
|
|
||||||
|
|
||||||
if (isAllowed) {
|
if (error) {
|
||||||
logger.debug(
|
logger.debug("Access token invalid: " + error);
|
||||||
"Resource allowed because user session is valid"
|
}
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Resource access token is invalid. Resource ID: ${
|
||||||
|
resource.resourceId
|
||||||
|
}. IP: ${clientIp}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valid && tokenItem) {
|
||||||
|
validAccessToken = tokenItem;
|
||||||
|
|
||||||
|
if (!sessions) {
|
||||||
|
return await createAccessTokenSession(
|
||||||
|
res,
|
||||||
|
resource,
|
||||||
|
tokenItem
|
||||||
);
|
);
|
||||||
return allowed(res);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!sessions) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Missing resource sessions. Resource ID: ${
|
||||||
|
resource.resourceId
|
||||||
|
}. IP: ${clientIp}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return notAllowed(res);
|
||||||
|
}
|
||||||
|
|
||||||
const resourceSessionToken =
|
const resourceSessionToken =
|
||||||
sessions[
|
sessions[
|
||||||
`${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`
|
`${config.getRawConfig().server.session_cookie_name}${
|
||||||
|
resource.ssl ? "_s" : ""
|
||||||
|
}`
|
||||||
];
|
];
|
||||||
|
|
||||||
if (resourceSessionToken) {
|
if (resourceSessionToken) {
|
||||||
const { resourceSession } = await validateResourceSessionToken(
|
const sessionCacheKey = `session:${resourceSessionToken}`;
|
||||||
resourceSessionToken,
|
let resourceSession: any = cache.get(sessionCacheKey);
|
||||||
resource.resourceId
|
|
||||||
);
|
if (!resourceSession) {
|
||||||
|
const result = await validateResourceSessionToken(
|
||||||
|
resourceSessionToken,
|
||||||
|
resource.resourceId
|
||||||
|
);
|
||||||
|
|
||||||
|
resourceSession = result?.resourceSession;
|
||||||
|
cache.set(sessionCacheKey, resourceSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceSession?.isRequestToken) {
|
||||||
|
logger.debug(
|
||||||
|
"Resource not allowed because session is a temporary request token"
|
||||||
|
);
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Resource session is an exchange token. Resource ID: ${
|
||||||
|
resource.resourceId
|
||||||
|
}. IP: ${clientIp}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return notAllowed(res);
|
||||||
|
}
|
||||||
|
|
||||||
if (resourceSession) {
|
if (resourceSession) {
|
||||||
if (pincode && resourceSession.pincodeId) {
|
if (pincode && resourceSession.pincodeId) {
|
||||||
@@ -169,10 +287,53 @@ export async function verifyResourceSession(
|
|||||||
);
|
);
|
||||||
return allowed(res);
|
return allowed(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resourceSession.userSessionId && sso) {
|
||||||
|
const userAccessCacheKey = `userAccess:${
|
||||||
|
resourceSession.userSessionId
|
||||||
|
}:${resource.resourceId}`;
|
||||||
|
|
||||||
|
let isAllowed: boolean | undefined =
|
||||||
|
cache.get(userAccessCacheKey);
|
||||||
|
|
||||||
|
if (isAllowed === undefined) {
|
||||||
|
isAllowed = await isUserAllowedToAccessResource(
|
||||||
|
resourceSession.userSessionId,
|
||||||
|
resource
|
||||||
|
);
|
||||||
|
|
||||||
|
cache.set(userAccessCacheKey, isAllowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAllowed) {
|
||||||
|
logger.debug(
|
||||||
|
"Resource allowed because user session is valid"
|
||||||
|
);
|
||||||
|
return allowed(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// At this point we have checked all sessions, but since the access token is
|
||||||
|
// valid, we should allow access and create a new session.
|
||||||
|
if (validAccessToken) {
|
||||||
|
return await createAccessTokenSession(
|
||||||
|
res,
|
||||||
|
resource,
|
||||||
|
validAccessToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug("No more auth to check, resource not allowed");
|
logger.debug("No more auth to check, resource not allowed");
|
||||||
|
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Resource access not allowed. Resource ID: ${
|
||||||
|
resource.resourceId
|
||||||
|
}. IP: ${clientIp}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return notAllowed(res, redirectUrl);
|
return notAllowed(res, redirectUrl);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -209,11 +370,59 @@ function allowed(res: Response) {
|
|||||||
return response<VerifyUserResponse>(res, data);
|
return response<VerifyUserResponse>(res, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createAccessTokenSession(
|
||||||
|
res: Response,
|
||||||
|
resource: Resource,
|
||||||
|
tokenItem: ResourceAccessToken
|
||||||
|
) {
|
||||||
|
const token = generateSessionToken();
|
||||||
|
await createResourceSession({
|
||||||
|
resourceId: resource.resourceId,
|
||||||
|
token,
|
||||||
|
accessTokenId: tokenItem.accessTokenId,
|
||||||
|
sessionLength: tokenItem.sessionLength,
|
||||||
|
expiresAt: tokenItem.expiresAt,
|
||||||
|
doNotExtend: tokenItem.expiresAt ? true : false
|
||||||
|
});
|
||||||
|
const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
|
||||||
|
const cookie = serializeResourceSessionCookie(
|
||||||
|
cookieName,
|
||||||
|
resource.fullDomain!,
|
||||||
|
token,
|
||||||
|
!resource.ssl
|
||||||
|
);
|
||||||
|
res.appendHeader("Set-Cookie", cookie);
|
||||||
|
logger.debug("Access token is valid, creating new session");
|
||||||
|
return response<VerifyUserResponse>(res, {
|
||||||
|
data: { valid: true },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Access allowed",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function isUserAllowedToAccessResource(
|
async function isUserAllowedToAccessResource(
|
||||||
user: User,
|
userSessionId: string,
|
||||||
resource: Resource
|
resource: Resource
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (config.getRawConfig().flags?.require_email_verification && !user.emailVerified) {
|
const [res] = await db
|
||||||
|
.select()
|
||||||
|
.from(sessions)
|
||||||
|
.leftJoin(users, eq(users.userId, sessions.userId))
|
||||||
|
.where(eq(sessions.sessionId, userSessionId));
|
||||||
|
|
||||||
|
const user = res.user;
|
||||||
|
const session = res.session;
|
||||||
|
|
||||||
|
if (!user || !session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
config.getRawConfig().flags?.require_email_verification &&
|
||||||
|
!user.emailVerified
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,3 +473,147 @@ async function isUserAllowedToAccessResource(
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkRules(
|
||||||
|
resourceId: number,
|
||||||
|
clientIp: string | undefined,
|
||||||
|
path: string | undefined
|
||||||
|
): Promise<"ACCEPT" | "DROP" | undefined> {
|
||||||
|
const ruleCacheKey = `rules:${resourceId}`;
|
||||||
|
|
||||||
|
let rules: ResourceRule[] | undefined = cache.get(ruleCacheKey);
|
||||||
|
|
||||||
|
if (!rules) {
|
||||||
|
rules = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceRules)
|
||||||
|
.where(eq(resourceRules.resourceId, resourceId));
|
||||||
|
|
||||||
|
cache.set(ruleCacheKey, rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.length === 0) {
|
||||||
|
logger.debug("No rules found for resource", resourceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort rules by priority in ascending order
|
||||||
|
rules = rules.sort((a, b) => a.priority - b.priority);
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (!rule.enabled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
clientIp &&
|
||||||
|
rule.match == "CIDR" &&
|
||||||
|
isIpInCidr(clientIp, rule.value)
|
||||||
|
) {
|
||||||
|
return rule.action as any;
|
||||||
|
} else if (clientIp && rule.match == "IP" && clientIp == rule.value) {
|
||||||
|
return rule.action as any;
|
||||||
|
} else if (
|
||||||
|
path &&
|
||||||
|
rule.match == "PATH" &&
|
||||||
|
isPathAllowed(rule.value, path)
|
||||||
|
) {
|
||||||
|
return rule.action as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathAllowed(pattern: string, path: string): boolean {
|
||||||
|
logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`);
|
||||||
|
|
||||||
|
// Normalize and split paths into segments
|
||||||
|
const normalize = (p: string) => p.split("/").filter(Boolean);
|
||||||
|
const patternParts = normalize(pattern);
|
||||||
|
const pathParts = normalize(path);
|
||||||
|
|
||||||
|
logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`);
|
||||||
|
logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`);
|
||||||
|
|
||||||
|
// Recursive function to try different wildcard matches
|
||||||
|
function matchSegments(patternIndex: number, pathIndex: number): boolean {
|
||||||
|
const indent = " ".repeat(pathIndex); // Indent based on recursion depth
|
||||||
|
const currentPatternPart = patternParts[patternIndex];
|
||||||
|
const currentPathPart = pathParts[pathIndex];
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Checking patternIndex=${patternIndex} (${currentPatternPart || "END"}) vs pathIndex=${pathIndex} (${currentPathPart || "END"})`
|
||||||
|
);
|
||||||
|
|
||||||
|
// If we've consumed all pattern parts, we should have consumed all path parts
|
||||||
|
if (patternIndex >= patternParts.length) {
|
||||||
|
const result = pathIndex >= pathParts.length;
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Reached end of pattern, remaining path: ${pathParts.slice(pathIndex).join("/")} -> ${result}`
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we've consumed all path parts but still have pattern parts
|
||||||
|
if (pathIndex >= pathParts.length) {
|
||||||
|
// The only way this can match is if all remaining pattern parts are wildcards
|
||||||
|
const remainingPattern = patternParts.slice(patternIndex);
|
||||||
|
const result = remainingPattern.every((p) => p === "*");
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Reached end of path, remaining pattern: ${remainingPattern.join("/")} -> ${result}`
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For wildcards, try consuming different numbers of path segments
|
||||||
|
if (currentPatternPart === "*") {
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Found wildcard at pattern index ${patternIndex}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try consuming 0 segments (skip the wildcard)
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Trying to skip wildcard (consume 0 segments)`
|
||||||
|
);
|
||||||
|
if (matchSegments(patternIndex + 1, pathIndex)) {
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Successfully matched by skipping wildcard`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try consuming current segment and recursively try rest
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Trying to consume segment "${currentPathPart}" for wildcard`
|
||||||
|
);
|
||||||
|
if (matchSegments(patternIndex, pathIndex + 1)) {
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Successfully matched by consuming segment for wildcard`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`${indent}Failed to match wildcard`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For regular segments, they must match exactly
|
||||||
|
if (currentPatternPart !== currentPathPart) {
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Segment mismatch: "${currentPatternPart}" != "${currentPathPart}"`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Segments match: "${currentPatternPart}" = "${currentPathPart}"`
|
||||||
|
);
|
||||||
|
// Move to next segments in both pattern and path
|
||||||
|
return matchSegments(patternIndex + 1, pathIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = matchSegments(0, 0);
|
||||||
|
logger.debug(`Final result: ${result}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
|
|||||||
import { ActionsEnum } from "@server/auth/actions";
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
import { verifyUserIsOrgOwner } from "../middlewares/verifyUserIsOrgOwner";
|
import { verifyUserIsOrgOwner } from "../middlewares/verifyUserIsOrgOwner";
|
||||||
import { createNewt, getToken } from "./newt";
|
import { createNewt, getToken } from "./newt";
|
||||||
|
import rateLimit from "express-rate-limit";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
|
||||||
// Root routes
|
// Root routes
|
||||||
export const unauthenticated = Router();
|
export const unauthenticated = Router();
|
||||||
@@ -184,6 +186,32 @@ authenticated.get(
|
|||||||
verifyUserHasAction(ActionsEnum.listTargets),
|
verifyUserHasAction(ActionsEnum.listTargets),
|
||||||
target.listTargets
|
target.listTargets
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/resource/:resourceId/rule",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.createResourceRule),
|
||||||
|
resource.createResourceRule
|
||||||
|
);
|
||||||
|
authenticated.get(
|
||||||
|
"/resource/:resourceId/rules",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listResourceRules),
|
||||||
|
resource.listResourceRules
|
||||||
|
);
|
||||||
|
authenticated.post(
|
||||||
|
"/resource/:resourceId/rule/:ruleId",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.updateResourceRule),
|
||||||
|
resource.updateResourceRule
|
||||||
|
);
|
||||||
|
authenticated.delete(
|
||||||
|
"/resource/:resourceId/rule/:ruleId",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.deleteResourceRule),
|
||||||
|
resource.deleteResourceRule
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/target/:targetId",
|
"/target/:targetId",
|
||||||
verifyTargetAccess,
|
verifyTargetAccess,
|
||||||
@@ -203,6 +231,7 @@ authenticated.delete(
|
|||||||
target.deleteTarget
|
target.deleteTarget
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/role",
|
"/org/:orgId/role",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -308,6 +337,13 @@ authenticated.get(
|
|||||||
resource.getResourceWhitelist
|
resource.getResourceWhitelist
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
`/resource/:resourceId/transfer`,
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.updateResource),
|
||||||
|
resource.transferResource
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
`/resource/:resourceId/access-token`,
|
`/resource/:resourceId/access-token`,
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
@@ -445,22 +481,61 @@ authRouter.post(
|
|||||||
);
|
);
|
||||||
authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa);
|
authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa);
|
||||||
authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail);
|
authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail);
|
||||||
|
|
||||||
authRouter.post(
|
authRouter.post(
|
||||||
"/verify-email/request",
|
"/verify-email/request",
|
||||||
verifySessionMiddleware,
|
verifySessionMiddleware,
|
||||||
|
rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 3,
|
||||||
|
keyGenerator: (req) => `requestEmailVerificationCode:${req.body.email}`,
|
||||||
|
handler: (req, res, next) => {
|
||||||
|
const message = `You can only request an email verification code ${3} times every ${15} minutes. Please try again later.`;
|
||||||
|
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||||
|
}
|
||||||
|
}),
|
||||||
auth.requestEmailVerificationCode
|
auth.requestEmailVerificationCode
|
||||||
);
|
);
|
||||||
|
|
||||||
// authRouter.post(
|
// authRouter.post(
|
||||||
// "/change-password",
|
// "/change-password",
|
||||||
// verifySessionUserMiddleware,
|
// verifySessionUserMiddleware,
|
||||||
// auth.changePassword
|
// auth.changePassword
|
||||||
// );
|
// );
|
||||||
authRouter.post("/reset-password/request", auth.requestPasswordReset);
|
|
||||||
|
authRouter.post(
|
||||||
|
"/reset-password/request",
|
||||||
|
rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 3,
|
||||||
|
keyGenerator: (req) => `requestPasswordReset:${req.body.email}`,
|
||||||
|
handler: (req, res, next) => {
|
||||||
|
const message = `You can only request a password reset ${3} times every ${15} minutes. Please try again later.`;
|
||||||
|
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
auth.requestPasswordReset
|
||||||
|
);
|
||||||
|
|
||||||
authRouter.post("/reset-password/", auth.resetPassword);
|
authRouter.post("/reset-password/", auth.resetPassword);
|
||||||
|
|
||||||
authRouter.post("/resource/:resourceId/password", resource.authWithPassword);
|
authRouter.post("/resource/:resourceId/password", resource.authWithPassword);
|
||||||
authRouter.post("/resource/:resourceId/pincode", resource.authWithPincode);
|
authRouter.post("/resource/:resourceId/pincode", resource.authWithPincode);
|
||||||
authRouter.post("/resource/:resourceId/whitelist", resource.authWithWhitelist);
|
|
||||||
|
authRouter.post(
|
||||||
|
"/resource/:resourceId/whitelist",
|
||||||
|
rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 10,
|
||||||
|
keyGenerator: (req) => `authWithWhitelist:${req.body.email}`,
|
||||||
|
handler: (req, res, next) => {
|
||||||
|
const message = `You can only request an email OTP ${10} times every ${15} minutes. Please try again later.`;
|
||||||
|
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
resource.authWithWhitelist
|
||||||
|
);
|
||||||
|
|
||||||
authRouter.post(
|
authRouter.post(
|
||||||
"/resource/:resourceId/access-token",
|
"/resource/:resourceId/access-token",
|
||||||
resource.authWithAccessToken
|
resource.authWithAccessToken
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import config from "@server/lib/config";
|
|||||||
import { getUniqueExitNodeEndpointName } from '@server/db/names';
|
import { getUniqueExitNodeEndpointName } from '@server/db/names';
|
||||||
import { findNextAvailableCidr } from "@server/lib/ip";
|
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||||
import { fromError } from 'zod-validation-error';
|
import { fromError } from 'zod-validation-error';
|
||||||
|
import { getAllowedIps } from '../target/helpers';
|
||||||
// Define Zod schema for request validation
|
// Define Zod schema for request validation
|
||||||
const getConfigSchema = z.object({
|
const getConfigSchema = z.object({
|
||||||
publicKey: z.string(),
|
publicKey: z.string(),
|
||||||
@@ -50,7 +51,9 @@ export async function getConfig(req: Request, res: Response, next: NextFunction)
|
|||||||
let exitNode;
|
let exitNode;
|
||||||
if (exitNodeQuery.length === 0) {
|
if (exitNodeQuery.length === 0) {
|
||||||
const address = await getNextAvailableSubnet();
|
const address = await getNextAvailableSubnet();
|
||||||
const listenPort = await getNextAvailablePort();
|
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
||||||
|
// const listenPort = await getNextAvailablePort();
|
||||||
|
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||||
let subEndpoint = "";
|
let subEndpoint = "";
|
||||||
if (config.getRawConfig().gerbil.use_subdomain) {
|
if (config.getRawConfig().gerbil.use_subdomain) {
|
||||||
subEndpoint = await getUniqueExitNodeEndpointName();
|
subEndpoint = await getUniqueExitNodeEndpointName();
|
||||||
@@ -81,22 +84,9 @@ export async function getConfig(req: Request, res: Response, next: NextFunction)
|
|||||||
});
|
});
|
||||||
|
|
||||||
const peers = await Promise.all(sitesRes.map(async (site) => {
|
const peers = await Promise.all(sitesRes.map(async (site) => {
|
||||||
// Fetch resources for this site
|
|
||||||
const resourcesRes = await db.query.resources.findMany({
|
|
||||||
where: eq(resources.siteId, site.siteId),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch targets for all resources of this site
|
|
||||||
const targetIps = await Promise.all(resourcesRes.map(async (resource) => {
|
|
||||||
const targetsRes = await db.query.targets.findMany({
|
|
||||||
where: eq(targets.resourceId, resource.resourceId),
|
|
||||||
});
|
|
||||||
return targetsRes.map(target => `${target.ip}/32`);
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
publicKey: site.pubKey,
|
publicKey: site.pubKey,
|
||||||
allowedIps: targetIps.flat(),
|
allowedIps: await getAllowedIps(site.siteId)
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import * as gerbil from "@server/routers/gerbil";
|
import * as gerbil from "@server/routers/gerbil";
|
||||||
import * as badger from "@server/routers/badger";
|
|
||||||
import * as traefik from "@server/routers/traefik";
|
import * as traefik from "@server/routers/traefik";
|
||||||
|
import * as resource from "./resource";
|
||||||
|
import * as badger from "./badger";
|
||||||
import * as auth from "@server/routers/auth";
|
import * as auth from "@server/routers/auth";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { verifyResourceAccess, verifySessionUserMiddleware } from "@server/middlewares";
|
||||||
|
|
||||||
// Root routes
|
// Root routes
|
||||||
const internalRouter = Router();
|
const internalRouter = Router();
|
||||||
@@ -13,9 +15,17 @@ internalRouter.get("/", (_, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
internalRouter.get("/traefik-config", traefik.traefikConfigProvider);
|
internalRouter.get("/traefik-config", traefik.traefikConfigProvider);
|
||||||
|
|
||||||
internalRouter.get(
|
internalRouter.get(
|
||||||
"/resource-session/:resourceId/:token",
|
"/resource-session/:resourceId/:token",
|
||||||
auth.checkResourceSession,
|
auth.checkResourceSession
|
||||||
|
);
|
||||||
|
|
||||||
|
internalRouter.post(
|
||||||
|
`/resource/:resourceId/get-exchange-token`,
|
||||||
|
verifySessionUserMiddleware,
|
||||||
|
verifyResourceAccess,
|
||||||
|
resource.getExchangeToken
|
||||||
);
|
);
|
||||||
|
|
||||||
// Gerbil routes
|
// Gerbil routes
|
||||||
@@ -30,5 +40,6 @@ const badgerRouter = Router();
|
|||||||
internalRouter.use("/badger", badgerRouter);
|
internalRouter.use("/badger", badgerRouter);
|
||||||
|
|
||||||
badgerRouter.post("/verify-session", badger.verifyResourceSession);
|
badgerRouter.post("/verify-session", badger.verifyResourceSession);
|
||||||
|
badgerRouter.post("/exchange-session", badger.exchangeSession);
|
||||||
|
|
||||||
export default internalRouter;
|
export default internalRouter;
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import {
|
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||||
generateSessionToken,
|
|
||||||
} from "@server/auth/sessions/app";
|
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { newts } from "@server/db/schema";
|
import { newts } from "@server/db/schema";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -10,8 +8,13 @@ import { NextFunction, Request, Response } from "express";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { createNewtSession, validateNewtSessionToken } from "@server/auth/sessions/newt";
|
import {
|
||||||
|
createNewtSession,
|
||||||
|
validateNewtSessionToken
|
||||||
|
} from "@server/auth/sessions/newt";
|
||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export const newtGetTokenBodySchema = z.object({
|
export const newtGetTokenBodySchema = z.object({
|
||||||
newtId: z.string(),
|
newtId: z.string(),
|
||||||
@@ -43,6 +46,11 @@ export async function getToken(
|
|||||||
if (token) {
|
if (token) {
|
||||||
const { session, newt } = await validateNewtSessionToken(token);
|
const { session, newt } = await validateNewtSessionToken(token);
|
||||||
if (session) {
|
if (session) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Newt session already valid. Newt ID: ${newtId}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return response<null>(res, {
|
return response<null>(res, {
|
||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
@@ -73,6 +81,11 @@ export async function getToken(
|
|||||||
existingNewt.secretHash
|
existingNewt.secretHash
|
||||||
);
|
);
|
||||||
if (!validSecret) {
|
if (!validSecret) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Newt id or secret is incorrect. Newt: ID ${newtId}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect")
|
createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { MessageHandler } from "../ws";
|
import { MessageHandler } from "../ws";
|
||||||
import { exitNodes, resources, sites, targets } from "@server/db/schema";
|
import {
|
||||||
import { eq, inArray } from "drizzle-orm";
|
exitNodes,
|
||||||
|
resources,
|
||||||
|
sites,
|
||||||
|
Target,
|
||||||
|
targets
|
||||||
|
} from "@server/db/schema";
|
||||||
|
import { eq, and, sql } from "drizzle-orm";
|
||||||
import { addPeer, deletePeer } from "../gerbil/peers";
|
import { addPeer, deletePeer } from "../gerbil/peers";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
@@ -69,37 +75,68 @@ export const handleRegisterMessage: MessageHandler = async (context) => {
|
|||||||
allowedIps: [site.subnet]
|
allowedIps: [site.subnet]
|
||||||
});
|
});
|
||||||
|
|
||||||
const siteResources = await db
|
const allResources = await db
|
||||||
.select()
|
.select({
|
||||||
|
// Resource fields
|
||||||
|
resourceId: resources.resourceId,
|
||||||
|
subdomain: resources.subdomain,
|
||||||
|
fullDomain: resources.fullDomain,
|
||||||
|
ssl: resources.ssl,
|
||||||
|
blockAccess: resources.blockAccess,
|
||||||
|
sso: resources.sso,
|
||||||
|
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
||||||
|
http: resources.http,
|
||||||
|
proxyPort: resources.proxyPort,
|
||||||
|
protocol: resources.protocol,
|
||||||
|
// Targets as a subquery
|
||||||
|
targets: sql<string>`json_group_array(json_object(
|
||||||
|
'targetId', ${targets.targetId},
|
||||||
|
'ip', ${targets.ip},
|
||||||
|
'method', ${targets.method},
|
||||||
|
'port', ${targets.port},
|
||||||
|
'internalPort', ${targets.internalPort},
|
||||||
|
'enabled', ${targets.enabled}
|
||||||
|
))`.as("targets")
|
||||||
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.where(eq(resources.siteId, siteId));
|
.leftJoin(
|
||||||
|
targets,
|
||||||
// get the targets from the resourceIds
|
and(
|
||||||
const siteTargets = await db
|
eq(targets.resourceId, resources.resourceId),
|
||||||
.select()
|
eq(targets.enabled, true)
|
||||||
.from(targets)
|
|
||||||
.where(
|
|
||||||
inArray(
|
|
||||||
targets.resourceId,
|
|
||||||
siteResources.map((resource) => resource.resourceId)
|
|
||||||
)
|
)
|
||||||
);
|
)
|
||||||
|
.where(eq(resources.siteId, siteId))
|
||||||
|
.groupBy(resources.resourceId);
|
||||||
|
|
||||||
const udpTargets = siteTargets
|
let tcpTargets: string[] = [];
|
||||||
.filter((target) => target.protocol === "udp")
|
let udpTargets: string[] = [];
|
||||||
.map((target) => {
|
|
||||||
return `${target.internalPort ? target.internalPort + ":" : ""}${
|
|
||||||
target.ip
|
|
||||||
}:${target.port}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const tcpTargets = siteTargets
|
for (const resource of allResources) {
|
||||||
.filter((target) => target.protocol === "tcp")
|
const targets = JSON.parse(resource.targets);
|
||||||
.map((target) => {
|
if (!targets || targets.length === 0) {
|
||||||
return `${target.internalPort ? target.internalPort + ":" : ""}${
|
continue;
|
||||||
target.ip
|
}
|
||||||
}:${target.port}`;
|
if (resource.protocol === "tcp") {
|
||||||
});
|
tcpTargets = tcpTargets.concat(
|
||||||
|
targets.map(
|
||||||
|
(target: Target) =>
|
||||||
|
`${
|
||||||
|
target.internalPort ? target.internalPort + ":" : ""
|
||||||
|
}${target.ip}:${target.port}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
udpTargets = tcpTargets.concat(
|
||||||
|
targets.map(
|
||||||
|
(target: Target) =>
|
||||||
|
`${
|
||||||
|
target.internalPort ? target.internalPort + ":" : ""
|
||||||
|
}${target.ip}:${target.port}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
|
|||||||
@@ -1,73 +1,44 @@
|
|||||||
import { Target } from "@server/db/schema";
|
import { Target } from "@server/db/schema";
|
||||||
import { sendToClient } from "../ws";
|
import { sendToClient } from "../ws";
|
||||||
|
|
||||||
export async function addTargets(newtId: string, targets: Target[]): Promise<void> {
|
export function addTargets(
|
||||||
|
newtId: string,
|
||||||
|
targets: Target[],
|
||||||
|
protocol: string
|
||||||
|
) {
|
||||||
//create a list of udp and tcp targets
|
//create a list of udp and tcp targets
|
||||||
const udpTargets = targets
|
const payloadTargets = targets.map((target) => {
|
||||||
.filter((target) => target.protocol === "udp")
|
return `${target.internalPort ? target.internalPort + ":" : ""}${
|
||||||
.map((target) => {
|
target.ip
|
||||||
return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
|
}:${target.port}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const tcpTargets = targets
|
const payload = {
|
||||||
.filter((target) => target.protocol === "tcp")
|
type: `newt/${protocol}/add`,
|
||||||
.map((target) => {
|
data: {
|
||||||
return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
|
targets: payloadTargets
|
||||||
});
|
}
|
||||||
|
};
|
||||||
if (udpTargets.length > 0) {
|
sendToClient(newtId, payload);
|
||||||
const payload = {
|
|
||||||
type: `newt/udp/add`,
|
|
||||||
data: {
|
|
||||||
targets: udpTargets,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
sendToClient(newtId, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tcpTargets.length > 0) {
|
|
||||||
const payload = {
|
|
||||||
type: `newt/tcp/add`,
|
|
||||||
data: {
|
|
||||||
targets: tcpTargets,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
sendToClient(newtId, payload);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function removeTargets(
|
||||||
export async function removeTargets(newtId: string, targets: Target[]): Promise<void> {
|
newtId: string,
|
||||||
|
targets: Target[],
|
||||||
|
protocol: string
|
||||||
|
) {
|
||||||
//create a list of udp and tcp targets
|
//create a list of udp and tcp targets
|
||||||
const udpTargets = targets
|
const payloadTargets = targets.map((target) => {
|
||||||
.filter((target) => target.protocol === "udp")
|
return `${target.internalPort ? target.internalPort + ":" : ""}${
|
||||||
.map((target) => {
|
target.ip
|
||||||
return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
|
}:${target.port}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const tcpTargets = targets
|
const payload = {
|
||||||
.filter((target) => target.protocol === "tcp")
|
type: `newt/${protocol}/remove`,
|
||||||
.map((target) => {
|
data: {
|
||||||
return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
|
targets: payloadTargets
|
||||||
});
|
}
|
||||||
|
};
|
||||||
if (udpTargets.length > 0) {
|
sendToClient(newtId, payload);
|
||||||
const payload = {
|
|
||||||
type: `newt/udp/remove`,
|
|
||||||
data: {
|
|
||||||
targets: udpTargets,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
sendToClient(newtId, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tcpTargets.length > 0) {
|
|
||||||
const payload = {
|
|
||||||
type: `newt/tcp/remove`,
|
|
||||||
data: {
|
|
||||||
targets: tcpTargets,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
sendToClient(newtId, payload);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { resourceAccessToken, resources } from "@server/db/schema";
|
import { resources } from "@server/db/schema";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import {
|
import { createResourceSession } from "@server/auth/sessions/resource";
|
||||||
createResourceSession,
|
|
||||||
serializeResourceSessionCookie
|
|
||||||
} from "@server/auth/sessions/resource";
|
|
||||||
import config from "@server/lib/config";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { verify } from "@node-rs/argon2";
|
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||||
import { isWithinExpirationDate } from "oslo";
|
import config from "@server/lib/config";
|
||||||
import { verifyPassword } from "@server/auth/password";
|
|
||||||
|
|
||||||
const authWithAccessTokenBodySchema = z
|
const authWithAccessTokenBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -69,58 +64,43 @@ export async function authWithAccessToken(
|
|||||||
const { accessToken, accessTokenId } = parsedBody.data;
|
const { accessToken, accessTokenId } = parsedBody.data;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [result] = await db
|
const [resource] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resourceAccessToken)
|
.from(resources)
|
||||||
.where(
|
.where(eq(resources.resourceId, resourceId))
|
||||||
and(
|
|
||||||
eq(resourceAccessToken.resourceId, resourceId),
|
|
||||||
eq(resourceAccessToken.accessTokenId, accessTokenId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
resources,
|
|
||||||
eq(resources.resourceId, resourceAccessToken.resourceId)
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const resource = result?.resources;
|
|
||||||
const tokenItem = result?.resourceAccessToken;
|
|
||||||
|
|
||||||
if (!tokenItem) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.UNAUTHORIZED,
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Access token does not exist for resource"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validCode = await verifyPassword(accessToken, tokenItem.tokenHash);
|
const { valid, error, tokenItem } = await verifyResourceAccessToken({
|
||||||
|
resource,
|
||||||
|
accessTokenId,
|
||||||
|
accessToken
|
||||||
|
});
|
||||||
|
|
||||||
if (!validCode) {
|
if (!valid) {
|
||||||
return next(
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token")
|
logger.info(
|
||||||
);
|
`Resource access token invalid. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
|
||||||
}
|
);
|
||||||
|
}
|
||||||
if (
|
|
||||||
tokenItem.expiresAt &&
|
|
||||||
!isWithinExpirationDate(new Date(tokenItem.expiresAt))
|
|
||||||
) {
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.UNAUTHORIZED,
|
HttpCode.UNAUTHORIZED,
|
||||||
"Access token has expired"
|
error || "Invalid access token"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tokenItem || !resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.UNAUTHORIZED,
|
||||||
|
"Access token does not exist for resource"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -130,13 +110,11 @@ export async function authWithAccessToken(
|
|||||||
resourceId,
|
resourceId,
|
||||||
token,
|
token,
|
||||||
accessTokenId: tokenItem.accessTokenId,
|
accessTokenId: tokenItem.accessTokenId,
|
||||||
sessionLength: tokenItem.sessionLength,
|
isRequestToken: true,
|
||||||
expiresAt: tokenItem.expiresAt,
|
expiresAt: Date.now() + 1000 * 30, // 30 seconds
|
||||||
doNotExtend: tokenItem.expiresAt ? true : false
|
sessionLength: 1000 * 30,
|
||||||
|
doNotExtend: true
|
||||||
});
|
});
|
||||||
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
|
|
||||||
const cookie = serializeResourceSessionCookie(cookieName, token);
|
|
||||||
res.appendHeader("Set-Cookie", cookie);
|
|
||||||
|
|
||||||
return response<AuthWithAccessTokenResponse>(res, {
|
return response<AuthWithAccessTokenResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -9,13 +9,10 @@ import { NextFunction, Request, Response } from "express";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import {
|
import { createResourceSession } from "@server/auth/sessions/resource";
|
||||||
createResourceSession,
|
|
||||||
serializeResourceSessionCookie
|
|
||||||
} from "@server/auth/sessions/resource";
|
|
||||||
import config from "@server/lib/config";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export const authWithPasswordBodySchema = z
|
export const authWithPasswordBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -84,7 +81,7 @@ export async function authWithPassword(
|
|||||||
|
|
||||||
if (!org) {
|
if (!org) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
|
createHttpError(HttpCode.BAD_REQUEST, "Org does not exist")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +108,11 @@ export async function authWithPassword(
|
|||||||
definedPassword.passwordHash
|
definedPassword.passwordHash
|
||||||
);
|
);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Resource password incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password")
|
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password")
|
||||||
);
|
);
|
||||||
@@ -120,11 +122,12 @@ export async function authWithPassword(
|
|||||||
await createResourceSession({
|
await createResourceSession({
|
||||||
resourceId,
|
resourceId,
|
||||||
token,
|
token,
|
||||||
passwordId: definedPassword.passwordId
|
passwordId: definedPassword.passwordId,
|
||||||
|
isRequestToken: true,
|
||||||
|
expiresAt: Date.now() + 1000 * 30, // 30 seconds
|
||||||
|
sessionLength: 1000 * 30,
|
||||||
|
doNotExtend: true
|
||||||
});
|
});
|
||||||
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
|
|
||||||
const cookie = serializeResourceSessionCookie(cookieName, token);
|
|
||||||
res.appendHeader("Set-Cookie", cookie);
|
|
||||||
|
|
||||||
return response<AuthWithPasswordResponse>(res, {
|
return response<AuthWithPasswordResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,29 +1,17 @@
|
|||||||
import { verify } from "@node-rs/argon2";
|
|
||||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import {
|
import { orgs, resourcePincode, resources } from "@server/db/schema";
|
||||||
orgs,
|
|
||||||
resourceOtp,
|
|
||||||
resourcePincode,
|
|
||||||
resources,
|
|
||||||
resourceWhitelist
|
|
||||||
} from "@server/db/schema";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import {
|
import { createResourceSession } from "@server/auth/sessions/resource";
|
||||||
createResourceSession,
|
|
||||||
serializeResourceSessionCookie
|
|
||||||
} from "@server/auth/sessions/resource";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
|
||||||
import { AuthWithPasswordResponse } from "./authWithPassword";
|
|
||||||
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
|
|
||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export const authWithPincodeBodySchema = z
|
export const authWithPincodeBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -109,19 +97,21 @@ export async function authWithPincode(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.UNAUTHORIZED,
|
HttpCode.UNAUTHORIZED,
|
||||||
createHttpError(
|
"Resource has no pincode protection"
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Resource has no pincode protection"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validPincode = verifyPassword(
|
const validPincode = await verifyPassword(
|
||||||
pincode,
|
pincode,
|
||||||
definedPincode.pincodeHash
|
definedPincode.pincodeHash
|
||||||
);
|
);
|
||||||
if (!validPincode) {
|
if (!validPincode) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Resource pin code incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN")
|
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN")
|
||||||
);
|
);
|
||||||
@@ -131,11 +121,12 @@ export async function authWithPincode(
|
|||||||
await createResourceSession({
|
await createResourceSession({
|
||||||
resourceId,
|
resourceId,
|
||||||
token,
|
token,
|
||||||
pincodeId: definedPincode.pincodeId
|
pincodeId: definedPincode.pincodeId,
|
||||||
|
isRequestToken: true,
|
||||||
|
expiresAt: Date.now() + 1000 * 30, // 30 seconds
|
||||||
|
sessionLength: 1000 * 30,
|
||||||
|
doNotExtend: true
|
||||||
});
|
});
|
||||||
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
|
|
||||||
const cookie = serializeResourceSessionCookie(cookieName, token);
|
|
||||||
res.appendHeader("Set-Cookie", cookie);
|
|
||||||
|
|
||||||
return response<AuthWithPincodeResponse>(res, {
|
return response<AuthWithPincodeResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import db from "@server/db";
|
|||||||
import {
|
import {
|
||||||
orgs,
|
orgs,
|
||||||
resourceOtp,
|
resourceOtp,
|
||||||
resourcePassword,
|
|
||||||
resources,
|
resources,
|
||||||
resourceWhitelist
|
resourceWhitelist
|
||||||
} from "@server/db/schema";
|
} from "@server/db/schema";
|
||||||
@@ -14,17 +13,17 @@ import { NextFunction, Request, Response } from "express";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import {
|
import { createResourceSession } from "@server/auth/sessions/resource";
|
||||||
createResourceSession,
|
|
||||||
serializeResourceSessionCookie
|
|
||||||
} from "@server/auth/sessions/resource";
|
|
||||||
import config from "@server/lib/config";
|
|
||||||
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
|
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
const authWithWhitelistBodySchema = z
|
const authWithWhitelistBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
email: z.string().email(),
|
email: z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.transform((v) => v.toLowerCase()),
|
||||||
otp: z.string().optional()
|
otp: z.string().optional()
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
@@ -90,20 +89,53 @@ export async function authWithWhitelist(
|
|||||||
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
|
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const resource = result?.resources;
|
let resource = result?.resources;
|
||||||
const org = result?.orgs;
|
let org = result?.orgs;
|
||||||
const whitelistedEmail = result?.resourceWhitelist;
|
let whitelistedEmail = result?.resourceWhitelist;
|
||||||
|
|
||||||
if (!whitelistedEmail) {
|
if (!whitelistedEmail) {
|
||||||
return next(
|
// if email is not found, check for wildcard email
|
||||||
createHttpError(
|
const wildcard = "*@" + email.split("@")[1];
|
||||||
HttpCode.UNAUTHORIZED,
|
|
||||||
createHttpError(
|
logger.debug("Checking for wildcard email: " + wildcard);
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Email is not whitelisted"
|
const [result] = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceWhitelist)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceWhitelist.resourceId, resourceId),
|
||||||
|
eq(resourceWhitelist.email, wildcard)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
.leftJoin(
|
||||||
|
resources,
|
||||||
|
eq(resources.resourceId, resourceWhitelist.resourceId)
|
||||||
|
)
|
||||||
|
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
resource = result?.resources;
|
||||||
|
org = result?.orgs;
|
||||||
|
whitelistedEmail = result?.resourceWhitelist;
|
||||||
|
|
||||||
|
// if wildcard is still not found, return unauthorized
|
||||||
|
if (!whitelistedEmail) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Email is not whitelisted. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.UNAUTHORIZED,
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Email is not whitelisted"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!org) {
|
if (!org) {
|
||||||
@@ -125,6 +157,11 @@ export async function authWithWhitelist(
|
|||||||
otp
|
otp
|
||||||
);
|
);
|
||||||
if (!isValidCode) {
|
if (!isValidCode) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Resource email otp incorrect. Resource ID: ${resource.resourceId}. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect OTP")
|
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect OTP")
|
||||||
);
|
);
|
||||||
@@ -175,11 +212,12 @@ export async function authWithWhitelist(
|
|||||||
await createResourceSession({
|
await createResourceSession({
|
||||||
resourceId,
|
resourceId,
|
||||||
token,
|
token,
|
||||||
whitelistId: whitelistedEmail.whitelistId
|
whitelistId: whitelistedEmail.whitelistId,
|
||||||
|
isRequestToken: true,
|
||||||
|
expiresAt: Date.now() + 1000 * 30, // 30 seconds
|
||||||
|
sessionLength: 1000 * 30,
|
||||||
|
doNotExtend: true
|
||||||
});
|
});
|
||||||
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
|
|
||||||
const cookie = serializeResourceSessionCookie(cookieName, token);
|
|
||||||
res.appendHeader("Set-Cookie", cookie);
|
|
||||||
|
|
||||||
return response<AuthWithWhitelistResponse>(res, {
|
return response<AuthWithWhitelistResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ import createHttpError from "http-errors";
|
|||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import stoi from "@server/lib/stoi";
|
import stoi from "@server/lib/stoi";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { subdomainSchema } from "@server/lib/schemas";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
const createResourceParamsSchema = z
|
const createResourceParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -28,10 +29,80 @@ const createResourceParamsSchema = z
|
|||||||
|
|
||||||
const createResourceSchema = z
|
const createResourceSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
subdomain: z.string().optional(),
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
subdomain: subdomainSchema
|
siteId: z.number(),
|
||||||
|
http: z.boolean(),
|
||||||
|
protocol: z.string(),
|
||||||
|
proxyPort: z.number().optional(),
|
||||||
|
isBaseDomain: z.boolean().optional()
|
||||||
})
|
})
|
||||||
.strict();
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (!data.http) {
|
||||||
|
return z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(65535)
|
||||||
|
.safeParse(data.proxyPort).success;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Invalid port number",
|
||||||
|
path: ["proxyPort"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.http && !data.isBaseDomain) {
|
||||||
|
return subdomainSchema.safeParse(data.subdomain).success;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Invalid subdomain",
|
||||||
|
path: ["subdomain"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (!config.getRawConfig().flags?.allow_raw_resources) {
|
||||||
|
if (data.proxyPort !== undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Proxy port cannot be set"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// .refine(
|
||||||
|
// (data) => {
|
||||||
|
// if (data.proxyPort === 443 || data.proxyPort === 80) {
|
||||||
|
// return false;
|
||||||
|
// }
|
||||||
|
// return true;
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// message: "Port 80 and 443 are reserved for http and https resources"
|
||||||
|
// }
|
||||||
|
// )
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
|
||||||
|
if (data.isBaseDomain) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Base domain resources are not allowed"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export type CreateResourceResponse = Resource;
|
export type CreateResourceResponse = Resource;
|
||||||
|
|
||||||
@@ -51,7 +122,7 @@ export async function createResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let { name, subdomain } = parsedBody.data;
|
let { name, subdomain, protocol, proxyPort, http, isBaseDomain } = parsedBody.data;
|
||||||
|
|
||||||
// Validate request params
|
// Validate request params
|
||||||
const parsedParams = createResourceParamsSchema.safeParse(req.params);
|
const parsedParams = createResourceParamsSchema.safeParse(req.params);
|
||||||
@@ -88,17 +159,64 @@ export async function createResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullDomain = `${subdomain}.${org[0].domain}`;
|
let fullDomain = "";
|
||||||
|
if (isBaseDomain) {
|
||||||
|
fullDomain = org[0].domain;
|
||||||
|
} else {
|
||||||
|
fullDomain = `${subdomain}.${org[0].domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if http is false check to see if there is already a resource with the same port and protocol
|
||||||
|
if (!http) {
|
||||||
|
const existingResource = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.protocol, protocol),
|
||||||
|
eq(resources.proxyPort, proxyPort!)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingResource.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Resource with that protocol and port already exists"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// make sure the full domain is unique
|
||||||
|
const existingResource = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.fullDomain, fullDomain));
|
||||||
|
|
||||||
|
if (existingResource.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Resource with that domain already exists"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const newResource = await trx
|
const newResource = await trx
|
||||||
.insert(resources)
|
.insert(resources)
|
||||||
.values({
|
.values({
|
||||||
siteId,
|
siteId,
|
||||||
fullDomain,
|
fullDomain: http ? fullDomain : null,
|
||||||
orgId,
|
orgId,
|
||||||
name,
|
name,
|
||||||
subdomain,
|
subdomain,
|
||||||
ssl: true
|
http,
|
||||||
|
protocol,
|
||||||
|
proxyPort,
|
||||||
|
ssl: true,
|
||||||
|
isBaseDomain
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -135,18 +253,6 @@ export async function createResource(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
|
||||||
error instanceof SqliteError &&
|
|
||||||
error.code === "SQLITE_CONSTRAINT_UNIQUE"
|
|
||||||
) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.CONFLICT,
|
|
||||||
"Resource with that subdomain already exists"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
|||||||
145
server/routers/resource/createResourceRule.ts
Normal file
145
server/routers/resource/createResourceRule.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { resourceRules, resources } from "@server/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import {
|
||||||
|
isValidCIDR,
|
||||||
|
isValidIP,
|
||||||
|
isValidUrlGlobPattern
|
||||||
|
} from "@server/lib/validators";
|
||||||
|
|
||||||
|
const createResourceRuleSchema = z
|
||||||
|
.object({
|
||||||
|
action: z.enum(["ACCEPT", "DROP"]),
|
||||||
|
match: z.enum(["CIDR", "IP", "PATH"]),
|
||||||
|
value: z.string().min(1),
|
||||||
|
priority: z.number().int(),
|
||||||
|
enabled: z.boolean().optional()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const createResourceRuleParamsSchema = z
|
||||||
|
.object({
|
||||||
|
resourceId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export async function createResourceRule(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedBody = createResourceRuleSchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { action, match, value, priority, enabled } = parsedBody.data;
|
||||||
|
|
||||||
|
const parsedParams = createResourceRuleParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
// Verify that the referenced resource exists
|
||||||
|
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.http) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Cannot create rule for non-http resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match === "CIDR") {
|
||||||
|
if (!isValidCIDR(value)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid CIDR provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (match === "IP") {
|
||||||
|
if (!isValidIP(value)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (match === "PATH") {
|
||||||
|
if (!isValidUrlGlobPattern(value)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid URL glob pattern provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the new resource rule
|
||||||
|
const [newRule] = await db
|
||||||
|
.insert(resourceRules)
|
||||||
|
.values({
|
||||||
|
resourceId,
|
||||||
|
action,
|
||||||
|
match,
|
||||||
|
value,
|
||||||
|
priority,
|
||||||
|
enabled
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: newRule,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource rule created successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { addPeer } from "../gerbil/peers";
|
import { addPeer } from "../gerbil/peers";
|
||||||
import { removeTargets } from "../newt/targets";
|
import { removeTargets } from "../newt/targets";
|
||||||
|
import { getAllowedIps } from "../target/helpers";
|
||||||
|
|
||||||
// Define Zod schema for request parameters validation
|
// Define Zod schema for request parameters validation
|
||||||
const deleteResourceSchema = z
|
const deleteResourceSchema = z
|
||||||
@@ -75,25 +76,9 @@ export async function deleteResource(
|
|||||||
|
|
||||||
if (site.pubKey) {
|
if (site.pubKey) {
|
||||||
if (site.type == "wireguard") {
|
if (site.type == "wireguard") {
|
||||||
// TODO: is this all inefficient?
|
|
||||||
// Fetch resources for this site
|
|
||||||
const resourcesRes = await db.query.resources.findMany({
|
|
||||||
where: eq(resources.siteId, site.siteId)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch targets for all resources of this site
|
|
||||||
const targetIps = await Promise.all(
|
|
||||||
resourcesRes.map(async (resource) => {
|
|
||||||
const targetsRes = await db.query.targets.findMany({
|
|
||||||
where: eq(targets.resourceId, resource.resourceId)
|
|
||||||
});
|
|
||||||
return targetsRes.map((target) => `${target.ip}/32`);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
await addPeer(site.exitNodeId!, {
|
await addPeer(site.exitNodeId!, {
|
||||||
publicKey: site.pubKey,
|
publicKey: site.pubKey,
|
||||||
allowedIps: targetIps.flat()
|
allowedIps: await getAllowedIps(site.siteId)
|
||||||
});
|
});
|
||||||
} else if (site.type == "newt") {
|
} else if (site.type == "newt") {
|
||||||
// get the newt on the site by querying the newt table for siteId
|
// get the newt on the site by querying the newt table for siteId
|
||||||
@@ -103,7 +88,7 @@ export async function deleteResource(
|
|||||||
.where(eq(newts.siteId, site.siteId))
|
.where(eq(newts.siteId, site.siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
removeTargets(newt.newtId, targetsToBeRemoved);
|
removeTargets(newt.newtId, targetsToBeRemoved, deletedResource.protocol);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
71
server/routers/resource/deleteResourceRule.ts
Normal file
71
server/routers/resource/deleteResourceRule.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { resourceRules, resources } from "@server/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const deleteResourceRuleSchema = z
|
||||||
|
.object({
|
||||||
|
ruleId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive()),
|
||||||
|
resourceId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export async function deleteResourceRule(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = deleteResourceRuleSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ruleId } = parsedParams.data;
|
||||||
|
|
||||||
|
// Delete the rule and return the deleted record
|
||||||
|
const [deletedRule] = await db
|
||||||
|
.delete(resourceRules)
|
||||||
|
.where(eq(resourceRules.ruleId, ruleId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!deletedRule) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource rule with ID ${ruleId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource rule deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
server/routers/resource/getExchangeToken.ts
Normal file
109
server/routers/resource/getExchangeToken.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { resources } from "@server/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { createResourceSession } from "@server/auth/sessions/resource";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import {
|
||||||
|
encodeHexLowerCase
|
||||||
|
} from "@oslojs/encoding";
|
||||||
|
import { sha256 } from "@oslojs/crypto/sha2";
|
||||||
|
import { response } from "@server/lib";
|
||||||
|
|
||||||
|
const getExchangeTokenParams = z
|
||||||
|
.object({
|
||||||
|
resourceId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type GetExchangeTokenResponse = {
|
||||||
|
requestToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getExchangeToken(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = getExchangeTokenParams.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
const resource = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (resource.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resourceId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssoSession =
|
||||||
|
req.cookies[config.getRawConfig().server.session_cookie_name];
|
||||||
|
if (!ssoSession) {
|
||||||
|
logger.debug(ssoSession);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.UNAUTHORIZED,
|
||||||
|
"Missing SSO session cookie"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = encodeHexLowerCase(
|
||||||
|
sha256(new TextEncoder().encode(ssoSession))
|
||||||
|
);
|
||||||
|
|
||||||
|
const token = generateSessionToken();
|
||||||
|
await createResourceSession({
|
||||||
|
resourceId,
|
||||||
|
token,
|
||||||
|
userSessionId: sessionId,
|
||||||
|
isRequestToken: true,
|
||||||
|
expiresAt: Date.now() + 1000 * 30, // 30 seconds
|
||||||
|
sessionLength: 1000 * 30,
|
||||||
|
doNotExtend: true
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug("Request token created successfully");
|
||||||
|
|
||||||
|
return response<GetExchangeTokenResponse>(res, {
|
||||||
|
data: {
|
||||||
|
requestToken: token
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Request token created successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,3 +16,9 @@ export * from "./setResourceWhitelist";
|
|||||||
export * from "./getResourceWhitelist";
|
export * from "./getResourceWhitelist";
|
||||||
export * from "./authWithWhitelist";
|
export * from "./authWithWhitelist";
|
||||||
export * from "./authWithAccessToken";
|
export * from "./authWithAccessToken";
|
||||||
|
export * from "./transferResource";
|
||||||
|
export * from "./getExchangeToken";
|
||||||
|
export * from "./createResourceRule";
|
||||||
|
export * from "./deleteResourceRule";
|
||||||
|
export * from "./listResourceRules";
|
||||||
|
export * from "./updateResourceRule";
|
||||||
139
server/routers/resource/listResourceRules.ts
Normal file
139
server/routers/resource/listResourceRules.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { db } from "@server/db";
|
||||||
|
import { resourceRules, resources } from "@server/db/schema";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
const listResourceRulesParamsSchema = z
|
||||||
|
.object({
|
||||||
|
resourceId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const listResourceRulesSchema = z.object({
|
||||||
|
limit: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("1000")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive()),
|
||||||
|
offset: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("0")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().nonnegative())
|
||||||
|
});
|
||||||
|
|
||||||
|
function queryResourceRules(resourceId: number) {
|
||||||
|
let baseQuery = db
|
||||||
|
.select({
|
||||||
|
ruleId: resourceRules.ruleId,
|
||||||
|
resourceId: resourceRules.resourceId,
|
||||||
|
action: resourceRules.action,
|
||||||
|
match: resourceRules.match,
|
||||||
|
value: resourceRules.value,
|
||||||
|
priority: resourceRules.priority,
|
||||||
|
enabled: resourceRules.enabled
|
||||||
|
})
|
||||||
|
.from(resourceRules)
|
||||||
|
.leftJoin(resources, eq(resourceRules.resourceId, resources.resourceId))
|
||||||
|
.where(eq(resourceRules.resourceId, resourceId));
|
||||||
|
|
||||||
|
return baseQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListResourceRulesResponse = {
|
||||||
|
rules: Awaited<ReturnType<typeof queryResourceRules>>;
|
||||||
|
pagination: { total: number; limit: number; offset: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listResourceRules(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedQuery = listResourceRulesSchema.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { limit, offset } = parsedQuery.data;
|
||||||
|
|
||||||
|
const parsedParams = listResourceRulesParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
// Verify the resource exists
|
||||||
|
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`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseQuery = queryResourceRules(resourceId);
|
||||||
|
|
||||||
|
let countQuery = db
|
||||||
|
.select({ count: sql<number>`cast(count(*) as integer)` })
|
||||||
|
.from(resourceRules)
|
||||||
|
.where(eq(resourceRules.resourceId, resourceId));
|
||||||
|
|
||||||
|
let rulesList = await baseQuery.limit(limit).offset(offset);
|
||||||
|
const totalCountResult = await countQuery;
|
||||||
|
const totalCount = totalCountResult[0].count;
|
||||||
|
|
||||||
|
// sort rules list by the priority in ascending order
|
||||||
|
rulesList = rulesList.sort((a, b) => a.priority - b.priority);
|
||||||
|
|
||||||
|
return response<ListResourceRulesResponse>(res, {
|
||||||
|
data: {
|
||||||
|
rules: rulesList,
|
||||||
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource rules retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,7 +63,10 @@ function queryResources(
|
|||||||
passwordId: resourcePassword.passwordId,
|
passwordId: resourcePassword.passwordId,
|
||||||
pincodeId: resourcePincode.pincodeId,
|
pincodeId: resourcePincode.pincodeId,
|
||||||
sso: resources.sso,
|
sso: resources.sso,
|
||||||
whitelist: resources.emailWhitelistEnabled
|
whitelist: resources.emailWhitelistEnabled,
|
||||||
|
http: resources.http,
|
||||||
|
protocol: resources.protocol,
|
||||||
|
proxyPort: resources.proxyPort
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
||||||
@@ -93,7 +96,10 @@ function queryResources(
|
|||||||
passwordId: resourcePassword.passwordId,
|
passwordId: resourcePassword.passwordId,
|
||||||
sso: resources.sso,
|
sso: resources.sso,
|
||||||
pincodeId: resourcePincode.pincodeId,
|
pincodeId: resourcePincode.pincodeId,
|
||||||
whitelist: resources.emailWhitelistEnabled
|
whitelist: resources.emailWhitelistEnabled,
|
||||||
|
http: resources.http,
|
||||||
|
protocol: resources.protocol,
|
||||||
|
proxyPort: resources.proxyPort
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
||||||
|
|||||||
@@ -11,7 +11,20 @@ import { and, eq } from "drizzle-orm";
|
|||||||
|
|
||||||
const setResourceWhitelistBodySchema = z
|
const setResourceWhitelistBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
emails: z.array(z.string().email()).max(50)
|
emails: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.or(
|
||||||
|
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
|
||||||
|
message:
|
||||||
|
"Invalid email address. Wildcard (*) must be the entire local part."
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.max(50)
|
||||||
|
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
192
server/routers/resource/transferResource.ts
Normal file
192
server/routers/resource/transferResource.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { newts, resources, sites, targets } from "@server/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { addPeer } from "../gerbil/peers";
|
||||||
|
import { addTargets, removeTargets } from "../newt/targets";
|
||||||
|
import { getAllowedIps } from "../target/helpers";
|
||||||
|
|
||||||
|
const transferResourceParamsSchema = z
|
||||||
|
.object({
|
||||||
|
resourceId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const transferResourceBodySchema = z
|
||||||
|
.object({
|
||||||
|
siteId: z.number().int().positive()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export async function transferResource(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = transferResourceParamsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = transferResourceBodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resourceId } = parsedParams.data;
|
||||||
|
const { siteId } = parsedBody.data;
|
||||||
|
|
||||||
|
const [oldResource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!oldResource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resourceId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldResource.siteId === siteId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
`Resource is already assigned to this site`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newSite] = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.siteId, siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!newSite) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Site with ID ${siteId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [oldSite] = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.siteId, oldResource.siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!oldSite) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Site with ID ${oldResource.siteId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updatedResource] = await db
|
||||||
|
.update(resources)
|
||||||
|
.set({ siteId })
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updatedResource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resourceId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceTargets = await db
|
||||||
|
.select()
|
||||||
|
.from(targets)
|
||||||
|
.where(eq(targets.resourceId, resourceId));
|
||||||
|
|
||||||
|
if (resourceTargets.length > 0) {
|
||||||
|
////// REMOVE THE TARGETS FROM THE OLD SITE //////
|
||||||
|
if (oldSite.pubKey) {
|
||||||
|
if (oldSite.type == "wireguard") {
|
||||||
|
await addPeer(oldSite.exitNodeId!, {
|
||||||
|
publicKey: oldSite.pubKey,
|
||||||
|
allowedIps: await getAllowedIps(oldSite.siteId)
|
||||||
|
});
|
||||||
|
} else if (oldSite.type == "newt") {
|
||||||
|
const [newt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, oldSite.siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
removeTargets(
|
||||||
|
newt.newtId,
|
||||||
|
resourceTargets,
|
||||||
|
updatedResource.protocol
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
////// ADD THE TARGETS TO THE NEW SITE //////
|
||||||
|
if (newSite.pubKey) {
|
||||||
|
if (newSite.type == "wireguard") {
|
||||||
|
await addPeer(newSite.exitNodeId!, {
|
||||||
|
publicKey: newSite.pubKey,
|
||||||
|
allowedIps: await getAllowedIps(newSite.siteId)
|
||||||
|
});
|
||||||
|
} else if (newSite.type == "newt") {
|
||||||
|
const [newt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, newSite.siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
addTargets(
|
||||||
|
newt.newtId,
|
||||||
|
resourceTargets,
|
||||||
|
updatedResource.protocol
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: updatedResource,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource transferred successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,14 @@ import { Request, Response, NextFunction } from "express";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { orgs, resources, sites } from "@server/db/schema";
|
import { orgs, resources, sites } from "@server/db/schema";
|
||||||
import { eq, or } from "drizzle-orm";
|
import { eq, or, and } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
import config from "@server/lib/config";
|
||||||
|
import { subdomainSchema } from "@server/lib/schemas";
|
||||||
|
|
||||||
const updateResourceParamsSchema = z
|
const updateResourceParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -26,13 +27,50 @@ const updateResourceBodySchema = z
|
|||||||
ssl: z.boolean().optional(),
|
ssl: z.boolean().optional(),
|
||||||
sso: z.boolean().optional(),
|
sso: z.boolean().optional(),
|
||||||
blockAccess: z.boolean().optional(),
|
blockAccess: z.boolean().optional(),
|
||||||
emailWhitelistEnabled: z.boolean().optional()
|
proxyPort: z.number().int().min(1).max(65535).optional(),
|
||||||
// siteId: z.number(),
|
emailWhitelistEnabled: z.boolean().optional(),
|
||||||
|
isBaseDomain: z.boolean().optional(),
|
||||||
|
applyRules: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
message: "At least one field must be provided for update"
|
message: "At least one field must be provided for update"
|
||||||
});
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (!config.getRawConfig().flags?.allow_raw_resources) {
|
||||||
|
if (data.proxyPort !== undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{ message: "Cannot update proxyPort" }
|
||||||
|
)
|
||||||
|
// .refine(
|
||||||
|
// (data) => {
|
||||||
|
// if (data.proxyPort === 443 || data.proxyPort === 80) {
|
||||||
|
// return false;
|
||||||
|
// }
|
||||||
|
// return true;
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// message: "Port 80 and 443 are reserved for http and https resources"
|
||||||
|
// }
|
||||||
|
// )
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
|
||||||
|
if (data.isBaseDomain) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Base domain resources are not allowed"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export async function updateResource(
|
export async function updateResource(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -63,13 +101,16 @@ export async function updateResource(
|
|||||||
const { resourceId } = parsedParams.data;
|
const { resourceId } = parsedParams.data;
|
||||||
const updateData = parsedBody.data;
|
const updateData = parsedBody.data;
|
||||||
|
|
||||||
const resource = await db
|
const [result] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.where(eq(resources.resourceId, resourceId))
|
.where(eq(resources.resourceId, resourceId))
|
||||||
.leftJoin(orgs, eq(resources.orgId, orgs.orgId));
|
.leftJoin(orgs, eq(resources.orgId, orgs.orgId));
|
||||||
|
|
||||||
if (resource.length === 0) {
|
const resource = result.resources;
|
||||||
|
const org = result.orgs;
|
||||||
|
|
||||||
|
if (!resource || !org) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.NOT_FOUND,
|
HttpCode.NOT_FOUND,
|
||||||
@@ -78,7 +119,55 @@ export async function updateResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resource[0].orgs?.domain) {
|
if (updateData.subdomain) {
|
||||||
|
if (!resource.http) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Cannot update subdomain for non-http resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = subdomainSchema.safeParse(
|
||||||
|
updateData.subdomain
|
||||||
|
).success;
|
||||||
|
if (!valid) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid subdomain provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateData.proxyPort) {
|
||||||
|
const proxyPort = updateData.proxyPort;
|
||||||
|
const existingResource = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.protocol, resource.protocol),
|
||||||
|
eq(resources.proxyPort, proxyPort!)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
existingResource.length > 0 &&
|
||||||
|
existingResource[0].resourceId !== resourceId
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Resource with that protocol and port already exists"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!org?.domain) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
@@ -87,15 +176,38 @@ export async function updateResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullDomain = updateData.subdomain
|
let fullDomain: string | undefined;
|
||||||
? `${updateData.subdomain}.${resource[0].orgs.domain}`
|
if (updateData.isBaseDomain) {
|
||||||
: undefined;
|
fullDomain = org.domain;
|
||||||
|
} else if (updateData.subdomain) {
|
||||||
|
fullDomain = `${updateData.subdomain}.${org.domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
const updatePayload = {
|
const updatePayload = {
|
||||||
...updateData,
|
...updateData,
|
||||||
...(fullDomain && { fullDomain })
|
...(fullDomain && { fullDomain })
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
fullDomain &&
|
||||||
|
(updatePayload.subdomain !== undefined ||
|
||||||
|
updatePayload.isBaseDomain !== undefined)
|
||||||
|
) {
|
||||||
|
const [existingDomain] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.fullDomain, fullDomain));
|
||||||
|
|
||||||
|
if (existingDomain && existingDomain.resourceId !== resourceId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Resource with that domain already exists"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updatedResource = await db
|
const updatedResource = await db
|
||||||
.update(resources)
|
.update(resources)
|
||||||
.set(updatePayload)
|
.set(updatePayload)
|
||||||
|
|||||||
179
server/routers/resource/updateResourceRule.ts
Normal file
179
server/routers/resource/updateResourceRule.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { resourceRules, resources } from "@server/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import {
|
||||||
|
isValidCIDR,
|
||||||
|
isValidIP,
|
||||||
|
isValidUrlGlobPattern
|
||||||
|
} from "@server/lib/validators";
|
||||||
|
|
||||||
|
// Define Zod schema for request parameters validation
|
||||||
|
const updateResourceRuleParamsSchema = z
|
||||||
|
.object({
|
||||||
|
ruleId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||||
|
resourceId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
// Define Zod schema for request body validation
|
||||||
|
const updateResourceRuleSchema = z
|
||||||
|
.object({
|
||||||
|
action: z.enum(["ACCEPT", "DROP"]).optional(),
|
||||||
|
match: z.enum(["CIDR", "IP", "PATH"]).optional(),
|
||||||
|
value: z.string().min(1).optional(),
|
||||||
|
priority: z.number().int(),
|
||||||
|
enabled: z.boolean().optional()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
|
message: "At least one field must be provided for update"
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateResourceRule(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
// Validate path parameters
|
||||||
|
const parsedParams = updateResourceRuleParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request body
|
||||||
|
const parsedBody = updateResourceRuleSchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ruleId, resourceId } = parsedParams.data;
|
||||||
|
const updateData = parsedBody.data;
|
||||||
|
|
||||||
|
// Verify that the resource exists
|
||||||
|
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.http) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Cannot create rule for non-http resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the rule exists and belongs to the specified resource
|
||||||
|
const [existingRule] = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceRules)
|
||||||
|
.where(eq(resourceRules.ruleId, ruleId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existingRule) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource rule with ID ${ruleId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingRule.resourceId !== resourceId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
`Resource rule ${ruleId} does not belong to resource ${resourceId}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = updateData.match || existingRule.match;
|
||||||
|
const { value } = updateData;
|
||||||
|
|
||||||
|
if (value !== undefined) {
|
||||||
|
if (match === "CIDR") {
|
||||||
|
if (!isValidCIDR(value)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid CIDR provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (match === "IP") {
|
||||||
|
if (!isValidIP(value)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid IP provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (match === "PATH") {
|
||||||
|
if (!isValidUrlGlobPattern(value)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid URL glob pattern provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the rule
|
||||||
|
const [updatedRule] = await db
|
||||||
|
.update(resourceRules)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(resourceRules.ruleId, ruleId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: updatedRule,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource rule updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ const createSiteParamsSchema = z
|
|||||||
const createSiteSchema = z
|
const createSiteSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
exitNodeId: z.number().int().positive(),
|
exitNodeId: z.number().int().positive().optional(),
|
||||||
// subdomain: z
|
// subdomain: z
|
||||||
// .string()
|
// .string()
|
||||||
// .min(1)
|
// .min(1)
|
||||||
@@ -32,7 +32,7 @@ const createSiteSchema = z
|
|||||||
// .transform((val) => val.toLowerCase())
|
// .transform((val) => val.toLowerCase())
|
||||||
// .optional(),
|
// .optional(),
|
||||||
pubKey: z.string().optional(),
|
pubKey: z.string().optional(),
|
||||||
subnet: z.string(),
|
subnet: z.string().optional(),
|
||||||
newtId: z.string().optional(),
|
newtId: z.string().optional(),
|
||||||
secret: z.string().optional(),
|
secret: z.string().optional(),
|
||||||
type: z.string()
|
type: z.string()
|
||||||
@@ -82,28 +82,46 @@ export async function createSite(
|
|||||||
|
|
||||||
const niceId = await getUniqueSiteName(orgId);
|
const niceId = await getUniqueSiteName(orgId);
|
||||||
|
|
||||||
let payload: any = {
|
|
||||||
orgId,
|
|
||||||
exitNodeId,
|
|
||||||
name,
|
|
||||||
niceId,
|
|
||||||
subnet,
|
|
||||||
type
|
|
||||||
};
|
|
||||||
|
|
||||||
if (pubKey && type == "wireguard") {
|
|
||||||
// we dont add the pubKey for newts because the newt will generate it
|
|
||||||
payload = {
|
|
||||||
...payload,
|
|
||||||
pubKey
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const [newSite] = await trx
|
let newSite: Site;
|
||||||
.insert(sites)
|
|
||||||
.values(payload)
|
if (exitNodeId) {
|
||||||
.returning();
|
// we are creating a site with an exit node (tunneled)
|
||||||
|
if (!subnet) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Subnet is required for tunneled sites"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[newSite] = await trx
|
||||||
|
.insert(sites)
|
||||||
|
.values({
|
||||||
|
orgId,
|
||||||
|
exitNodeId,
|
||||||
|
name,
|
||||||
|
niceId,
|
||||||
|
subnet,
|
||||||
|
type,
|
||||||
|
...(pubKey && type == "wireguard" && { pubKey })
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
} else {
|
||||||
|
// we are creating a site with no tunneling
|
||||||
|
|
||||||
|
[newSite] = await trx
|
||||||
|
.insert(sites)
|
||||||
|
.values({
|
||||||
|
orgId,
|
||||||
|
name,
|
||||||
|
niceId,
|
||||||
|
type,
|
||||||
|
subnet: "0.0.0.0/0"
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
const adminRole = await trx
|
const adminRole = await trx
|
||||||
.select()
|
.select()
|
||||||
@@ -149,6 +167,16 @@ export async function createSite(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!exitNodeId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Exit node ID is required for wireguard sites"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await addPeer(exitNodeId, {
|
await addPeer(exitNodeId, {
|
||||||
publicKey: pubKey,
|
publicKey: pubKey,
|
||||||
allowedIps: []
|
allowedIps: []
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { addPeer } from "../gerbil/peers";
|
import { addPeer } from "../gerbil/peers";
|
||||||
import { eq, and } from "drizzle-orm";
|
|
||||||
import { isIpInCidr } from "@server/lib/ip";
|
import { isIpInCidr } from "@server/lib/ip";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { addTargets } from "../newt/targets";
|
import { addTargets } from "../newt/targets";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { pickPort } from "./helpers";
|
||||||
|
|
||||||
// Regular expressions for validation
|
// Regular expressions for validation
|
||||||
const DOMAIN_REGEX =
|
const DOMAIN_REGEX =
|
||||||
@@ -52,9 +53,8 @@ const createTargetParamsSchema = z
|
|||||||
const createTargetSchema = z
|
const createTargetSchema = z
|
||||||
.object({
|
.object({
|
||||||
ip: domainSchema,
|
ip: domainSchema,
|
||||||
method: z.string().min(1).max(10),
|
method: z.string().optional().nullable(),
|
||||||
port: z.number().int().min(1).max(65535),
|
port: z.number().int().min(1).max(65535),
|
||||||
protocol: z.string().optional(),
|
|
||||||
enabled: z.boolean().default(true)
|
enabled: z.boolean().default(true)
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
@@ -93,9 +93,7 @@ export async function createTarget(
|
|||||||
|
|
||||||
// get the resource
|
// get the resource
|
||||||
const [resource] = await db
|
const [resource] = await db
|
||||||
.select({
|
.select()
|
||||||
siteId: resources.siteId
|
|
||||||
})
|
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.where(eq(resources.resourceId, resourceId));
|
.where(eq(resources.resourceId, resourceId));
|
||||||
|
|
||||||
@@ -123,88 +121,68 @@ export async function createTarget(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// make sure the target is within the site subnet
|
let newTarget: Target[] = [];
|
||||||
if (
|
if (site.type == "local") {
|
||||||
site.type == "wireguard" &&
|
newTarget = await db
|
||||||
!isIpInCidr(targetData.ip, site.subnet!)
|
.insert(targets)
|
||||||
) {
|
.values({
|
||||||
return next(
|
resourceId,
|
||||||
createHttpError(
|
...targetData
|
||||||
HttpCode.BAD_REQUEST,
|
})
|
||||||
`Target IP is not within the site subnet`
|
.returning();
|
||||||
)
|
} else {
|
||||||
);
|
// make sure the target is within the site subnet
|
||||||
}
|
if (
|
||||||
|
site.type == "wireguard" &&
|
||||||
// Fetch resources for this site
|
!isIpInCidr(targetData.ip, site.subnet!)
|
||||||
const resourcesRes = await db.query.resources.findMany({
|
) {
|
||||||
where: eq(resources.siteId, site.siteId)
|
return next(
|
||||||
});
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
// TODO: is this all inefficient?
|
`Target IP is not within the site subnet`
|
||||||
// Fetch targets for all resources of this site
|
)
|
||||||
let targetIps: string[] = [];
|
);
|
||||||
let targetInternalPorts: number[] = [];
|
|
||||||
await Promise.all(
|
|
||||||
resourcesRes.map(async (resource) => {
|
|
||||||
const targetsRes = await db.query.targets.findMany({
|
|
||||||
where: eq(targets.resourceId, resource.resourceId)
|
|
||||||
});
|
|
||||||
targetsRes.forEach((target) => {
|
|
||||||
targetIps.push(`${target.ip}/32`);
|
|
||||||
if (target.internalPort) {
|
|
||||||
targetInternalPorts.push(target.internalPort);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
let internalPort!: number;
|
|
||||||
// pick a port
|
|
||||||
for (let i = 40000; i < 65535; i++) {
|
|
||||||
if (!targetInternalPorts.includes(i)) {
|
|
||||||
internalPort = i;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!internalPort) {
|
const { internalPort, targetIps } = await pickPort(site.siteId!);
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
`No available internal port`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newTarget = await db
|
if (!internalPort) {
|
||||||
.insert(targets)
|
return next(
|
||||||
.values({
|
createHttpError(
|
||||||
resourceId,
|
HttpCode.BAD_REQUEST,
|
||||||
protocol: "tcp", // hard code for now
|
`No available internal port`
|
||||||
internalPort,
|
)
|
||||||
...targetData
|
);
|
||||||
})
|
}
|
||||||
.returning();
|
|
||||||
|
|
||||||
// add the new target to the targetIps array
|
newTarget = await db
|
||||||
targetIps.push(`${targetData.ip}/32`);
|
.insert(targets)
|
||||||
|
.values({
|
||||||
|
resourceId,
|
||||||
|
internalPort,
|
||||||
|
...targetData
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
if (site.pubKey) {
|
// add the new target to the targetIps array
|
||||||
if (site.type == "wireguard") {
|
targetIps.push(`${targetData.ip}/32`);
|
||||||
await addPeer(site.exitNodeId!, {
|
|
||||||
publicKey: site.pubKey,
|
|
||||||
allowedIps: targetIps.flat()
|
|
||||||
});
|
|
||||||
} else if (site.type == "newt") {
|
|
||||||
// get the newt on the site by querying the newt table for siteId
|
|
||||||
const [newt] = await db
|
|
||||||
.select()
|
|
||||||
.from(newts)
|
|
||||||
.where(eq(newts.siteId, site.siteId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
addTargets(newt.newtId, newTarget);
|
if (site.pubKey) {
|
||||||
|
if (site.type == "wireguard") {
|
||||||
|
await addPeer(site.exitNodeId!, {
|
||||||
|
publicKey: site.pubKey,
|
||||||
|
allowedIps: targetIps.flat()
|
||||||
|
});
|
||||||
|
} else if (site.type == "newt") {
|
||||||
|
// get the newt on the site by querying the newt table for siteId
|
||||||
|
const [newt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, site.siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
addTargets(newt.newtId, newTarget, resource.protocol);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import logger from "@server/logger";
|
|||||||
import { addPeer } from "../gerbil/peers";
|
import { addPeer } from "../gerbil/peers";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { removeTargets } from "../newt/targets";
|
import { removeTargets } from "../newt/targets";
|
||||||
|
import { getAllowedIps } from "./helpers";
|
||||||
|
|
||||||
const deleteTargetSchema = z
|
const deleteTargetSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -50,9 +51,7 @@ export async function deleteTarget(
|
|||||||
}
|
}
|
||||||
// get the resource
|
// get the resource
|
||||||
const [resource] = await db
|
const [resource] = await db
|
||||||
.select({
|
.select()
|
||||||
siteId: resources.siteId
|
|
||||||
})
|
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.where(eq(resources.resourceId, deletedTarget.resourceId!));
|
.where(eq(resources.resourceId, deletedTarget.resourceId!));
|
||||||
|
|
||||||
@@ -82,25 +81,9 @@ export async function deleteTarget(
|
|||||||
|
|
||||||
if (site.pubKey) {
|
if (site.pubKey) {
|
||||||
if (site.type == "wireguard") {
|
if (site.type == "wireguard") {
|
||||||
// TODO: is this all inefficient?
|
|
||||||
// Fetch resources for this site
|
|
||||||
const resourcesRes = await db.query.resources.findMany({
|
|
||||||
where: eq(resources.siteId, site.siteId)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch targets for all resources of this site
|
|
||||||
const targetIps = await Promise.all(
|
|
||||||
resourcesRes.map(async (resource) => {
|
|
||||||
const targetsRes = await db.query.targets.findMany({
|
|
||||||
where: eq(targets.resourceId, resource.resourceId)
|
|
||||||
});
|
|
||||||
return targetsRes.map((target) => `${target.ip}/32`);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
await addPeer(site.exitNodeId!, {
|
await addPeer(site.exitNodeId!, {
|
||||||
publicKey: site.pubKey,
|
publicKey: site.pubKey,
|
||||||
allowedIps: targetIps.flat()
|
allowedIps: await getAllowedIps(site.siteId)
|
||||||
});
|
});
|
||||||
} else if (site.type == "newt") {
|
} else if (site.type == "newt") {
|
||||||
// get the newt on the site by querying the newt table for siteId
|
// get the newt on the site by querying the newt table for siteId
|
||||||
@@ -110,7 +93,7 @@ export async function deleteTarget(
|
|||||||
.where(eq(newts.siteId, site.siteId))
|
.where(eq(newts.siteId, site.siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
removeTargets(newt.newtId, [deletedTarget]);
|
removeTargets(newt.newtId, [deletedTarget], resource.protocol);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
66
server/routers/target/helpers.ts
Normal file
66
server/routers/target/helpers.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { db } from "@server/db";
|
||||||
|
import { resources, targets } from "@server/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
let currentBannedPorts: number[] = [];
|
||||||
|
|
||||||
|
export async function pickPort(siteId: number): Promise<{
|
||||||
|
internalPort: number;
|
||||||
|
targetIps: string[];
|
||||||
|
}> {
|
||||||
|
// Fetch resources for this site
|
||||||
|
const resourcesRes = await db.query.resources.findMany({
|
||||||
|
where: eq(resources.siteId, siteId)
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: is this all inefficient?
|
||||||
|
// Fetch targets for all resources of this site
|
||||||
|
let targetIps: string[] = [];
|
||||||
|
let targetInternalPorts: number[] = [];
|
||||||
|
await Promise.all(
|
||||||
|
resourcesRes.map(async (resource) => {
|
||||||
|
const targetsRes = await db.query.targets.findMany({
|
||||||
|
where: eq(targets.resourceId, resource.resourceId)
|
||||||
|
});
|
||||||
|
targetsRes.forEach((target) => {
|
||||||
|
targetIps.push(`${target.ip}/32`);
|
||||||
|
if (target.internalPort) {
|
||||||
|
targetInternalPorts.push(target.internalPort);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let internalPort!: number;
|
||||||
|
// pick a port random port from 40000 to 65535 that is not in use
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
internalPort = Math.floor(Math.random() * 25535) + 40000;
|
||||||
|
if (
|
||||||
|
!targetInternalPorts.includes(internalPort) &&
|
||||||
|
!currentBannedPorts.includes(internalPort)
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentBannedPorts.push(internalPort);
|
||||||
|
|
||||||
|
return { internalPort, targetIps };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllowedIps(siteId: number) {
|
||||||
|
// TODO: is this all inefficient?
|
||||||
|
const resourcesRes = await db.query.resources.findMany({
|
||||||
|
where: eq(resources.siteId, siteId)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch targets for all resources of this site
|
||||||
|
const targetIps = await Promise.all(
|
||||||
|
resourcesRes.map(async (resource) => {
|
||||||
|
const targetsRes = await db.query.targets.findMany({
|
||||||
|
where: eq(targets.resourceId, resource.resourceId)
|
||||||
|
});
|
||||||
|
return targetsRes.map((target) => `${target.ip}/32`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return targetIps.flat();
|
||||||
|
}
|
||||||
@@ -40,7 +40,6 @@ function queryTargets(resourceId: number) {
|
|||||||
ip: targets.ip,
|
ip: targets.ip,
|
||||||
method: targets.method,
|
method: targets.method,
|
||||||
port: targets.port,
|
port: targets.port,
|
||||||
protocol: targets.protocol,
|
|
||||||
enabled: targets.enabled,
|
enabled: targets.enabled,
|
||||||
resourceId: targets.resourceId
|
resourceId: targets.resourceId
|
||||||
// resourceName: resources.name,
|
// resourceName: resources.name,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { addPeer } from "../gerbil/peers";
|
import { addPeer } from "../gerbil/peers";
|
||||||
import { addTargets } from "../newt/targets";
|
import { addTargets } from "../newt/targets";
|
||||||
|
import { pickPort } from "./helpers";
|
||||||
|
|
||||||
// Regular expressions for validation
|
// Regular expressions for validation
|
||||||
const DOMAIN_REGEX =
|
const DOMAIN_REGEX =
|
||||||
@@ -48,7 +49,7 @@ const updateTargetParamsSchema = z
|
|||||||
const updateTargetBodySchema = z
|
const updateTargetBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
ip: domainSchema.optional(),
|
ip: domainSchema.optional(),
|
||||||
method: z.string().min(1).max(10).optional(),
|
method: z.string().min(1).max(10).optional().nullable(),
|
||||||
port: z.number().int().min(1).max(65535).optional(),
|
port: z.number().int().min(1).max(65535).optional(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional()
|
||||||
})
|
})
|
||||||
@@ -84,15 +85,14 @@ export async function updateTarget(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { targetId } = parsedParams.data;
|
const { targetId } = parsedParams.data;
|
||||||
const updateData = parsedBody.data;
|
|
||||||
|
|
||||||
const [updatedTarget] = await db
|
const [target] = await db
|
||||||
.update(targets)
|
.select()
|
||||||
.set(updateData)
|
.from(targets)
|
||||||
.where(eq(targets.targetId, targetId))
|
.where(eq(targets.targetId, targetId))
|
||||||
.returning();
|
.limit(1);
|
||||||
|
|
||||||
if (!updatedTarget) {
|
if (!target) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.NOT_FOUND,
|
HttpCode.NOT_FOUND,
|
||||||
@@ -103,17 +103,15 @@ export async function updateTarget(
|
|||||||
|
|
||||||
// get the resource
|
// get the resource
|
||||||
const [resource] = await db
|
const [resource] = await db
|
||||||
.select({
|
.select()
|
||||||
siteId: resources.siteId
|
|
||||||
})
|
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.where(eq(resources.resourceId, updatedTarget.resourceId!));
|
.where(eq(resources.resourceId, target.resourceId!));
|
||||||
|
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.NOT_FOUND,
|
HttpCode.NOT_FOUND,
|
||||||
`Resource with ID ${updatedTarget.resourceId} not found`
|
`Resource with ID ${target.resourceId} not found`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -132,24 +130,29 @@ export async function updateTarget(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { internalPort, targetIps } = await pickPort(site.siteId!);
|
||||||
|
|
||||||
|
if (!internalPort) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
`No available internal port`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updatedTarget] = await db
|
||||||
|
.update(targets)
|
||||||
|
.set({
|
||||||
|
...parsedBody.data,
|
||||||
|
internalPort
|
||||||
|
})
|
||||||
|
.where(eq(targets.targetId, targetId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
if (site.pubKey) {
|
if (site.pubKey) {
|
||||||
if (site.type == "wireguard") {
|
if (site.type == "wireguard") {
|
||||||
// TODO: is this all inefficient?
|
|
||||||
// Fetch resources for this site
|
|
||||||
const resourcesRes = await db.query.resources.findMany({
|
|
||||||
where: eq(resources.siteId, site.siteId)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch targets for all resources of this site
|
|
||||||
const targetIps = await Promise.all(
|
|
||||||
resourcesRes.map(async (resource) => {
|
|
||||||
const targetsRes = await db.query.targets.findMany({
|
|
||||||
where: eq(targets.resourceId, resource.resourceId)
|
|
||||||
});
|
|
||||||
return targetsRes.map((target) => `${target.ip}/32`);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
await addPeer(site.exitNodeId!, {
|
await addPeer(site.exitNodeId!, {
|
||||||
publicKey: site.pubKey,
|
publicKey: site.pubKey,
|
||||||
allowedIps: targetIps.flat()
|
allowedIps: targetIps.flat()
|
||||||
@@ -162,7 +165,7 @@ export async function updateTarget(
|
|||||||
.where(eq(newts.siteId, site.siteId))
|
.where(eq(newts.siteId, site.siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
addTargets(newt.newtId, [updatedTarget]);
|
addTargets(newt.newtId, [updatedTarget], resource.protocol);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|||||||
@@ -1,163 +1,295 @@
|
|||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import * as schema from "@server/db/schema";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { and, eq, isNotNull } from "drizzle-orm";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
import { orgs, resources, sites, Target, targets } from "@server/db/schema";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
export async function traefikConfigProvider(
|
export async function traefikConfigProvider(
|
||||||
_: Request,
|
_: Request,
|
||||||
res: Response,
|
res: Response
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const all = await db
|
const allResources = await db
|
||||||
.select()
|
.select({
|
||||||
.from(schema.targets)
|
// Resource fields
|
||||||
.innerJoin(
|
resourceId: resources.resourceId,
|
||||||
schema.resources,
|
subdomain: resources.subdomain,
|
||||||
eq(schema.targets.resourceId, schema.resources.resourceId),
|
fullDomain: resources.fullDomain,
|
||||||
)
|
ssl: resources.ssl,
|
||||||
.innerJoin(
|
blockAccess: resources.blockAccess,
|
||||||
schema.orgs,
|
sso: resources.sso,
|
||||||
eq(schema.resources.orgId, schema.orgs.orgId),
|
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
||||||
)
|
http: resources.http,
|
||||||
.innerJoin(
|
proxyPort: resources.proxyPort,
|
||||||
schema.sites,
|
protocol: resources.protocol,
|
||||||
eq(schema.sites.siteId, schema.resources.siteId),
|
isBaseDomain: resources.isBaseDomain,
|
||||||
)
|
// Site fields
|
||||||
.where(
|
site: {
|
||||||
|
siteId: sites.siteId,
|
||||||
|
type: sites.type,
|
||||||
|
subnet: sites.subnet
|
||||||
|
},
|
||||||
|
// Org fields
|
||||||
|
org: {
|
||||||
|
orgId: orgs.orgId,
|
||||||
|
domain: orgs.domain
|
||||||
|
},
|
||||||
|
// Targets as a subquery
|
||||||
|
targets: sql<string>`json_group_array(json_object(
|
||||||
|
'targetId', ${targets.targetId},
|
||||||
|
'ip', ${targets.ip},
|
||||||
|
'method', ${targets.method},
|
||||||
|
'port', ${targets.port},
|
||||||
|
'internalPort', ${targets.internalPort},
|
||||||
|
'enabled', ${targets.enabled}
|
||||||
|
))`.as("targets")
|
||||||
|
})
|
||||||
|
.from(resources)
|
||||||
|
.innerJoin(sites, eq(sites.siteId, resources.siteId))
|
||||||
|
.innerJoin(orgs, eq(resources.orgId, orgs.orgId))
|
||||||
|
.leftJoin(
|
||||||
|
targets,
|
||||||
and(
|
and(
|
||||||
eq(schema.targets.enabled, true),
|
eq(targets.resourceId, resources.resourceId),
|
||||||
isNotNull(schema.resources.subdomain),
|
eq(targets.enabled, true)
|
||||||
isNotNull(schema.orgs.domain),
|
)
|
||||||
),
|
)
|
||||||
);
|
.groupBy(resources.resourceId);
|
||||||
|
|
||||||
if (!all.length) {
|
if (!allResources.length) {
|
||||||
return res.status(HttpCode.OK).json({});
|
return res.status(HttpCode.OK).json({});
|
||||||
}
|
}
|
||||||
|
|
||||||
const badgerMiddlewareName = "badger";
|
const badgerMiddlewareName = "badger";
|
||||||
const redirectMiddlewareName = "redirect-to-https";
|
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||||
|
|
||||||
const http: any = {
|
const config_output: any = {
|
||||||
routers: {},
|
http: {
|
||||||
services: {},
|
middlewares: {
|
||||||
middlewares: {
|
[badgerMiddlewareName]: {
|
||||||
[badgerMiddlewareName]: {
|
plugin: {
|
||||||
plugin: {
|
[badgerMiddlewareName]: {
|
||||||
[badgerMiddlewareName]: {
|
apiBaseUrl: new URL(
|
||||||
apiBaseUrl: new URL(
|
"/api/v1",
|
||||||
"/api/v1",
|
`http://${config.getRawConfig().server.internal_hostname}:${
|
||||||
`http://${config.getRawConfig().server.internal_hostname}:${config.getRawConfig().server.internal_port}`,
|
config.getRawConfig().server
|
||||||
).href,
|
.internal_port
|
||||||
resourceSessionCookieName:
|
}`
|
||||||
config.getRawConfig().server.resource_session_cookie_name,
|
).href,
|
||||||
userSessionCookieName:
|
userSessionCookieName:
|
||||||
config.getRawConfig().server.session_cookie_name,
|
config.getRawConfig().server
|
||||||
},
|
.session_cookie_name,
|
||||||
|
accessTokenQueryParam:
|
||||||
|
config.getRawConfig().server
|
||||||
|
.resource_access_token_param,
|
||||||
|
resourceSessionRequestParam:
|
||||||
|
config.getRawConfig().server
|
||||||
|
.resource_session_request_param
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
[redirectHttpsMiddlewareName]: {
|
||||||
[redirectMiddlewareName]: {
|
redirectScheme: {
|
||||||
redirectScheme: {
|
scheme: "https"
|
||||||
scheme: "https",
|
}
|
||||||
permanent: true,
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
|
||||||
};
|
};
|
||||||
for (const item of all) {
|
|
||||||
const target = item.targets;
|
|
||||||
const resource = item.resources;
|
|
||||||
const site = item.sites;
|
|
||||||
const org = item.orgs;
|
|
||||||
|
|
||||||
const routerName = `${target.targetId}-router`;
|
for (const resource of allResources) {
|
||||||
const serviceName = `${target.targetId}-service`;
|
const targets = JSON.parse(resource.targets);
|
||||||
|
const site = resource.site;
|
||||||
|
const org = resource.org;
|
||||||
|
|
||||||
if (!resource || !resource.subdomain) {
|
if (!org.domain) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!org || !org.domain) {
|
const routerName = `${resource.resourceId}-router`;
|
||||||
continue;
|
const serviceName = `${resource.resourceId}-service`;
|
||||||
}
|
const fullDomain = `${resource.fullDomain}`;
|
||||||
|
|
||||||
const fullDomain = `${resource.subdomain}.${org.domain}`;
|
if (resource.http) {
|
||||||
|
// HTTP configuration remains the same
|
||||||
|
if (!resource.subdomain && !resource.isBaseDomain) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const domainParts = fullDomain.split(".");
|
// add routers and services empty objects if they don't exist
|
||||||
let wildCard;
|
if (!config_output.http.routers) {
|
||||||
if (domainParts.length <= 2) {
|
config_output.http.routers = {};
|
||||||
wildCard = `*.${domainParts.join(".")}`;
|
}
|
||||||
} else {
|
|
||||||
wildCard = `*.${domainParts.slice(1).join(".")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tls = {
|
if (!config_output.http.services) {
|
||||||
certResolver: config.getRawConfig().traefik.cert_resolver,
|
config_output.http.services = {};
|
||||||
...(config.getRawConfig().traefik.prefer_wildcard_cert
|
}
|
||||||
? {
|
|
||||||
domains: [
|
|
||||||
{
|
|
||||||
main: wildCard,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
http.routers![routerName] = {
|
const domainParts = fullDomain.split(".");
|
||||||
entryPoints: [
|
let wildCard;
|
||||||
resource.ssl
|
if (domainParts.length <= 2) {
|
||||||
? config.getRawConfig().traefik.https_entrypoint
|
wildCard = `*.${domainParts.join(".")}`;
|
||||||
: config.getRawConfig().traefik.http_entrypoint,
|
} else {
|
||||||
],
|
wildCard = `*.${domainParts.slice(1).join(".")}`;
|
||||||
middlewares: [badgerMiddlewareName],
|
}
|
||||||
service: serviceName,
|
|
||||||
rule: `Host(\`${fullDomain}\`)`,
|
|
||||||
...(resource.ssl ? { tls } : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (resource.ssl) {
|
const tls = {
|
||||||
// this is a redirect router; all it does is redirect to the https version if tls is enabled
|
certResolver: config.getRawConfig().traefik.cert_resolver,
|
||||||
http.routers![routerName + "-redirect"] = {
|
...(config.getRawConfig().traefik.prefer_wildcard_cert
|
||||||
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
|
? {
|
||||||
middlewares: [redirectMiddlewareName],
|
domains: [
|
||||||
|
{
|
||||||
|
main: wildCard
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
};
|
||||||
|
|
||||||
|
const additionalMiddlewares =
|
||||||
|
config.getRawConfig().traefik.additional_middlewares || [];
|
||||||
|
|
||||||
|
config_output.http.routers![routerName] = {
|
||||||
|
entryPoints: [
|
||||||
|
resource.ssl
|
||||||
|
? config.getRawConfig().traefik.https_entrypoint
|
||||||
|
: config.getRawConfig().traefik.http_entrypoint
|
||||||
|
],
|
||||||
|
middlewares: [
|
||||||
|
badgerMiddlewareName,
|
||||||
|
...additionalMiddlewares
|
||||||
|
],
|
||||||
service: serviceName,
|
service: serviceName,
|
||||||
rule: `Host(\`${fullDomain}\`)`,
|
rule: `Host(\`${fullDomain}\`)`,
|
||||||
|
...(resource.ssl ? { tls } : {})
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
if (site.type === "newt") {
|
if (resource.ssl) {
|
||||||
const ip = site.subnet.split("/")[0];
|
config_output.http.routers![routerName + "-redirect"] = {
|
||||||
http.services![serviceName] = {
|
entryPoints: [
|
||||||
loadBalancer: {
|
config.getRawConfig().traefik.http_entrypoint
|
||||||
servers: [
|
|
||||||
{
|
|
||||||
url: `${target.method}://${ip}:${target.internalPort}`,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
middlewares: [redirectHttpsMiddlewareName],
|
||||||
|
service: serviceName,
|
||||||
|
rule: `Host(\`${fullDomain}\`)`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
config_output.http.services![serviceName] = {
|
||||||
|
loadBalancer: {
|
||||||
|
servers: targets
|
||||||
|
.filter((target: Target) => {
|
||||||
|
if (!target.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
site.type === "local" ||
|
||||||
|
site.type === "wireguard"
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!target.ip ||
|
||||||
|
!target.port ||
|
||||||
|
!target.method
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (site.type === "newt") {
|
||||||
|
if (
|
||||||
|
!target.internalPort ||
|
||||||
|
!target.method
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((target: Target) => {
|
||||||
|
if (
|
||||||
|
site.type === "local" ||
|
||||||
|
site.type === "wireguard"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
url: `${target.method}://${target.ip}:${target.port}`
|
||||||
|
};
|
||||||
|
} else if (site.type === "newt") {
|
||||||
|
const ip = site.subnet.split("/")[0];
|
||||||
|
return {
|
||||||
|
url: `${target.method}://${ip}:${target.internalPort}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
};
|
};
|
||||||
} else if (site.type === "wireguard") {
|
} else {
|
||||||
http.services![serviceName] = {
|
// Non-HTTP (TCP/UDP) configuration
|
||||||
|
const protocol = resource.protocol.toLowerCase();
|
||||||
|
const port = resource.proxyPort;
|
||||||
|
|
||||||
|
if (!port) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config_output[protocol]) {
|
||||||
|
config_output[protocol] = {
|
||||||
|
routers: {},
|
||||||
|
services: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
config_output[protocol].routers[routerName] = {
|
||||||
|
entryPoints: [`${protocol}-${port}`],
|
||||||
|
service: serviceName,
|
||||||
|
...(protocol === "tcp" ? { rule: "HostSNI(`*`)" } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
config_output[protocol].services[serviceName] = {
|
||||||
loadBalancer: {
|
loadBalancer: {
|
||||||
servers: [
|
servers: targets
|
||||||
{
|
.filter((target: Target) => {
|
||||||
url: `${target.method}://${target.ip}:${target.port}`,
|
if (!target.enabled) {
|
||||||
},
|
return false;
|
||||||
],
|
}
|
||||||
},
|
if (
|
||||||
|
site.type === "local" ||
|
||||||
|
site.type === "wireguard"
|
||||||
|
) {
|
||||||
|
if (!target.ip || !target.port) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (site.type === "newt") {
|
||||||
|
if (!target.internalPort) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((target: Target) => {
|
||||||
|
if (
|
||||||
|
site.type === "local" ||
|
||||||
|
site.type === "wireguard"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
address: `${target.ip}:${target.port}`
|
||||||
|
};
|
||||||
|
} else if (site.type === "newt") {
|
||||||
|
const ip = site.subnet.split("/")[0];
|
||||||
|
return {
|
||||||
|
address: `${ip}:${target.internalPort}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return res.status(HttpCode.OK).json(config_output);
|
||||||
return res.status(HttpCode.OK).json({ http });
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`Failed to build traefik config: ${e}`);
|
logger.error(`Failed to build Traefik config: ${e}`);
|
||||||
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
||||||
error: "Failed to build traefik config",
|
error: "Failed to build Traefik config"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ const inviteUserParamsSchema = z
|
|||||||
|
|
||||||
const inviteUserBodySchema = z
|
const inviteUserBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
email: z.string().email(),
|
email: z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.transform((v) => v.toLowerCase()),
|
||||||
roleId: z.number(),
|
roleId: z.number(),
|
||||||
validHours: z.number().gt(0).lte(168),
|
validHours: z.number().gt(0).lte(168),
|
||||||
sendEmail: z.boolean().optional()
|
sendEmail: z.boolean().optional()
|
||||||
@@ -165,7 +168,7 @@ export async function inviteUser(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
to: email,
|
to: email,
|
||||||
from: config.getRawConfig().email?.no_reply,
|
from: config.getNoReplyEmail(),
|
||||||
subject: "You're invited to join a Fossorial organization"
|
subject: "You're invited to join a Fossorial organization"
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import logger from "@server/logger";
|
|||||||
export async function copyInConfig() {
|
export async function copyInConfig() {
|
||||||
const domain = config.getBaseDomain();
|
const domain = config.getBaseDomain();
|
||||||
const endpoint = config.getRawConfig().gerbil.base_endpoint;
|
const endpoint = config.getRawConfig().gerbil.base_endpoint;
|
||||||
|
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||||
|
|
||||||
// update the domain on all of the orgs where the domain is not equal to the new domain
|
// update the domain on all of the orgs where the domain is not equal to the new domain
|
||||||
// TODO: eventually each org could have a unique domain that we do not want to overwrite, so this will be unnecessary
|
// TODO: eventually each org could have a unique domain that we do not want to overwrite, so this will be unnecessary
|
||||||
@@ -14,13 +15,20 @@ export async function copyInConfig() {
|
|||||||
|
|
||||||
// TODO: eventually each exit node could have a different endpoint
|
// TODO: eventually each exit node could have a different endpoint
|
||||||
await db.update(exitNodes).set({ endpoint }).where(ne(exitNodes.endpoint, endpoint));
|
await db.update(exitNodes).set({ endpoint }).where(ne(exitNodes.endpoint, endpoint));
|
||||||
|
// TODO: eventually each exit node could have a different port
|
||||||
|
await db.update(exitNodes).set({ listenPort }).where(ne(exitNodes.listenPort, listenPort));
|
||||||
|
|
||||||
// update all resources fullDomain to use the new domain
|
// update all resources fullDomain to use the new domain
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const allResources = await trx.select().from(resources);
|
const allResources = await trx.select().from(resources);
|
||||||
|
|
||||||
for (const resource of allResources) {
|
for (const resource of allResources) {
|
||||||
const fullDomain = `${resource.subdomain}.${domain}`;
|
let fullDomain = "";
|
||||||
|
if (resource.isBaseDomain) {
|
||||||
|
fullDomain = domain;
|
||||||
|
} else {
|
||||||
|
fullDomain = `${resource.subdomain}.${domain}`;
|
||||||
|
}
|
||||||
await trx
|
await trx
|
||||||
.update(resources)
|
.update(resources)
|
||||||
.set({ fullDomain })
|
.set({ fullDomain })
|
||||||
|
|||||||
@@ -3,12 +3,18 @@ import db, { exists } from "@server/db";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import { versionMigrations } from "@server/db/schema";
|
import { versionMigrations } from "@server/db/schema";
|
||||||
import { desc } from "drizzle-orm";
|
import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts";
|
||||||
import { __DIRNAME } from "@server/lib/consts";
|
import { SqliteError } from "better-sqlite3";
|
||||||
import { loadAppVersion } from "@server/lib/loadAppVersion";
|
import fs from "fs";
|
||||||
import m1 from "./scripts/1.0.0-beta1";
|
import m1 from "./scripts/1.0.0-beta1";
|
||||||
import m2 from "./scripts/1.0.0-beta2";
|
import m2 from "./scripts/1.0.0-beta2";
|
||||||
import m3 from "./scripts/1.0.0-beta3";
|
import m3 from "./scripts/1.0.0-beta3";
|
||||||
|
import m4 from "./scripts/1.0.0-beta5";
|
||||||
|
import m5 from "./scripts/1.0.0-beta6";
|
||||||
|
import m6 from "./scripts/1.0.0-beta9";
|
||||||
|
import m7 from "./scripts/1.0.0-beta10";
|
||||||
|
import m8 from "./scripts/1.0.0-beta12";
|
||||||
|
import m13 from "./scripts/1.0.0-beta13";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
@@ -17,61 +23,103 @@ import m3 from "./scripts/1.0.0-beta3";
|
|||||||
const migrations = [
|
const migrations = [
|
||||||
{ version: "1.0.0-beta.1", run: m1 },
|
{ version: "1.0.0-beta.1", run: m1 },
|
||||||
{ version: "1.0.0-beta.2", run: m2 },
|
{ version: "1.0.0-beta.2", run: m2 },
|
||||||
{ version: "1.0.0-beta.3", run: m3 }
|
{ version: "1.0.0-beta.3", run: m3 },
|
||||||
|
{ version: "1.0.0-beta.5", run: m4 },
|
||||||
|
{ version: "1.0.0-beta.6", run: m5 },
|
||||||
|
{ version: "1.0.0-beta.9", run: m6 },
|
||||||
|
{ version: "1.0.0-beta.10", run: m7 },
|
||||||
|
{ version: "1.0.0-beta.12", run: m8 },
|
||||||
|
{ version: "1.0.0-beta.13", run: m13 }
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Run the migrations
|
await run();
|
||||||
await runMigrations();
|
|
||||||
|
|
||||||
export async function runMigrations() {
|
async function run() {
|
||||||
const appVersion = loadAppVersion();
|
// backup the database
|
||||||
if (!appVersion) {
|
backupDb();
|
||||||
throw new Error("APP_VERSION is not set in the environment");
|
|
||||||
|
// run the migrations
|
||||||
|
await runMigrations();
|
||||||
|
}
|
||||||
|
|
||||||
|
function backupDb() {
|
||||||
|
// make dir config/db/backups
|
||||||
|
const appPath = APP_PATH;
|
||||||
|
const dbDir = path.join(appPath, "db");
|
||||||
|
|
||||||
|
const backupsDir = path.join(dbDir, "backups");
|
||||||
|
|
||||||
|
// check if the backups directory exists and create it if it doesn't
|
||||||
|
if (!fs.existsSync(backupsDir)) {
|
||||||
|
fs.mkdirSync(backupsDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exists) {
|
// copy the db.sqlite file to backups
|
||||||
await executeScripts();
|
// add the date to the filename
|
||||||
} else {
|
const date = new Date();
|
||||||
console.log("Running migrations...");
|
const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`;
|
||||||
try {
|
const dbPath = path.join(dbDir, "db.sqlite");
|
||||||
migrate(db, {
|
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
|
||||||
migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build
|
fs.copyFileSync(dbPath, backupPath);
|
||||||
});
|
}
|
||||||
console.log("Migrations completed successfully.");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error running migrations:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
export async function runMigrations() {
|
||||||
.insert(versionMigrations)
|
try {
|
||||||
.values({
|
const appVersion = APP_VERSION;
|
||||||
version: appVersion,
|
|
||||||
executedAt: Date.now()
|
if (exists) {
|
||||||
})
|
await executeScripts();
|
||||||
.execute();
|
} else {
|
||||||
|
console.log("Running migrations...");
|
||||||
|
try {
|
||||||
|
migrate(db, {
|
||||||
|
migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build
|
||||||
|
});
|
||||||
|
console.log("Migrations completed successfully.");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error running migrations:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(versionMigrations)
|
||||||
|
.values({
|
||||||
|
version: appVersion,
|
||||||
|
executedAt: Date.now()
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error running migrations:", e);
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, 1000 * 60 * 60 * 24 * 1)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeScripts() {
|
async function executeScripts() {
|
||||||
try {
|
try {
|
||||||
// Get the last executed version from the database
|
// Get the last executed version from the database
|
||||||
const lastExecuted = await db
|
const lastExecuted = await db.select().from(versionMigrations);
|
||||||
.select()
|
|
||||||
.from(versionMigrations)
|
|
||||||
.orderBy(desc(versionMigrations.version))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const startVersion = lastExecuted[0]?.version ?? "0.0.0";
|
|
||||||
console.log(`Starting migrations from version ${startVersion}`);
|
|
||||||
|
|
||||||
// Filter and sort migrations
|
// Filter and sort migrations
|
||||||
const pendingMigrations = migrations
|
const pendingMigrations = lastExecuted
|
||||||
.filter((migration) => semver.gt(migration.version, startVersion))
|
.map((m) => m)
|
||||||
.sort((a, b) => semver.compare(a.version, b.version));
|
.sort((a, b) => semver.compare(b.version, a.version));
|
||||||
|
const startVersion = pendingMigrations[0]?.version ?? "0.0.0";
|
||||||
|
console.log(`Starting migrations from version ${startVersion}`);
|
||||||
|
|
||||||
|
const migrationsToRun = migrations.filter((migration) =>
|
||||||
|
semver.gt(migration.version, startVersion)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"Migrations to run:",
|
||||||
|
migrationsToRun.map((m) => m.version).join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
// Run migrations in order
|
// Run migrations in order
|
||||||
for (const migration of pendingMigrations) {
|
for (const migration of migrationsToRun) {
|
||||||
console.log(`Running migration ${migration.version}`);
|
console.log(`Running migration ${migration.version}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -89,12 +137,19 @@ async function executeScripts() {
|
|||||||
console.log(
|
console.log(
|
||||||
`Successfully completed migration ${migration.version}`
|
`Successfully completed migration ${migration.version}`
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
|
if (
|
||||||
|
e instanceof SqliteError &&
|
||||||
|
e.code === "SQLITE_CONSTRAINT_UNIQUE"
|
||||||
|
) {
|
||||||
|
console.error("Migration has already run! Skipping...");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
console.error(
|
console.error(
|
||||||
`Failed to run migration ${migration.version}:`,
|
`Failed to run migration ${migration.version}:`,
|
||||||
error
|
e
|
||||||
);
|
);
|
||||||
throw error; // Re-throw to stop migration process
|
throw e; // Re-throw to stop migration process
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
45
server/setup/scripts/1.0.0-beta10.ts
Normal file
45
server/setup/scripts/1.0.0-beta10.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
|
import fs from "fs";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log("Running setup script 1.0.0-beta.10...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Determine which config file exists
|
||||||
|
const filePaths = [configFilePath1, configFilePath2];
|
||||||
|
let filePath = "";
|
||||||
|
for (const path of filePaths) {
|
||||||
|
if (fs.existsSync(path)) {
|
||||||
|
filePath = path;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
throw new Error(
|
||||||
|
`No config file found (expected config.yml or config.yaml).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and parse the YAML file
|
||||||
|
let rawConfig: any;
|
||||||
|
const fileContents = fs.readFileSync(filePath, "utf8");
|
||||||
|
rawConfig = yaml.load(fileContents);
|
||||||
|
|
||||||
|
delete rawConfig.server.secure_cookies;
|
||||||
|
|
||||||
|
// Write the updated YAML back to the file
|
||||||
|
const updatedYaml = yaml.dump(rawConfig);
|
||||||
|
fs.writeFileSync(filePath, updatedYaml, "utf8");
|
||||||
|
|
||||||
|
console.log(`Removed deprecated config option: secure_cookies.`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(
|
||||||
|
`Was unable to remove deprecated config option: secure_cookies. Error: ${e}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Done.");
|
||||||
|
}
|
||||||
62
server/setup/scripts/1.0.0-beta12.ts
Normal file
62
server/setup/scripts/1.0.0-beta12.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import db from "@server/db";
|
||||||
|
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import fs from "fs";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log("Running setup script 1.0.0-beta.12...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Determine which config file exists
|
||||||
|
const filePaths = [configFilePath1, configFilePath2];
|
||||||
|
let filePath = "";
|
||||||
|
for (const path of filePaths) {
|
||||||
|
if (fs.existsSync(path)) {
|
||||||
|
filePath = path;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
throw new Error(
|
||||||
|
`No config file found (expected config.yml or config.yaml).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and parse the YAML file
|
||||||
|
let rawConfig: any;
|
||||||
|
const fileContents = fs.readFileSync(filePath, "utf8");
|
||||||
|
rawConfig = yaml.load(fileContents);
|
||||||
|
|
||||||
|
if (!rawConfig.flags) {
|
||||||
|
rawConfig.flags = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
rawConfig.flags.allow_base_domain_resources = true;
|
||||||
|
|
||||||
|
// Write the updated YAML back to the file
|
||||||
|
const updatedYaml = yaml.dump(rawConfig);
|
||||||
|
fs.writeFileSync(filePath, updatedYaml, "utf8");
|
||||||
|
|
||||||
|
console.log(`Added new config option: allow_base_domain_resources`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(
|
||||||
|
`Unable to add new config option: allow_base_domain_resources. This is not critical.`
|
||||||
|
);
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.transaction((trx) => {
|
||||||
|
trx.run(sql`ALTER TABLE 'resources' ADD 'isBaseDomain' integer;`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Added new column: isBaseDomain`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Unable to add new column: isBaseDomain");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Done.");
|
||||||
|
}
|
||||||
33
server/setup/scripts/1.0.0-beta13.ts
Normal file
33
server/setup/scripts/1.0.0-beta13.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import db from "@server/db";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
const version = "1.0.0-beta.13";
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log(`Running setup script ${version}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.transaction((trx) => {
|
||||||
|
trx.run(sql`CREATE TABLE resourceRules (
|
||||||
|
ruleId integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
resourceId integer NOT NULL,
|
||||||
|
priority integer NOT NULL,
|
||||||
|
enabled integer DEFAULT true NOT NULL,
|
||||||
|
action text NOT NULL,
|
||||||
|
match text NOT NULL,
|
||||||
|
value text NOT NULL,
|
||||||
|
FOREIGN KEY (resourceId) REFERENCES resources(resourceId) ON UPDATE no action ON DELETE cascade
|
||||||
|
);`);
|
||||||
|
trx.run(
|
||||||
|
sql`ALTER TABLE resources ADD applyRules integer DEFAULT false NOT NULL;`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Added new table and column: resourceRules, applyRules`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Unable to add new table and column: resourceRules, applyRules");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${version} migration complete`);
|
||||||
|
}
|
||||||
101
server/setup/scripts/1.0.0-beta5.ts
Normal file
101
server/setup/scripts/1.0.0-beta5.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
|
import fs from "fs";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
import path from "path";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromZodError } from "zod-validation-error";
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log("Running setup script 1.0.0-beta.5...");
|
||||||
|
|
||||||
|
// Determine which config file exists
|
||||||
|
const filePaths = [configFilePath1, configFilePath2];
|
||||||
|
let filePath = "";
|
||||||
|
for (const path of filePaths) {
|
||||||
|
if (fs.existsSync(path)) {
|
||||||
|
filePath = path;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
throw new Error(
|
||||||
|
`No config file found (expected config.yml or config.yaml).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and parse the YAML file
|
||||||
|
let rawConfig: any;
|
||||||
|
const fileContents = fs.readFileSync(filePath, "utf8");
|
||||||
|
rawConfig = yaml.load(fileContents);
|
||||||
|
|
||||||
|
// Validate the structure
|
||||||
|
if (!rawConfig.server) {
|
||||||
|
throw new Error(`Invalid config file: server is missing.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the config
|
||||||
|
rawConfig.server.resource_access_token_param = "p_token";
|
||||||
|
|
||||||
|
// Write the updated YAML back to the file
|
||||||
|
const updatedYaml = yaml.dump(rawConfig);
|
||||||
|
fs.writeFileSync(filePath, updatedYaml, "utf8");
|
||||||
|
|
||||||
|
// then try to update badger in traefik config
|
||||||
|
|
||||||
|
try {
|
||||||
|
const traefikPath = path.join(
|
||||||
|
APP_PATH,
|
||||||
|
"traefik",
|
||||||
|
"traefik_config.yml"
|
||||||
|
);
|
||||||
|
|
||||||
|
// read the traefik file
|
||||||
|
// look for the badger middleware
|
||||||
|
// set the version to v1.0.0-beta.2
|
||||||
|
/*
|
||||||
|
experimental:
|
||||||
|
plugins:
|
||||||
|
badger:
|
||||||
|
moduleName: "github.com/fosrl/badger"
|
||||||
|
version: "v1.0.0-beta.2"
|
||||||
|
*/
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
experimental: z.object({
|
||||||
|
plugins: z.object({
|
||||||
|
badger: z.object({
|
||||||
|
moduleName: z.string(),
|
||||||
|
version: z.string()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const traefikFileContents = fs.readFileSync(traefikPath, "utf8");
|
||||||
|
const traefikConfig = yaml.load(traefikFileContents) as any;
|
||||||
|
|
||||||
|
const parsedConfig = schema.safeParse(traefikConfig);
|
||||||
|
|
||||||
|
if (!parsedConfig.success) {
|
||||||
|
throw new Error(fromZodError(parsedConfig.error).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
traefikConfig.experimental.plugins.badger.version = "v1.0.0-beta.2";
|
||||||
|
|
||||||
|
const updatedTraefikYaml = yaml.dump(traefikConfig);
|
||||||
|
|
||||||
|
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"Updated the version of Badger in your Traefik configuration to v1.0.0-beta.2."
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(
|
||||||
|
"We were unable to update the version of Badger in your Traefik configuration. Please update it manually."
|
||||||
|
);
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Done.");
|
||||||
|
}
|
||||||
52
server/setup/scripts/1.0.0-beta6.ts
Normal file
52
server/setup/scripts/1.0.0-beta6.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
|
import fs from "fs";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log("Running setup script 1.0.0-beta.6...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Determine which config file exists
|
||||||
|
const filePaths = [configFilePath1, configFilePath2];
|
||||||
|
let filePath = "";
|
||||||
|
for (const path of filePaths) {
|
||||||
|
if (fs.existsSync(path)) {
|
||||||
|
filePath = path;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
throw new Error(
|
||||||
|
`No config file found (expected config.yml or config.yaml).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and parse the YAML file
|
||||||
|
let rawConfig: any;
|
||||||
|
const fileContents = fs.readFileSync(filePath, "utf8");
|
||||||
|
rawConfig = yaml.load(fileContents);
|
||||||
|
|
||||||
|
// Validate the structure
|
||||||
|
if (!rawConfig.server) {
|
||||||
|
throw new Error(`Invalid config file: server is missing.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the config
|
||||||
|
rawConfig.server.cors = {
|
||||||
|
origins: [rawConfig.app.dashboard_url],
|
||||||
|
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||||
|
headers: ["X-CSRF-Token", "Content-Type"],
|
||||||
|
credentials: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write the updated YAML back to the file
|
||||||
|
const updatedYaml = yaml.dump(rawConfig);
|
||||||
|
fs.writeFileSync(filePath, updatedYaml, "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
console.log("We were unable to add CORS to your config file. Please add it manually.")
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Done.");
|
||||||
|
}
|
||||||
291
server/setup/scripts/1.0.0-beta9.ts
Normal file
291
server/setup/scripts/1.0.0-beta9.ts
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import db from "@server/db";
|
||||||
|
import {
|
||||||
|
emailVerificationCodes,
|
||||||
|
passwordResetTokens,
|
||||||
|
resourceOtp,
|
||||||
|
resources,
|
||||||
|
resourceWhitelist,
|
||||||
|
targets,
|
||||||
|
userInvites,
|
||||||
|
users
|
||||||
|
} from "@server/db/schema";
|
||||||
|
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import fs from "fs";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
import path from "path";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromZodError } from "zod-validation-error";
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log("Running setup script 1.0.0-beta.9...");
|
||||||
|
|
||||||
|
// make dir config/db/backups
|
||||||
|
const appPath = APP_PATH;
|
||||||
|
const dbDir = path.join(appPath, "db");
|
||||||
|
|
||||||
|
const backupsDir = path.join(dbDir, "backups");
|
||||||
|
|
||||||
|
// check if the backups directory exists and create it if it doesn't
|
||||||
|
if (!fs.existsSync(backupsDir)) {
|
||||||
|
fs.mkdirSync(backupsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// copy the db.sqlite file to backups
|
||||||
|
// add the date to the filename
|
||||||
|
const date = new Date();
|
||||||
|
const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`;
|
||||||
|
const dbPath = path.join(dbDir, "db.sqlite");
|
||||||
|
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
|
||||||
|
fs.copyFileSync(dbPath, backupPath);
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
try {
|
||||||
|
// Determine which config file exists
|
||||||
|
const filePaths = [configFilePath1, configFilePath2];
|
||||||
|
let filePath = "";
|
||||||
|
for (const path of filePaths) {
|
||||||
|
if (fs.existsSync(path)) {
|
||||||
|
filePath = path;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
throw new Error(
|
||||||
|
`No config file found (expected config.yml or config.yaml).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and parse the YAML file
|
||||||
|
let rawConfig: any;
|
||||||
|
const fileContents = fs.readFileSync(filePath, "utf8");
|
||||||
|
rawConfig = yaml.load(fileContents);
|
||||||
|
|
||||||
|
rawConfig.server.resource_session_request_param =
|
||||||
|
"p_session_request";
|
||||||
|
rawConfig.server.session_cookie_name = "p_session_token"; // rename to prevent conflicts
|
||||||
|
delete rawConfig.server.resource_session_cookie_name;
|
||||||
|
|
||||||
|
if (!rawConfig.flags) {
|
||||||
|
rawConfig.flags = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
rawConfig.flags.allow_raw_resources = true;
|
||||||
|
|
||||||
|
// Write the updated YAML back to the file
|
||||||
|
const updatedYaml = yaml.dump(rawConfig);
|
||||||
|
fs.writeFileSync(filePath, updatedYaml, "utf8");
|
||||||
|
} catch (e) {
|
||||||
|
console.log(
|
||||||
|
`Failed to add resource_session_request_param to config. Please add it manually. https://docs.fossorial.io/Pangolin/Configuration/config`
|
||||||
|
);
|
||||||
|
trx.rollback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const traefikPath = path.join(
|
||||||
|
APP_PATH,
|
||||||
|
"traefik",
|
||||||
|
"traefik_config.yml"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Define schema for traefik config validation
|
||||||
|
const schema = z.object({
|
||||||
|
entryPoints: z
|
||||||
|
.object({
|
||||||
|
websecure: z
|
||||||
|
.object({
|
||||||
|
address: z.string(),
|
||||||
|
transport: z
|
||||||
|
.object({
|
||||||
|
respondingTimeouts: z.object({
|
||||||
|
readTimeout: z.string()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
experimental: z.object({
|
||||||
|
plugins: z.object({
|
||||||
|
badger: z.object({
|
||||||
|
moduleName: z.string(),
|
||||||
|
version: z.string()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const traefikFileContents = fs.readFileSync(traefikPath, "utf8");
|
||||||
|
const traefikConfig = yaml.load(traefikFileContents) as any;
|
||||||
|
|
||||||
|
let parsedConfig: any = schema.safeParse(traefikConfig);
|
||||||
|
|
||||||
|
if (parsedConfig.success) {
|
||||||
|
// Ensure websecure entrypoint exists
|
||||||
|
if (traefikConfig.entryPoints?.websecure) {
|
||||||
|
// Add transport configuration
|
||||||
|
traefikConfig.entryPoints.websecure.transport = {
|
||||||
|
respondingTimeouts: {
|
||||||
|
readTimeout: "30m"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
traefikConfig.experimental.plugins.badger.version =
|
||||||
|
"v1.0.0-beta.3";
|
||||||
|
|
||||||
|
const updatedTraefikYaml = yaml.dump(traefikConfig);
|
||||||
|
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
|
||||||
|
|
||||||
|
console.log("Updated Badger version in Traefik config.");
|
||||||
|
} else {
|
||||||
|
console.log(fromZodError(parsedConfig.error));
|
||||||
|
console.log(
|
||||||
|
"We were unable to update the version of Badger in your Traefik configuration. Please update it manually to at least v1.0.0-beta.3. https://github.com/fosrl/badger"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(
|
||||||
|
"We were unable to update the version of Badger in your Traefik configuration. Please update it manually to at least v1.0.0-beta.3. https://github.com/fosrl/badger"
|
||||||
|
);
|
||||||
|
trx.rollback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const traefikPath = path.join(
|
||||||
|
APP_PATH,
|
||||||
|
"traefik",
|
||||||
|
"dynamic_config.yml"
|
||||||
|
);
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
http: z.object({
|
||||||
|
middlewares: z.object({
|
||||||
|
"redirect-to-https": z.object({
|
||||||
|
redirectScheme: z.object({
|
||||||
|
scheme: z.string(),
|
||||||
|
permanent: z.boolean()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const traefikFileContents = fs.readFileSync(traefikPath, "utf8");
|
||||||
|
const traefikConfig = yaml.load(traefikFileContents) as any;
|
||||||
|
|
||||||
|
let parsedConfig: any = schema.safeParse(traefikConfig);
|
||||||
|
|
||||||
|
if (parsedConfig.success) {
|
||||||
|
// delete permanent from redirect-to-https middleware
|
||||||
|
delete traefikConfig.http.middlewares["redirect-to-https"].redirectScheme.permanent;
|
||||||
|
|
||||||
|
const updatedTraefikYaml = yaml.dump(traefikConfig);
|
||||||
|
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
|
||||||
|
|
||||||
|
console.log("Deleted permanent from redirect-to-https middleware.");
|
||||||
|
} else {
|
||||||
|
console.log(fromZodError(parsedConfig.error));
|
||||||
|
console.log(
|
||||||
|
"We were unable to delete the permanent field from the redirect-to-https middleware in your Traefik configuration. Please delete it manually."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(
|
||||||
|
"We were unable to delete the permanent field from the redirect-to-https middleware in your Traefik configuration. Please delete it manually. Note that this is not a critical change but recommended."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
trx.run(sql`UPDATE ${users} SET email = LOWER(email);`);
|
||||||
|
trx.run(
|
||||||
|
sql`UPDATE ${emailVerificationCodes} SET email = LOWER(email);`
|
||||||
|
);
|
||||||
|
trx.run(sql`UPDATE ${passwordResetTokens} SET email = LOWER(email);`);
|
||||||
|
trx.run(sql`UPDATE ${userInvites} SET email = LOWER(email);`);
|
||||||
|
trx.run(sql`UPDATE ${resourceWhitelist} SET email = LOWER(email);`);
|
||||||
|
trx.run(sql`UPDATE ${resourceOtp} SET email = LOWER(email);`);
|
||||||
|
|
||||||
|
const resourcesAll = await trx
|
||||||
|
.select({
|
||||||
|
resourceId: resources.resourceId,
|
||||||
|
fullDomain: resources.fullDomain,
|
||||||
|
subdomain: resources.subdomain
|
||||||
|
})
|
||||||
|
.from(resources);
|
||||||
|
|
||||||
|
trx.run(`DROP INDEX resources_fullDomain_unique;`);
|
||||||
|
trx.run(`ALTER TABLE resources
|
||||||
|
DROP COLUMN fullDomain;
|
||||||
|
`);
|
||||||
|
trx.run(`ALTER TABLE resources
|
||||||
|
DROP COLUMN subdomain;
|
||||||
|
`);
|
||||||
|
trx.run(sql`ALTER TABLE resources
|
||||||
|
ADD COLUMN fullDomain TEXT;
|
||||||
|
`);
|
||||||
|
trx.run(sql`ALTER TABLE resources
|
||||||
|
ADD COLUMN subdomain TEXT;
|
||||||
|
`);
|
||||||
|
trx.run(sql`ALTER TABLE resources
|
||||||
|
ADD COLUMN http INTEGER DEFAULT true NOT NULL;
|
||||||
|
`);
|
||||||
|
trx.run(sql`ALTER TABLE resources
|
||||||
|
ADD COLUMN protocol TEXT DEFAULT 'tcp' NOT NULL;
|
||||||
|
`);
|
||||||
|
trx.run(sql`ALTER TABLE resources
|
||||||
|
ADD COLUMN proxyPort INTEGER;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// write the new fullDomain and subdomain values back to the database
|
||||||
|
for (const resource of resourcesAll) {
|
||||||
|
await trx
|
||||||
|
.update(resources)
|
||||||
|
.set({
|
||||||
|
fullDomain: resource.fullDomain,
|
||||||
|
subdomain: resource.subdomain
|
||||||
|
})
|
||||||
|
.where(eq(resources.resourceId, resource.resourceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetsAll = await trx
|
||||||
|
.select({
|
||||||
|
targetId: targets.targetId,
|
||||||
|
method: targets.method
|
||||||
|
})
|
||||||
|
.from(targets);
|
||||||
|
|
||||||
|
trx.run(`ALTER TABLE targets
|
||||||
|
DROP COLUMN method;
|
||||||
|
`);
|
||||||
|
trx.run(`ALTER TABLE targets
|
||||||
|
DROP COLUMN protocol;
|
||||||
|
`);
|
||||||
|
trx.run(sql`ALTER TABLE targets
|
||||||
|
ADD COLUMN method TEXT;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// write the new method and protocol values back to the database
|
||||||
|
for (const target of targetsAll) {
|
||||||
|
await trx
|
||||||
|
.update(targets)
|
||||||
|
.set({
|
||||||
|
method: target.method
|
||||||
|
})
|
||||||
|
.where(eq(targets.targetId, target.targetId));
|
||||||
|
}
|
||||||
|
|
||||||
|
trx.run(
|
||||||
|
sql`ALTER TABLE 'resourceSessions' ADD 'isRequestToken' integer;`
|
||||||
|
);
|
||||||
|
trx.run(
|
||||||
|
sql`ALTER TABLE 'resourceSessions' ADD 'userSessionId' text REFERENCES session(id);`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Done.");
|
||||||
|
}
|
||||||
@@ -69,6 +69,8 @@ export async function setupServerAdmin() {
|
|||||||
|
|
||||||
const userId = generateId(15);
|
const userId = generateId(15);
|
||||||
|
|
||||||
|
await trx.update(users).set({ serverAdmin: false });
|
||||||
|
|
||||||
await db.insert(users).values({
|
await db.insert(users).values({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
email: email,
|
email: email,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -48,7 +48,6 @@ export default function CreateRoleForm({
|
|||||||
setOpen,
|
setOpen,
|
||||||
afterCreate,
|
afterCreate,
|
||||||
}: CreateRoleFormProps) {
|
}: CreateRoleFormProps) {
|
||||||
const { toast } = useToast();
|
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -56,7 +56,6 @@ export default function DeleteRoleForm({
|
|||||||
setOpen,
|
setOpen,
|
||||||
afterDelete,
|
afterDelete,
|
||||||
}: CreateRoleFormProps) {
|
}: CreateRoleFormProps) {
|
||||||
const { toast } = useToast();
|
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
SortingState,
|
SortingState,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
ColumnFiltersState,
|
ColumnFiltersState,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
TableContainer,
|
TableContainer,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -35,7 +35,7 @@ interface DataTableProps<TData, TValue> {
|
|||||||
export function RolesDataTable<TData, TValue>({
|
export function RolesDataTable<TData, TValue>({
|
||||||
addRole,
|
addRole,
|
||||||
columns,
|
columns,
|
||||||
data,
|
data
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
@@ -49,14 +49,16 @@ export function RolesDataTable<TData, TValue>({
|
|||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
initialState: {
|
||||||
|
pagination: {
|
||||||
|
pageSize: 20,
|
||||||
|
pageIndex: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
columnFilters,
|
columnFilters
|
||||||
pagination: {
|
}
|
||||||
pageSize: 100,
|
|
||||||
pageIndex: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -102,7 +104,7 @@ export function RolesDataTable<TData, TValue>({
|
|||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef
|
header.column.columnDef
|
||||||
.header,
|
.header,
|
||||||
header.getContext(),
|
header.getContext()
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
);
|
);
|
||||||
@@ -123,7 +125,7 @@ export function RolesDataTable<TData, TValue>({
|
|||||||
<TableCell key={cell.id}>
|
<TableCell key={cell.id}>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
cell.getContext(),
|
cell.getContext()
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user