Compare commits
241 Commits
1.0.0-beta
...
1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aabdcea3c0 | ||
|
|
a178faa377 | ||
|
|
edf0ce226f | ||
|
|
7118ae374d | ||
|
|
f2a14e6a36 | ||
|
|
f37be774a6 | ||
|
|
0dcfeb3587 | ||
|
|
dbfc8b51aa | ||
|
|
d72a8af04b | ||
|
|
7131dea7a0 | ||
|
|
deb30ed4ae | ||
|
|
3b09ef3345 | ||
|
|
06e90c9555 | ||
|
|
cdc415079c | ||
|
|
1c2ba4076a | ||
|
|
af68aa692c | ||
|
|
edba818615 | ||
|
|
cdf904a2bc | ||
|
|
fedab6c9a8 | ||
|
|
33e8ed4c93 | ||
|
|
2b54dfe035 | ||
|
|
e601816791 | ||
|
|
7a46cf3da7 | ||
|
|
ad32e5e651 | ||
|
|
8ec55eb70d | ||
|
|
767fec19cd | ||
|
|
d215a12f5a | ||
|
|
d22dcfb464 | ||
|
|
c93b36c757 | ||
|
|
9253dd19ba | ||
|
|
b9d83a2507 | ||
|
|
581f96daa8 | ||
|
|
33ff2fbf3b | ||
|
|
535b4e1fb1 | ||
|
|
5871bea706 | ||
|
|
07eb422491 | ||
|
|
654ed46a46 | ||
|
|
eb73da8aa0 | ||
|
|
cc6800c791 | ||
|
|
47abdf873a | ||
|
|
90366da61b | ||
|
|
5529beaf6e | ||
|
|
93c8236535 | ||
|
|
37fdc4a6a8 | ||
|
|
a456a37b2f | ||
|
|
430afe3f93 | ||
|
|
3b60e1f3ac | ||
|
|
b8543e5fa8 | ||
|
|
d594314e52 | ||
|
|
81c142e8ae | ||
|
|
59eedce664 | ||
|
|
adef93623d | ||
|
|
759434e9f8 | ||
|
|
0e38f58a7f | ||
|
|
8445e83c7c | ||
|
|
89a59b25fc | ||
|
|
57a37a01ce | ||
|
|
f8add1f098 | ||
|
|
0bd0cc76fb | ||
|
|
06e4fbac68 | ||
|
|
e82df67063 | ||
|
|
84f94bb727 | ||
|
|
20f1a6372b | ||
|
|
06c434a5ea | ||
|
|
b83dadb14b | ||
|
|
492e53edf3 | ||
|
|
3d9557b65c | ||
|
|
332804ed71 | ||
|
|
de70c62ea8 | ||
|
|
e4789c6b08 | ||
|
|
ec9d02a735 | ||
|
|
ae73a2f3f4 | ||
|
|
d8183bfd0d | ||
|
|
e11748fe30 | ||
|
|
ccbe56e110 | ||
|
|
ff37e07ce6 | ||
|
|
f59f0ee57d | ||
|
|
372932985d | ||
|
|
c877bb1187 | ||
|
|
5f95500b6f | ||
|
|
3194dc56eb | ||
|
|
e49fb646b0 | ||
|
|
fd11fb81d6 | ||
|
|
82f990eb8b | ||
|
|
851bedb2e5 | ||
|
|
e6c42e9610 | ||
|
|
d3d523b2b8 | ||
|
|
532d3696c2 | ||
|
|
dabd4a055c | ||
|
|
7bf820a4bf | ||
|
|
b862e1aeef | ||
|
|
bdee036ab4 | ||
|
|
62238948e0 | ||
|
|
489f6bed17 | ||
|
|
6aa4908446 | ||
|
|
d5a220a004 | ||
|
|
a418195b28 | ||
|
|
2ff6d1d117 | ||
|
|
8dd30c88ab | ||
|
|
7797c6c770 | ||
|
|
40922fedb8 | ||
|
|
4c1366ef91 | ||
|
|
f61d442989 | ||
|
|
60449afca5 | ||
|
|
b1702bf99a | ||
|
|
a35e24bc0e | ||
|
|
c230e034cf | ||
|
|
06ceff7427 | ||
|
|
81c4199e87 | ||
|
|
19273ddbd5 | ||
|
|
fdf1dfdeba | ||
|
|
f14ecf50e4 | ||
|
|
c244ef387b | ||
|
|
8165051dd8 | ||
|
|
a7b8ffaf9f | ||
|
|
6fba13c8d1 | ||
|
|
3c99fbb1ef | ||
|
|
5b44ffa2fb | ||
|
|
6e6992e19f | ||
|
|
4bce210ff5 | ||
|
|
bbc1a9eac4 | ||
|
|
5e92aebd20 | ||
|
|
2428738fa6 | ||
|
|
d22c7826fe | ||
|
|
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 |
@@ -22,7 +22,6 @@ next-env.d.ts
|
||||
*.log
|
||||
.machinelogs*.json
|
||||
*-audit.json
|
||||
package-lock.json
|
||||
install/
|
||||
bruno/
|
||||
LICENSE
|
||||
|
||||
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
@@ -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 go-build-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
|
||||
3
.gitignore
vendored
@@ -23,7 +23,6 @@ next-env.d.ts
|
||||
.machinelogs*.json
|
||||
*-audit.json
|
||||
migrations
|
||||
package-lock.json
|
||||
tsconfig.tsbuildinfo
|
||||
config/config.yml
|
||||
dist
|
||||
@@ -31,3 +30,5 @@ dist
|
||||
installer
|
||||
*.tar
|
||||
bin
|
||||
.secrets
|
||||
test_event.json
|
||||
|
||||
19
Dockerfile
@@ -2,9 +2,8 @@ FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
|
||||
RUN npm install
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -14,21 +13,19 @@ RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
# Curl used for the health checks
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
RUN npm install --omit=dev
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/init ./dist/init
|
||||
|
||||
COPY 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 public ./public
|
||||
|
||||
3
Makefile
@@ -12,9 +12,6 @@ build-arm:
|
||||
build-x86:
|
||||
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:
|
||||
docker build -t fosrl/pangolin:latest .
|
||||
|
||||
|
||||
116
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://hub.docker.com/r/fosrl/pangolin)
|
||||
@@ -6,19 +7,32 @@
|
||||
[](https://discord.gg/HCJR8Xhme4)
|
||||
[](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
|
||||
<h3 align="center">Tunneled Mesh Reverse Proxy Server with Access Control</h3>
|
||||
<div align="center">
|
||||
|
||||
- [Installation Instructions](https://docs.fossorial.io/Getting%20Started/quick-install)
|
||||
- [Full Documentation](https://docs.fossorial.io)
|
||||
_Your own self-hosted zero trust tunnel._
|
||||
|
||||
### Authors and Maintainers
|
||||
</div>
|
||||
|
||||
- [Milo Schwartz](https://github.com/miloschwartz)
|
||||
- [Owen Schwartz](https://github.com/oschwartz10612)
|
||||
<div align="center">
|
||||
<h5>
|
||||
<a href="https://fossorial.io">
|
||||
Website
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://docs.fossorial.io/Getting%20Started/quick-install">
|
||||
Install Guide
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="mailto:numbat@fossorial.io">
|
||||
Contact Us
|
||||
</a>
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
## Preview
|
||||
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"/>
|
||||
|
||||
@@ -28,15 +42,18 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
||||
|
||||
### 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).
|
||||
- Built-in support for any WireGuard client.
|
||||
- Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/).
|
||||
- Support for HTTP/HTTPS and **raw TCP/UDP services**.
|
||||
- Load balancing.
|
||||
|
||||
### Identity & Access Management
|
||||
|
||||
- Centralized authentication system using platform SSO. **Users will only have to manage one login.**
|
||||
- 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.
|
||||
- **Role-based access control** to manage resource access permissions.
|
||||
- Additional authentication options include:
|
||||
@@ -54,63 +71,42 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
||||
|
||||
### Easy Deployment
|
||||
|
||||
- Docker Compose based setup for simplified deployment.
|
||||
- Run on any cloud provider or on-premises.
|
||||
- **Docker Compose based setup** for simplified deployment.
|
||||
- Future-proof installation script for streamlined setup and feature additions.
|
||||
- Run on any VPS.
|
||||
- Use your preferred WireGuard client to connect, or use Newt, our custom user space client for the best experience.
|
||||
- Use any WireGuard client to connect, or use **Newt, our custom user space client** for the best experience.
|
||||
|
||||
### 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 [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin) and [Geoblock](github.com/PascalMinder/geoblock).
|
||||
- **Automatically install and configure Crowdsec via Pangolin's installer script.**
|
||||
- Attach as many sites to the central server as you wish.
|
||||
|
||||
## Screenshots
|
||||
<img src="public/screenshots/collage.png" alt="Collage"/>
|
||||
|
||||
Pangolin has a straightforward and simple dashboard UI:
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src="public/screenshots/sites.png" alt="Sites Example" width="200"/></td>
|
||||
<td align="center"><img src="public/screenshots/users.png" alt="Users Example" width="200"/></td>
|
||||
<td align="center"><img src="public/screenshots/share-link.png" alt="Share Link Example" width="200"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Sites</b></td>
|
||||
<td align="center"><b>Users</b></td>
|
||||
<td align="center"><b>Share Link</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="public/screenshots/auth.png" alt="Authentication Example" width="200"/></td>
|
||||
<td align="center"><img src="public/screenshots/connectivity.png" alt="Connectivity Example" width="200"/></td>
|
||||
<td align="center"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Authentication</b></td>
|
||||
<td align="center"><b>Connectivity</b></td>
|
||||
<td align="center"><b></b></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## Workflow Example
|
||||
|
||||
### Deployment and Usage Example
|
||||
## Deployment and Usage Example
|
||||
|
||||
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 RackNerd, Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
|
||||
|
||||
> [!TIP]
|
||||
> Many of our users have had a great experience with [RackNerd](https://my.racknerd.com/aff.php?aff=13788). Depending on promotions, you can likely get a **VPS with 1 vCPU, 1GB RAM, and ~20GB SSD for just around $12/year**. That's a great deal!
|
||||
> We are part of the [RackNerd](https://my.racknerd.com/aff.php?aff=13788) affiliate program, so if you purchase through [our link](https://my.racknerd.com/aff.php?aff=13788), we receive a small commission which helps us maintain the project and keep it free for everyone.
|
||||
|
||||
2. **Domain Configuration**:
|
||||
|
||||
- Point your domain name to the VPS and configure Pangolin with your preferred settings.
|
||||
|
||||
3. **Connect Private Sites**:
|
||||
|
||||
- Install Newt or use another WireGuard client on private sites.
|
||||
- Automatically establish a connection from these sites to the central server.
|
||||
4. **Configure Users & Roles**
|
||||
- Define organizations and invite users.
|
||||
- Implement user- or role-based permissions to control resource access.
|
||||
|
||||
4. **Expose Resources**:
|
||||
|
||||
- Add resources to the central server and configure access control rules.
|
||||
- Access these resources securely from anywhere.
|
||||
|
||||
**Use Case Example - Bypassing Port Restrictions in Home Lab**:
|
||||
Imagine private sites where the ISP restricts port forwarding. By connecting these sites to Pangolin via WireGuard, you can securely expose HTTP and HTTPS resources on the private network without any networking complexity.
|
||||
@@ -118,19 +114,29 @@ Pangolin has a straightforward and simple dashboard UI:
|
||||
**Use Case Example - IoT Networks**:
|
||||
IoT networks are often fragmented and difficult to manage. By deploying Pangolin on a central server, you can connect all your IoT sites via Newt or another WireGuard client. This creates a simple, secure, and centralized way to access IoT resources without the need for intricate networking setups.
|
||||
|
||||
|
||||
<img src="public/screenshots/resources.png" alt="Resources"/>
|
||||
|
||||
_Resources page of Pangolin dashboard (dark mode) showing HTTPS and TCP resources with access control rules._
|
||||
|
||||
## Similar Projects and Inspirations
|
||||
|
||||
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.
|
||||
|
||||
- **Authentik and Authelia**:
|
||||
**Authentik and Authelia**:
|
||||
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
|
||||
|
||||
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 AGPL-3 and the Fossorial Commercial license. To see our commercial offerings, please see our [website](https://fossorial.io) for details. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io).
|
||||
|
||||
## Contributions
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ post {
|
||||
|
||||
body:json {
|
||||
{
|
||||
"email": "owen@fossorial.io",
|
||||
"email": "admin@fosrl.io",
|
||||
"password": "Password123!"
|
||||
}
|
||||
}
|
||||
|
||||
11
bruno/Users/adminListUsers.bru
Normal file
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: adminListUsers
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: http://localhost:3000/api/v1/users
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
11
bruno/Users/adminRemoveUser.bru
Normal file
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: adminRemoveUser
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
delete {
|
||||
url: http://localhost:3000/api/v1/user/ky5r7ivqs8wc7u4
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
@@ -1,27 +1,32 @@
|
||||
# To see all available options, please visit the docs:
|
||||
# https://docs.fossorial.io/Pangolin/Configuration/config
|
||||
|
||||
app:
|
||||
dashboard_url: http://localhost:3002
|
||||
base_domain: localhost
|
||||
log_level: info
|
||||
dashboard_url: "http://localhost:3002"
|
||||
log_level: "info"
|
||||
save_logs: false
|
||||
|
||||
domains:
|
||||
domain1:
|
||||
base_domain: "example.com"
|
||||
cert_resolver: "letsencrypt"
|
||||
|
||||
server:
|
||||
external_port: 3000
|
||||
internal_port: 3001
|
||||
next_port: 3002
|
||||
internal_hostname: pangolin
|
||||
secure_cookies: true
|
||||
session_cookie_name: p_session
|
||||
resource_session_cookie_name: p_resource_session
|
||||
resource_access_token_param: p_token
|
||||
internal_hostname: "pangolin"
|
||||
session_cookie_name: "p_session_token"
|
||||
resource_access_token_param: "p_token"
|
||||
resource_session_request_param: "p_session_request"
|
||||
|
||||
traefik:
|
||||
cert_resolver: letsencrypt
|
||||
http_entrypoint: web
|
||||
https_entrypoint: websecure
|
||||
http_entrypoint: "web"
|
||||
https_entrypoint: "websecure"
|
||||
|
||||
gerbil:
|
||||
start_port: 51820
|
||||
base_endpoint: localhost
|
||||
base_endpoint: "localhost"
|
||||
block_size: 24
|
||||
site_block_size: 30
|
||||
subnet_group: 100.89.137.0/20
|
||||
@@ -34,10 +39,12 @@ rate_limits:
|
||||
|
||||
users:
|
||||
server_admin:
|
||||
email: admin@example.com
|
||||
password: Password123!
|
||||
email: "admin@example.com"
|
||||
password: "Password123!"
|
||||
|
||||
flags:
|
||||
require_email_verification: false
|
||||
disable_signup_without_invite: true
|
||||
disable_user_create_org: true
|
||||
allow_raw_resources: true
|
||||
allow_base_domain_resources: true
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
http:
|
||||
middlewares:
|
||||
redirect-to-https:
|
||||
redirectScheme:
|
||||
scheme: https
|
||||
permanent: true
|
||||
|
||||
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
|
||||
@@ -1,41 +0,0 @@
|
||||
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.2"
|
||||
|
||||
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"
|
||||
http:
|
||||
tls:
|
||||
certResolver: "letsencrypt"
|
||||
|
||||
serversTransport:
|
||||
insecureSkipVerify: true
|
||||
@@ -1,5 +1,4 @@
|
||||
version: "3.7"
|
||||
|
||||
name: pangolin
|
||||
services:
|
||||
pangolin:
|
||||
image: fosrl/pangolin:latest
|
||||
@@ -32,12 +31,11 @@ services:
|
||||
- SYS_MODULE
|
||||
ports:
|
||||
- 51820:51820/udp
|
||||
- 8080:8080 # 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
|
||||
|
||||
traefik:
|
||||
image: traefik:v3.1
|
||||
image: traefik:v3.3.3
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||
@@ -47,5 +45,10 @@ services:
|
||||
command:
|
||||
- --configFile=/etc/traefik/traefik_config.yml
|
||||
volumes:
|
||||
- ./traefik:/etc/traefik:ro # Volume to store the Traefik configuration
|
||||
- ./letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
||||
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
|
||||
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
||||
|
||||
networks:
|
||||
default:
|
||||
driver: bridge
|
||||
name: pangolin
|
||||
9
eslint.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// eslint.config.js
|
||||
export default [
|
||||
{
|
||||
rules: {
|
||||
semi: "error",
|
||||
"prefer-const": "error"
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -1,14 +1,24 @@
|
||||
all: update-versions go-build-release put-back
|
||||
|
||||
all: build
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build -o bin/installer
|
||||
|
||||
release:
|
||||
go-build-release:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
|
||||
|
||||
clean:
|
||||
rm bin/installer
|
||||
rm bin/installer_linux_amd64
|
||||
rm bin/installer_linux_arm64
|
||||
rm -f bin/installer_linux_amd64
|
||||
rm -f bin/installer_linux_arm64
|
||||
|
||||
update-versions:
|
||||
@echo "Fetching latest versions..."
|
||||
cp main.go main.go.bak && \
|
||||
PANGOLIN_VERSION=$$(curl -s https://api.github.com/repos/fosrl/pangolin/tags | jq -r '.[0].name') && \
|
||||
GERBIL_VERSION=$$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') && \
|
||||
BADGER_VERSION=$$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') && \
|
||||
echo "Latest versions - Pangolin: $$PANGOLIN_VERSION, Gerbil: $$GERBIL_VERSION, Badger: $$BADGER_VERSION" && \
|
||||
sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$$PANGOLIN_VERSION\"/" main.go && \
|
||||
sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$$GERBIL_VERSION\"/" main.go && \
|
||||
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$$BADGER_VERSION\"/" main.go && \
|
||||
echo "Updated main.go with latest versions"
|
||||
|
||||
put-back:
|
||||
mv main.go.bak main.go
|
||||
353
install/config.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// TraefikConfig represents the structure of the main Traefik configuration
|
||||
type TraefikConfig struct {
|
||||
Experimental struct {
|
||||
Plugins struct {
|
||||
Badger struct {
|
||||
Version string `yaml:"version"`
|
||||
} `yaml:"badger"`
|
||||
} `yaml:"plugins"`
|
||||
} `yaml:"experimental"`
|
||||
CertificatesResolvers struct {
|
||||
LetsEncrypt struct {
|
||||
Acme struct {
|
||||
Email string `yaml:"email"`
|
||||
} `yaml:"acme"`
|
||||
} `yaml:"letsencrypt"`
|
||||
} `yaml:"certificatesResolvers"`
|
||||
}
|
||||
|
||||
// DynamicConfig represents the structure of the dynamic configuration
|
||||
type DynamicConfig struct {
|
||||
HTTP struct {
|
||||
Routers map[string]struct {
|
||||
Rule string `yaml:"rule"`
|
||||
} `yaml:"routers"`
|
||||
} `yaml:"http"`
|
||||
}
|
||||
|
||||
// ConfigValues holds the extracted configuration values
|
||||
type ConfigValues struct {
|
||||
DashboardDomain string
|
||||
LetsEncryptEmail string
|
||||
BadgerVersion string
|
||||
}
|
||||
|
||||
// ReadTraefikConfig reads and extracts values from Traefik configuration files
|
||||
func ReadTraefikConfig(mainConfigPath, dynamicConfigPath string) (*ConfigValues, error) {
|
||||
// Read main config file
|
||||
mainConfigData, err := os.ReadFile(mainConfigPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading main config file: %w", err)
|
||||
}
|
||||
|
||||
var mainConfig TraefikConfig
|
||||
if err := yaml.Unmarshal(mainConfigData, &mainConfig); err != nil {
|
||||
return nil, fmt.Errorf("error parsing main config file: %w", err)
|
||||
}
|
||||
|
||||
// Read dynamic config file
|
||||
dynamicConfigData, err := os.ReadFile(dynamicConfigPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading dynamic config file: %w", err)
|
||||
}
|
||||
|
||||
var dynamicConfig DynamicConfig
|
||||
if err := yaml.Unmarshal(dynamicConfigData, &dynamicConfig); err != nil {
|
||||
return nil, fmt.Errorf("error parsing dynamic config file: %w", err)
|
||||
}
|
||||
|
||||
// Extract values
|
||||
values := &ConfigValues{
|
||||
BadgerVersion: mainConfig.Experimental.Plugins.Badger.Version,
|
||||
LetsEncryptEmail: mainConfig.CertificatesResolvers.LetsEncrypt.Acme.Email,
|
||||
}
|
||||
|
||||
// Extract DashboardDomain from router rules
|
||||
// Look for it in the main router rules
|
||||
for _, router := range dynamicConfig.HTTP.Routers {
|
||||
if router.Rule != "" {
|
||||
// Extract domain from Host(`mydomain.com`)
|
||||
if domain := extractDomainFromRule(router.Rule); domain != "" {
|
||||
values.DashboardDomain = domain
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// extractDomainFromRule extracts the domain from a router rule
|
||||
func extractDomainFromRule(rule string) string {
|
||||
// Look for the Host(`mydomain.com`) pattern
|
||||
if start := findPattern(rule, "Host(`"); start != -1 {
|
||||
end := findPattern(rule[start:], "`)")
|
||||
if end != -1 {
|
||||
return rule[start+6 : start+end]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findPattern finds the start of a pattern in a string
|
||||
func findPattern(s, pattern string) int {
|
||||
return bytes.Index([]byte(s), []byte(pattern))
|
||||
}
|
||||
|
||||
func copyDockerService(sourceFile, destFile, serviceName string) error {
|
||||
// Read source file
|
||||
sourceData, err := os.ReadFile(sourceFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading source file: %w", err)
|
||||
}
|
||||
|
||||
// Read destination file
|
||||
destData, err := os.ReadFile(destFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading destination file: %w", err)
|
||||
}
|
||||
|
||||
// Parse source Docker Compose YAML
|
||||
var sourceCompose map[string]interface{}
|
||||
if err := yaml.Unmarshal(sourceData, &sourceCompose); err != nil {
|
||||
return fmt.Errorf("error parsing source Docker Compose file: %w", err)
|
||||
}
|
||||
|
||||
// Parse destination Docker Compose YAML
|
||||
var destCompose map[string]interface{}
|
||||
if err := yaml.Unmarshal(destData, &destCompose); err != nil {
|
||||
return fmt.Errorf("error parsing destination Docker Compose file: %w", err)
|
||||
}
|
||||
|
||||
// Get services section from source
|
||||
sourceServices, ok := sourceCompose["services"].(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("services section not found in source file or has invalid format")
|
||||
}
|
||||
|
||||
// Get the specific service configuration
|
||||
serviceConfig, ok := sourceServices[serviceName]
|
||||
if !ok {
|
||||
return fmt.Errorf("service '%s' not found in source file", serviceName)
|
||||
}
|
||||
|
||||
// Get or create services section in destination
|
||||
destServices, ok := destCompose["services"].(map[string]interface{})
|
||||
if !ok {
|
||||
// If services section doesn't exist, create it
|
||||
destServices = make(map[string]interface{})
|
||||
destCompose["services"] = destServices
|
||||
}
|
||||
|
||||
// Update service in destination
|
||||
destServices[serviceName] = serviceConfig
|
||||
|
||||
// Marshal updated destination YAML
|
||||
// Use yaml.v3 encoder to preserve formatting and comments
|
||||
// updatedData, err := yaml.Marshal(destCompose)
|
||||
updatedData, err := MarshalYAMLWithIndent(destCompose, 2)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshaling updated Docker Compose file: %w", err)
|
||||
}
|
||||
|
||||
// Write updated YAML back to destination file
|
||||
if err := os.WriteFile(destFile, updatedData, 0644); err != nil {
|
||||
return fmt.Errorf("error writing to destination file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func backupConfig() error {
|
||||
// Backup docker-compose.yml
|
||||
if _, err := os.Stat("docker-compose.yml"); err == nil {
|
||||
if err := copyFile("docker-compose.yml", "docker-compose.yml.backup"); err != nil {
|
||||
return fmt.Errorf("failed to backup docker-compose.yml: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Backup config directory
|
||||
if _, err := os.Stat("config"); err == nil {
|
||||
cmd := exec.Command("tar", "-czvf", "config.tar.gz", "config")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to backup config directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func MarshalYAMLWithIndent(data interface{}, indent int) ([]byte, error) {
|
||||
buffer := new(bytes.Buffer)
|
||||
encoder := yaml.NewEncoder(buffer)
|
||||
encoder.SetIndent(indent)
|
||||
|
||||
err := encoder.Encode(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer encoder.Close()
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
func replaceInFile(filepath, oldStr, newStr string) error {
|
||||
// Read the file content
|
||||
content, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading file: %v", err)
|
||||
}
|
||||
|
||||
// Replace the string
|
||||
newContent := strings.Replace(string(content), oldStr, newStr, -1)
|
||||
|
||||
// Write the modified content back to the file
|
||||
err = os.WriteFile(filepath, []byte(newContent), 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error writing file: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CheckAndAddTraefikLogVolume(composePath string) error {
|
||||
// Read the docker-compose.yml file
|
||||
data, err := os.ReadFile(composePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading compose file: %w", err)
|
||||
}
|
||||
|
||||
// Parse YAML into a generic map
|
||||
var compose map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &compose); err != nil {
|
||||
return fmt.Errorf("error parsing compose file: %w", err)
|
||||
}
|
||||
|
||||
// Get services section
|
||||
services, ok := compose["services"].(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("services section not found or invalid")
|
||||
}
|
||||
|
||||
// Get traefik service
|
||||
traefik, ok := services["traefik"].(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("traefik service not found or invalid")
|
||||
}
|
||||
|
||||
// Check volumes
|
||||
logVolume := "./config/traefik/logs:/var/log/traefik"
|
||||
var volumes []interface{}
|
||||
|
||||
if existingVolumes, ok := traefik["volumes"].([]interface{}); ok {
|
||||
// Check if volume already exists
|
||||
for _, v := range existingVolumes {
|
||||
if v.(string) == logVolume {
|
||||
fmt.Println("Traefik log volume is already configured")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
volumes = existingVolumes
|
||||
}
|
||||
|
||||
// Add new volume
|
||||
volumes = append(volumes, logVolume)
|
||||
traefik["volumes"] = volumes
|
||||
|
||||
// Write updated config back to file
|
||||
newData, err := MarshalYAMLWithIndent(compose, 2)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshaling updated compose file: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(composePath, newData, 0644); err != nil {
|
||||
return fmt.Errorf("error writing updated compose file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Added traefik log volume and created logs directory")
|
||||
return nil
|
||||
}
|
||||
|
||||
// MergeYAML merges two YAML files, where the contents of the second file
|
||||
// are merged into the first file. In case of conflicts, values from the
|
||||
// second file take precedence.
|
||||
func MergeYAML(baseFile, overlayFile string) error {
|
||||
// Read the base YAML file
|
||||
baseContent, err := os.ReadFile(baseFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading base file: %v", err)
|
||||
}
|
||||
|
||||
// Read the overlay YAML file
|
||||
overlayContent, err := os.ReadFile(overlayFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading overlay file: %v", err)
|
||||
}
|
||||
|
||||
// Parse base YAML into a map
|
||||
var baseMap map[string]interface{}
|
||||
if err := yaml.Unmarshal(baseContent, &baseMap); err != nil {
|
||||
return fmt.Errorf("error parsing base YAML: %v", err)
|
||||
}
|
||||
|
||||
// Parse overlay YAML into a map
|
||||
var overlayMap map[string]interface{}
|
||||
if err := yaml.Unmarshal(overlayContent, &overlayMap); err != nil {
|
||||
return fmt.Errorf("error parsing overlay YAML: %v", err)
|
||||
}
|
||||
|
||||
// Merge the overlay into the base
|
||||
merged := mergeMap(baseMap, overlayMap)
|
||||
|
||||
// Marshal the merged result back to YAML
|
||||
mergedContent, err := MarshalYAMLWithIndent(merged, 2)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshaling merged YAML: %v", err)
|
||||
}
|
||||
|
||||
// Write the merged content back to the base file
|
||||
if err := os.WriteFile(baseFile, mergedContent, 0644); err != nil {
|
||||
return fmt.Errorf("error writing merged YAML: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeMap recursively merges two maps
|
||||
func mergeMap(base, overlay map[string]interface{}) map[string]interface{} {
|
||||
result := make(map[string]interface{})
|
||||
|
||||
// Copy all key-values from base map
|
||||
for k, v := range base {
|
||||
result[k] = v
|
||||
}
|
||||
|
||||
// Merge overlay values
|
||||
for k, v := range overlay {
|
||||
// If both maps have the same key and both values are maps, merge recursively
|
||||
if baseVal, ok := base[k]; ok {
|
||||
if baseMap, isBaseMap := baseVal.(map[string]interface{}); isBaseMap {
|
||||
if overlayMap, isOverlayMap := v.(map[string]interface{}); isOverlayMap {
|
||||
result[k] = mergeMap(baseMap, overlayMap)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
// Otherwise, overlay value takes precedence
|
||||
result[k] = v
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
63
install/config/config.yml
Normal file
@@ -0,0 +1,63 @@
|
||||
# To see all available options, please visit the docs:
|
||||
# https://docs.fossorial.io/Pangolin/Configuration/config
|
||||
|
||||
app:
|
||||
dashboard_url: "https://{{.DashboardDomain}}"
|
||||
log_level: "info"
|
||||
save_logs: false
|
||||
|
||||
domains:
|
||||
domain1:
|
||||
base_domain: "{{.BaseDomain}}"
|
||||
cert_resolver: "letsencrypt"
|
||||
|
||||
server:
|
||||
external_port: 3000
|
||||
internal_port: 3001
|
||||
next_port: 3002
|
||||
internal_hostname: "pangolin"
|
||||
session_cookie_name: "p_session_token"
|
||||
resource_access_token_param: "p_token"
|
||||
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:
|
||||
cert_resolver: "letsencrypt"
|
||||
http_entrypoint: "web"
|
||||
https_entrypoint: "websecure"
|
||||
|
||||
gerbil:
|
||||
start_port: 51820
|
||||
base_endpoint: "{{.DashboardDomain}}"
|
||||
use_subdomain: false
|
||||
block_size: 24
|
||||
site_block_size: 30
|
||||
subnet_group: 100.89.137.0/20
|
||||
|
||||
rate_limits:
|
||||
global:
|
||||
window_minutes: 1
|
||||
max_requests: 500
|
||||
{{if .EnableEmail}}
|
||||
email:
|
||||
smtp_host: "{{.EmailSMTPHost}}"
|
||||
smtp_port: {{.EmailSMTPPort}}
|
||||
smtp_user: "{{.EmailSMTPUser}}"
|
||||
smtp_pass: "{{.EmailSMTPPass}}"
|
||||
no_reply: "{{.EmailNoReply}}"
|
||||
{{end}}
|
||||
users:
|
||||
server_admin:
|
||||
email: "{{.AdminUserEmail}}"
|
||||
password: "{{.AdminUserPassword}}"
|
||||
|
||||
flags:
|
||||
require_email_verification: {{.EnableEmail}}
|
||||
disable_signup_without_invite: {{.DisableSignupWithoutInvite}}
|
||||
disable_user_create_org: {{.DisableUserCreateOrg}}
|
||||
allow_raw_resources: true
|
||||
allow_base_domain_resources: true
|
||||
18
install/config/crowdsec/acquis.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
filenames:
|
||||
- /var/log/auth.log
|
||||
- /var/log/syslog
|
||||
labels:
|
||||
type: syslog
|
||||
---
|
||||
poll_without_inotify: false
|
||||
filenames:
|
||||
- /var/log/traefik/*.log
|
||||
labels:
|
||||
type: traefik
|
||||
---
|
||||
listen_addr: 0.0.0.0:7422
|
||||
appsec_config: crowdsecurity/appsec-default
|
||||
name: myAppSecComponent
|
||||
source: appsec
|
||||
labels:
|
||||
type: appsec
|
||||
30
install/config/crowdsec/docker-compose.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
services:
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:latest
|
||||
container_name: crowdsec
|
||||
environment:
|
||||
GID: "1000"
|
||||
COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
|
||||
ENROLL_INSTANCE_NAME: "pangolin-crowdsec"
|
||||
PARSERS: crowdsecurity/whitelists
|
||||
ACQUIRE_FILES: "/var/log/traefik/*.log"
|
||||
ENROLL_TAGS: docker
|
||||
healthcheck:
|
||||
test: ["CMD", "cscli", "capi", "status"]
|
||||
labels:
|
||||
- "traefik.enable=false" # Disable traefik for crowdsec
|
||||
volumes:
|
||||
# crowdsec container data
|
||||
- ./config/crowdsec:/etc/crowdsec # crowdsec config
|
||||
- ./config/crowdsec/db:/var/lib/crowdsec/data # crowdsec db
|
||||
# log bind mounts into crowdsec
|
||||
- ./config/crowdsec_logs/auth.log:/var/log/auth.log:ro # auth.log
|
||||
- ./config/crowdsec_logs/syslog:/var/log/syslog:ro # syslog
|
||||
- ./config/crowdsec_logs:/var/log # crowdsec logs
|
||||
- ./config/traefik/logs:/var/log/traefik # traefik logs
|
||||
ports:
|
||||
- 6060:6060 # metrics endpoint for prometheus
|
||||
expose:
|
||||
- 6060 # metrics endpoint for prometheus
|
||||
restart: unless-stopped
|
||||
command: -t # Add test config flag to verify configuration
|
||||
108
install/config/crowdsec/dynamic_config.yml
Normal file
@@ -0,0 +1,108 @@
|
||||
http:
|
||||
middlewares:
|
||||
redirect-to-https:
|
||||
redirectScheme:
|
||||
scheme: https
|
||||
default-whitelist: # Whitelist middleware for internal IPs
|
||||
ipWhiteList: # Internal IP addresses
|
||||
sourceRange: # Internal IP addresses
|
||||
- "10.0.0.0/8" # Internal IP addresses
|
||||
- "192.168.0.0/16" # Internal IP addresses
|
||||
- "172.16.0.0/12" # Internal IP addresses
|
||||
# Basic security headers
|
||||
security-headers:
|
||||
headers:
|
||||
customResponseHeaders: # Custom response headers
|
||||
Server: "" # Remove server header
|
||||
X-Powered-By: "" # Remove powered by header
|
||||
X-Forwarded-Proto: "https" # Set forwarded proto to https
|
||||
sslProxyHeaders: # SSL proxy headers
|
||||
X-Forwarded-Proto: "https" # Set forwarded proto to https
|
||||
hostsProxyHeaders: # Hosts proxy headers
|
||||
- "X-Forwarded-Host" # Set forwarded host
|
||||
contentTypeNosniff: true # Prevent MIME sniffing
|
||||
customFrameOptionsValue: "SAMEORIGIN" # Set frame options
|
||||
referrerPolicy: "strict-origin-when-cross-origin" # Set referrer policy
|
||||
forceSTSHeader: true # Force STS header
|
||||
stsIncludeSubdomains: true # Include subdomains
|
||||
stsSeconds: 63072000 # STS seconds
|
||||
stsPreload: true # Preload STS
|
||||
# CrowdSec configuration with proper IP forwarding
|
||||
crowdsec:
|
||||
plugin:
|
||||
crowdsec:
|
||||
enabled: true # Enable CrowdSec plugin
|
||||
logLevel: INFO # Log level
|
||||
updateIntervalSeconds: 15 # Update interval
|
||||
updateMaxFailure: 0 # Update max failure
|
||||
defaultDecisionSeconds: 15 # Default decision seconds
|
||||
httpTimeoutSeconds: 10 # HTTP timeout
|
||||
crowdsecMode: live # CrowdSec mode
|
||||
crowdsecAppsecEnabled: true # Enable AppSec
|
||||
crowdsecAppsecHost: crowdsec:7422 # CrowdSec IP address which you noted down later
|
||||
crowdsecAppsecFailureBlock: true # Block on failure
|
||||
crowdsecAppsecUnreachableBlock: true # Block on unreachable
|
||||
crowdsecLapiKey: "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK" # CrowdSec API key which you noted down later
|
||||
crowdsecLapiHost: crowdsec:8080 # CrowdSec
|
||||
crowdsecLapiScheme: http # CrowdSec API scheme
|
||||
forwardedHeadersTrustedIPs: # Forwarded headers trusted IPs
|
||||
- "0.0.0.0/0" # All IP addresses are trusted for forwarded headers (CHANGE MADE HERE)
|
||||
clientTrustedIPs: # Client trusted IPs (CHANGE MADE HERE)
|
||||
- "10.0.0.0/8" # Internal LAN IP addresses
|
||||
- "172.16.0.0/12" # Internal LAN IP addresses
|
||||
- "192.168.0.0/16" # Internal LAN IP addresses
|
||||
- "100.89.137.0/20" # Internal LAN IP addresses
|
||||
|
||||
routers:
|
||||
# HTTP to HTTPS redirect router
|
||||
main-app-router-redirect:
|
||||
rule: "Host(`{{.DashboardDomain}}`)" # Dynamic Domain Name
|
||||
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`)" # Dynamic Domain Name
|
||||
service: next-service
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- security-headers # Add security headers middleware
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
# API router (handles /api/v1 paths)
|
||||
api-router:
|
||||
rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)" # Dynamic Domain Name
|
||||
service: api-service
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- security-headers # Add security headers middleware
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
# WebSocket router
|
||||
ws-router:
|
||||
rule: "Host(`{{.DashboardDomain}}`)" # Dynamic Domain Name
|
||||
service: api-service
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- security-headers # Add security headers middleware
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
services:
|
||||
next-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://pangolin:3002" # Next.js server
|
||||
|
||||
api-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://pangolin:3000" # API/WebSocket server
|
||||
25
install/config/crowdsec/profiles.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
name: captcha_remediation
|
||||
filters:
|
||||
- Alert.Remediation == true && Alert.GetScope() == "Ip" && Alert.GetScenario() contains "http"
|
||||
decisions:
|
||||
- type: captcha
|
||||
duration: 4h
|
||||
on_success: break
|
||||
|
||||
---
|
||||
name: default_ip_remediation
|
||||
filters:
|
||||
- Alert.Remediation == true && Alert.GetScope() == "Ip"
|
||||
decisions:
|
||||
- type: ban
|
||||
duration: 4h
|
||||
on_success: break
|
||||
|
||||
---
|
||||
name: default_range_remediation
|
||||
filters:
|
||||
- Alert.Remediation == true && Alert.GetScope() == "Range"
|
||||
decisions:
|
||||
- type: ban
|
||||
duration: 4h
|
||||
on_success: break
|
||||
87
install/config/crowdsec/traefik_config.yml
Normal file
@@ -0,0 +1,87 @@
|
||||
api:
|
||||
insecure: true
|
||||
dashboard: true
|
||||
|
||||
providers:
|
||||
http:
|
||||
endpoint: "http://pangolin:3001/api/v1/traefik-config"
|
||||
pollInterval: "5s"
|
||||
file:
|
||||
filename: "/etc/traefik/dynamic_config.yml"
|
||||
|
||||
experimental:
|
||||
plugins:
|
||||
badger:
|
||||
moduleName: "github.com/fosrl/badger"
|
||||
version: "{{.BadgerVersion}}"
|
||||
crowdsec: # CrowdSec plugin configuration added
|
||||
moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
version: "v1.3.5"
|
||||
|
||||
log:
|
||||
level: "INFO"
|
||||
format: "json" # Log format changed to json for better parsing
|
||||
|
||||
accessLog: # We enable access logs as json
|
||||
filePath: "/var/log/traefik/access.log"
|
||||
format: json
|
||||
filters:
|
||||
statusCodes:
|
||||
- "200-299" # Success codes
|
||||
- "400-499" # Client errors
|
||||
- "500-599" # Server errors
|
||||
retryAttempts: true
|
||||
minDuration: "100ms" # Increased to focus on slower requests
|
||||
bufferingSize: 100 # Add buffering for better performance
|
||||
fields:
|
||||
defaultMode: drop # Start with dropping all fields
|
||||
names:
|
||||
ClientAddr: keep # Keep client address for IP tracking
|
||||
ClientHost: keep # Keep client host for IP tracking
|
||||
RequestMethod: keep # Keep request method for tracking
|
||||
RequestPath: keep # Keep request path for tracking
|
||||
RequestProtocol: keep # Keep request protocol for tracking
|
||||
DownstreamStatus: keep # Keep downstream status for tracking
|
||||
DownstreamContentSize: keep # Keep downstream content size for tracking
|
||||
Duration: keep # Keep request duration for tracking
|
||||
ServiceName: keep # Keep service name for tracking
|
||||
StartUTC: keep # Keep start time for tracking
|
||||
TLSVersion: keep # Keep TLS version for tracking
|
||||
TLSCipher: keep # Keep TLS cipher for tracking
|
||||
RetryAttempts: keep # Keep retry attempts for tracking
|
||||
headers:
|
||||
defaultMode: drop # Start with dropping all headers
|
||||
names:
|
||||
User-Agent: keep # Keep user agent for tracking
|
||||
X-Real-Ip: keep # Keep real IP for tracking
|
||||
X-Forwarded-For: keep # Keep forwarded IP for tracking
|
||||
X-Forwarded-Proto: keep # Keep forwarded protocol for tracking
|
||||
Content-Type: keep # Keep content type for tracking
|
||||
Authorization: redact # Redact sensitive information
|
||||
Cookie: redact # Redact sensitive information
|
||||
|
||||
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"
|
||||
middlewares:
|
||||
- crowdsec@file
|
||||
|
||||
serversTransport:
|
||||
insecureSkipVerify: true
|
||||
@@ -1,3 +1,4 @@
|
||||
name: pangolin
|
||||
services:
|
||||
pangolin:
|
||||
image: fosrl/pangolin:{{.PangolinVersion}}
|
||||
@@ -10,7 +11,6 @@ services:
|
||||
interval: "3s"
|
||||
timeout: "3s"
|
||||
retries: 5
|
||||
|
||||
{{if .InstallGerbil}}
|
||||
gerbil:
|
||||
image: fosrl/gerbil:{{.GerbilVersion}}
|
||||
@@ -34,15 +34,13 @@ services:
|
||||
- 443:443 # Port for traefik because of the network_mode
|
||||
- 80:80 # Port for traefik because of the network_mode
|
||||
{{end}}
|
||||
|
||||
traefik:
|
||||
image: traefik:v3.1
|
||||
image: traefik:v3.3.3
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
{{if .InstallGerbil}}
|
||||
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||
{{end}}
|
||||
{{if not .InstallGerbil}}
|
||||
{{end}}{{if not .InstallGerbil}}
|
||||
ports:
|
||||
- 443:443
|
||||
- 80:80
|
||||
@@ -55,3 +53,9 @@ services:
|
||||
volumes:
|
||||
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
|
||||
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
||||
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
|
||||
|
||||
networks:
|
||||
default:
|
||||
driver: bridge
|
||||
name: pangolin
|
||||
@@ -3,7 +3,6 @@ http:
|
||||
redirect-to-https:
|
||||
redirectScheme:
|
||||
scheme: https
|
||||
permanent: true
|
||||
|
||||
routers:
|
||||
# HTTP to HTTPS redirect router
|
||||
@@ -13,7 +13,7 @@ experimental:
|
||||
plugins:
|
||||
badger:
|
||||
moduleName: "github.com/fosrl/badger"
|
||||
version: "v1.0.0-beta.2"
|
||||
version: "{{.BadgerVersion}}"
|
||||
|
||||
log:
|
||||
level: "INFO"
|
||||
@@ -33,6 +33,9 @@ entryPoints:
|
||||
address: ":80"
|
||||
websecure:
|
||||
address: ":443"
|
||||
transport:
|
||||
respondingTimeouts:
|
||||
readTimeout: "30m"
|
||||
http:
|
||||
tls:
|
||||
certResolver: "letsencrypt"
|
||||
137
install/crowdsec.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func installCrowdsec(config Config) error {
|
||||
|
||||
if err := stopContainers(); err != nil {
|
||||
return fmt.Errorf("failed to stop containers: %v", err)
|
||||
}
|
||||
|
||||
// Run installation steps
|
||||
if err := backupConfig(); err != nil {
|
||||
return fmt.Errorf("backup failed: %v", err)
|
||||
}
|
||||
|
||||
if err := createConfigFiles(config); err != nil {
|
||||
fmt.Printf("Error creating config files: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
os.MkdirAll("config/crowdsec/db", 0755)
|
||||
os.MkdirAll("config/crowdsec_logs/syslog", 0755)
|
||||
os.MkdirAll("config/traefik/logs", 0755)
|
||||
|
||||
if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil {
|
||||
fmt.Printf("Error copying docker service: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := MergeYAML("config/traefik/traefik_config.yml", "config/crowdsec/traefik_config.yml"); err != nil {
|
||||
fmt.Printf("Error copying entry points: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// delete the 2nd file
|
||||
if err := os.Remove("config/crowdsec/traefik_config.yml"); err != nil {
|
||||
fmt.Printf("Error removing file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := MergeYAML("config/traefik/dynamic_config.yml", "config/crowdsec/dynamic_config.yml"); err != nil {
|
||||
fmt.Printf("Error copying entry points: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// delete the 2nd file
|
||||
if err := os.Remove("config/crowdsec/dynamic_config.yml"); err != nil {
|
||||
fmt.Printf("Error removing file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := os.Remove("config/crowdsec/docker-compose.yml"); err != nil {
|
||||
fmt.Printf("Error removing file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := CheckAndAddTraefikLogVolume("docker-compose.yml"); err != nil {
|
||||
fmt.Printf("Error checking and adding Traefik log volume: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := startContainers(); err != nil {
|
||||
return fmt.Errorf("failed to start containers: %v", err)
|
||||
}
|
||||
|
||||
// get API key
|
||||
apiKey, err := GetCrowdSecAPIKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API key: %v", err)
|
||||
}
|
||||
config.TraefikBouncerKey = apiKey
|
||||
|
||||
if err := replaceInFile("config/traefik/dynamic_config.yml", "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK", config.TraefikBouncerKey); err != nil {
|
||||
return fmt.Errorf("failed to replace bouncer key: %v", err)
|
||||
}
|
||||
|
||||
if err := restartContainer("traefik"); err != nil {
|
||||
return fmt.Errorf("failed to restart containers: %v", err)
|
||||
}
|
||||
|
||||
if checkIfTextInFile("config/traefik/dynamic_config.yml", "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK") {
|
||||
fmt.Println("Failed to replace bouncer key! Please retrieve the key and replace it in the config/traefik/dynamic_config.yml file using the following command:")
|
||||
fmt.Println(" docker exec crowdsec cscli bouncers add traefik-bouncer")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkIsCrowdsecInstalledInCompose() bool {
|
||||
// Read docker-compose.yml
|
||||
content, err := os.ReadFile("docker-compose.yml")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for crowdsec service
|
||||
return bytes.Contains(content, []byte("crowdsec:"))
|
||||
}
|
||||
|
||||
func GetCrowdSecAPIKey() (string, error) {
|
||||
// First, ensure the container is running
|
||||
if err := waitForContainer("crowdsec"); err != nil {
|
||||
return "", fmt.Errorf("waiting for container: %w", err)
|
||||
}
|
||||
|
||||
// Execute the command to get the API key
|
||||
cmd := exec.Command("docker", "exec", "crowdsec", "cscli", "bouncers", "add", "traefik-bouncer", "-o", "raw")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("executing command: %w", err)
|
||||
}
|
||||
|
||||
// Trim any whitespace from the output
|
||||
apiKey := strings.TrimSpace(out.String())
|
||||
if apiKey == "" {
|
||||
return "", fmt.Errorf("empty API key returned")
|
||||
}
|
||||
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
func checkIfTextInFile(file, text string) bool {
|
||||
// Read file
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for text
|
||||
return bytes.Contains(content, []byte(text))
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
app:
|
||||
dashboard_url: https://{{.DashboardDomain}}
|
||||
base_domain: {{.BaseDomain}}
|
||||
log_level: info
|
||||
save_logs: false
|
||||
|
||||
server:
|
||||
external_port: 3000
|
||||
internal_port: 3001
|
||||
next_port: 3002
|
||||
internal_hostname: pangolin
|
||||
secure_cookies: true
|
||||
session_cookie_name: p_session
|
||||
resource_session_cookie_name: p_resource_session
|
||||
resource_access_token_param: p_token
|
||||
cors:
|
||||
origins: ["https://{{.DashboardDomain}}"]
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||
headers: ["X-CSRF-Token", "Content-Type"]
|
||||
credentials: false
|
||||
|
||||
traefik:
|
||||
cert_resolver: letsencrypt
|
||||
http_entrypoint: web
|
||||
https_entrypoint: websecure
|
||||
prefer_wildcard_cert: false
|
||||
|
||||
gerbil:
|
||||
start_port: 51820
|
||||
base_endpoint: {{.DashboardDomain}}
|
||||
use_subdomain: false
|
||||
block_size: 24
|
||||
site_block_size: 30
|
||||
subnet_group: 100.89.137.0/20
|
||||
|
||||
rate_limits:
|
||||
global:
|
||||
window_minutes: 1
|
||||
max_requests: 100
|
||||
{{if .EnableEmail}}
|
||||
email:
|
||||
smtp_host: {{.EmailSMTPHost}}
|
||||
smtp_port: {{.EmailSMTPPort}}
|
||||
smtp_user: {{.EmailSMTPUser}}
|
||||
smtp_pass: {{.EmailSMTPPass}}
|
||||
no_reply: {{.EmailNoReply}}
|
||||
{{end}}
|
||||
users:
|
||||
server_admin:
|
||||
email: {{.AdminUserEmail}}
|
||||
password: {{.AdminUserPassword}}
|
||||
|
||||
flags:
|
||||
require_email_verification: {{.EnableEmail}}
|
||||
disable_signup_without_invite: {{.DisableSignupWithoutInvite}}
|
||||
disable_user_create_org: {{.DisableUserCreateOrg}}
|
||||
@@ -5,4 +5,5 @@ go 1.23.0
|
||||
require (
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/term v0.28.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -2,3 +2,6 @@ golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
12
install/input.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
example.com
|
||||
pangolin.example.com
|
||||
admin@example.com
|
||||
yes
|
||||
admin@example.com
|
||||
Password123!
|
||||
Password123!
|
||||
yes
|
||||
no
|
||||
no
|
||||
no
|
||||
yes
|
||||
377
install/main.go
@@ -2,8 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -12,22 +14,26 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/template"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD
|
||||
func loadVersions(config *Config) {
|
||||
config.PangolinVersion = "1.0.0-beta.6"
|
||||
config.GerbilVersion = "1.0.0-beta.2"
|
||||
config.PangolinVersion = "replaceme"
|
||||
config.GerbilVersion = "replaceme"
|
||||
config.BadgerVersion = "replaceme"
|
||||
}
|
||||
|
||||
//go:embed fs/*
|
||||
//go:embed config/*
|
||||
var configFiles embed.FS
|
||||
|
||||
type Config struct {
|
||||
PangolinVersion string
|
||||
GerbilVersion string
|
||||
BadgerVersion string
|
||||
BaseDomain string
|
||||
DashboardDomain string
|
||||
LetsEncryptEmail string
|
||||
@@ -42,6 +48,8 @@ type Config struct {
|
||||
EmailSMTPPass string
|
||||
EmailNoReply string
|
||||
InstallGerbil bool
|
||||
TraefikBouncerKey string
|
||||
DoCrowdsecInstall bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -53,9 +61,12 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var config Config
|
||||
config.DoCrowdsecInstall = false
|
||||
|
||||
// check if there is already a config file
|
||||
if _, err := os.Stat("config/config.yml"); err != nil {
|
||||
config := collectUserInput(reader)
|
||||
config = collectUserInput(reader)
|
||||
|
||||
loadVersions(&config)
|
||||
|
||||
@@ -64,18 +75,55 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
moveFile("config/docker-compose.yml", "docker-compose.yml")
|
||||
|
||||
if !isDockerInstalled() && runtime.GOOS == "linux" {
|
||||
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
|
||||
installDocker()
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Starting installation ===")
|
||||
|
||||
if isDockerInstalled() {
|
||||
if readBool(reader, "Would you like to install and start the containers?", true) {
|
||||
pullAndStartContainers()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Config file already exists... skipping configuration")
|
||||
fmt.Println("Looks like you already installed, so I am going to do the setup...")
|
||||
}
|
||||
|
||||
if isDockerInstalled() {
|
||||
if readBool(reader, "Would you like to install and start the containers?", true) {
|
||||
pullAndStartContainers()
|
||||
if !checkIsCrowdsecInstalledInCompose() {
|
||||
fmt.Println("\n=== CrowdSec Install ===")
|
||||
// check if crowdsec is installed
|
||||
if readBool(reader, "Would you like to install CrowdSec?", false) {
|
||||
fmt.Println("This installer constitutes a minimal viable CrowdSec deployment. CrowdSec will add extra complexity to your Pangolin installation and may not work to the best of its abilities out of the box. Users are expected to implement configuration adjustments on their own to achieve the best security posture. Consult the CrowdSec documentation for detailed configuration instructions.")
|
||||
if readBool(reader, "Are you willing to manage CrowdSec?", false) {
|
||||
if config.DashboardDomain == "" {
|
||||
traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml", "config/traefik/dynamic_config.yml")
|
||||
if err != nil {
|
||||
fmt.Printf("Error reading config: %v\n", err)
|
||||
return
|
||||
}
|
||||
config.DashboardDomain = traefikConfig.DashboardDomain
|
||||
config.LetsEncryptEmail = traefikConfig.LetsEncryptEmail
|
||||
config.BadgerVersion = traefikConfig.BadgerVersion
|
||||
|
||||
// print the values and check if they are right
|
||||
fmt.Println("Detected values:")
|
||||
fmt.Printf("Dashboard Domain: %s\n", config.DashboardDomain)
|
||||
fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail)
|
||||
fmt.Printf("Badger Version: %s\n", config.BadgerVersion)
|
||||
|
||||
if !readBool(reader, "Are these values correct?", true) {
|
||||
config = collectUserInput(reader)
|
||||
}
|
||||
}
|
||||
|
||||
config.DoCrowdsecInstall = true
|
||||
installCrowdsec(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,22 +144,24 @@ func readString(reader *bufio.Reader, prompt string, defaultValue string) string
|
||||
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 ""
|
||||
func readPassword(prompt string, reader *bufio.Reader) string {
|
||||
if term.IsTerminal(int(syscall.Stdin)) {
|
||||
fmt.Print(prompt + ": ")
|
||||
// Read password without echo if we're in a terminal
|
||||
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, reader)
|
||||
}
|
||||
return input
|
||||
} else {
|
||||
// Fallback to reading from stdin if not in a terminal
|
||||
return readString(reader, prompt, "")
|
||||
}
|
||||
|
||||
input := strings.TrimSpace(string(password))
|
||||
if input == "" {
|
||||
return readPassword(prompt)
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
|
||||
@@ -147,8 +197,8 @@ func collectUserInput(reader *bufio.Reader) Config {
|
||||
fmt.Println("\n=== Admin User Configuration ===")
|
||||
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain)
|
||||
for {
|
||||
pass1 := readPassword("Create admin user password")
|
||||
pass2 := readPassword("Confirm admin user password")
|
||||
pass1 := readPassword("Create admin user password", reader)
|
||||
pass2 := readPassword("Confirm admin user password", reader)
|
||||
|
||||
if pass1 != pass2 {
|
||||
fmt.Println("Passwords do not match")
|
||||
@@ -258,26 +308,33 @@ func createConfigFiles(config Config) error {
|
||||
os.MkdirAll("config/logs", 0755)
|
||||
|
||||
// Walk through all embedded files
|
||||
err := fs.WalkDir(configFiles, "fs", func(path string, d fs.DirEntry, err error) error {
|
||||
err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip the root fs directory itself
|
||||
if path == "fs" {
|
||||
if path == "config" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get the relative path by removing the "fs/" prefix
|
||||
relPath := strings.TrimPrefix(path, "fs/")
|
||||
if !config.DoCrowdsecInstall && strings.Contains(path, "crowdsec") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create the full output path under "config/"
|
||||
outPath := filepath.Join("config", relPath)
|
||||
if config.DoCrowdsecInstall && !strings.Contains(path, "crowdsec") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// skip .DS_Store
|
||||
if strings.Contains(path, ".DS_Store") {
|
||||
return nil
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
// Create directory
|
||||
if err := os.MkdirAll(outPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %v", outPath, err)
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %v", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -295,14 +352,14 @@ func createConfigFiles(config Config) error {
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directory for %s: %v", outPath, err)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directory for %s: %v", path, err)
|
||||
}
|
||||
|
||||
// Create output file
|
||||
outFile, err := os.Create(outPath)
|
||||
outFile, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create %s: %v", outPath, err)
|
||||
return fmt.Errorf("failed to create %s: %v", path, err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
@@ -318,27 +375,6 @@ func createConfigFiles(config Config) error {
|
||||
return fmt.Errorf("error walking config files: %v", err)
|
||||
}
|
||||
|
||||
// get the current directory
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current directory: %v", err)
|
||||
}
|
||||
|
||||
sourcePath := filepath.Join(dir, "config/docker-compose.yml")
|
||||
destPath := filepath.Join(dir, "docker-compose.yml")
|
||||
|
||||
// Check if source file exists
|
||||
if _, err := os.Stat(sourcePath); err != nil {
|
||||
return fmt.Errorf("source docker-compose.yml not found: %v", err)
|
||||
}
|
||||
|
||||
// Try to move the file
|
||||
err = os.Rename(sourcePath, destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to move docker-compose.yml from %s to %s: %v",
|
||||
sourcePath, destPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -374,7 +410,7 @@ func installDocker() error {
|
||||
switch {
|
||||
case strings.Contains(osRelease, "ID=ubuntu"):
|
||||
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
|
||||
apt-get update &&
|
||||
apt-get update &&
|
||||
apt-get install -y apt-transport-https ca-certificates curl software-properties-common &&
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg &&
|
||||
echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list &&
|
||||
@@ -383,7 +419,7 @@ func installDocker() error {
|
||||
`, dockerArch))
|
||||
case strings.Contains(osRelease, "ID=debian"):
|
||||
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
|
||||
apt-get update &&
|
||||
apt-get update &&
|
||||
apt-get install -y apt-transport-https ca-certificates curl software-properties-common &&
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg &&
|
||||
echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list &&
|
||||
@@ -432,29 +468,216 @@ func isDockerInstalled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func getCommandString(useNewStyle bool) string {
|
||||
if useNewStyle {
|
||||
return "'docker compose'"
|
||||
}
|
||||
return "'docker-compose'"
|
||||
}
|
||||
|
||||
func pullAndStartContainers() error {
|
||||
fmt.Println("Starting containers...")
|
||||
|
||||
// First try docker compose (new style)
|
||||
cmd := exec.Command("docker", "compose", "-f", "docker-compose.yml", "pull")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
err := cmd.Run()
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Failed to start containers using docker compose, falling back to docker-compose command")
|
||||
os.Exit(1)
|
||||
// Check which docker compose command is available
|
||||
var useNewStyle bool
|
||||
checkCmd := exec.Command("docker", "compose", "version")
|
||||
if err := checkCmd.Run(); err == nil {
|
||||
useNewStyle = true
|
||||
} else {
|
||||
// Check if docker-compose (old style) is available
|
||||
checkCmd = exec.Command("docker-compose", "version")
|
||||
if err := checkCmd.Run(); err != nil {
|
||||
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cmd = exec.Command("docker", "compose", "-f", "docker-compose.yml", "up", "-d")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
err = cmd.Run()
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Failed to start containers using docker-compose command")
|
||||
os.Exit(1)
|
||||
// Helper function to execute docker compose commands
|
||||
executeCommand := func(args ...string) error {
|
||||
var cmd *exec.Cmd
|
||||
if useNewStyle {
|
||||
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||
} else {
|
||||
cmd = exec.Command("docker-compose", args...)
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// bring containers down
|
||||
func stopContainers() error {
|
||||
fmt.Println("Stopping containers...")
|
||||
|
||||
// Check which docker compose command is available
|
||||
var useNewStyle bool
|
||||
checkCmd := exec.Command("docker", "compose", "version")
|
||||
if err := checkCmd.Run(); err == nil {
|
||||
useNewStyle = true
|
||||
} else {
|
||||
// Check if docker-compose (old style) is available
|
||||
checkCmd = exec.Command("docker-compose", "version")
|
||||
if err := checkCmd.Run(); err != nil {
|
||||
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to execute docker compose commands
|
||||
executeCommand := func(args ...string) error {
|
||||
var cmd *exec.Cmd
|
||||
if useNewStyle {
|
||||
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||
} else {
|
||||
cmd = exec.Command("docker-compose", args...)
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
if err := executeCommand("-f", "docker-compose.yml", "down"); err != nil {
|
||||
return fmt.Errorf("failed to stop containers: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// just start containers
|
||||
func startContainers() error {
|
||||
fmt.Println("Starting containers...")
|
||||
|
||||
// Check which docker compose command is available
|
||||
var useNewStyle bool
|
||||
checkCmd := exec.Command("docker", "compose", "version")
|
||||
if err := checkCmd.Run(); err == nil {
|
||||
useNewStyle = true
|
||||
} else {
|
||||
// Check if docker-compose (old style) is available
|
||||
checkCmd = exec.Command("docker-compose", "version")
|
||||
if err := checkCmd.Run(); err != nil {
|
||||
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to execute docker compose commands
|
||||
executeCommand := func(args ...string) error {
|
||||
var cmd *exec.Cmd
|
||||
if useNewStyle {
|
||||
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||
} else {
|
||||
cmd = exec.Command("docker-compose", args...)
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
if err := executeCommand("-f", "docker-compose.yml", "up", "-d"); err != nil {
|
||||
return fmt.Errorf("failed to start containers: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func restartContainer(container string) error {
|
||||
fmt.Printf("Restarting %s container...\n", container)
|
||||
|
||||
// Check which docker compose command is available
|
||||
var useNewStyle bool
|
||||
checkCmd := exec.Command("docker", "compose", "version")
|
||||
if err := checkCmd.Run(); err == nil {
|
||||
useNewStyle = true
|
||||
} else {
|
||||
// Check if docker-compose (old style) is available
|
||||
checkCmd = exec.Command("docker-compose", "version")
|
||||
if err := checkCmd.Run(); err != nil {
|
||||
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to execute docker compose commands
|
||||
executeCommand := func(args ...string) error {
|
||||
var cmd *exec.Cmd
|
||||
if useNewStyle {
|
||||
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||
} else {
|
||||
cmd = exec.Command("docker-compose", args...)
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
if err := executeCommand("-f", "docker-compose.yml", "restart", container); err != nil {
|
||||
return fmt.Errorf("failed to restart %s container: %v", container, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
source, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
destination, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destination.Close()
|
||||
|
||||
_, err = io.Copy(destination, source)
|
||||
return err
|
||||
}
|
||||
|
||||
func moveFile(src, dst string) error {
|
||||
if err := copyFile(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Remove(src)
|
||||
}
|
||||
|
||||
func waitForContainer(containerName string) error {
|
||||
maxAttempts := 30
|
||||
retryInterval := time.Second * 2
|
||||
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
// Check if container is running
|
||||
cmd := exec.Command("docker", "container", "inspect", "-f", "{{.State.Running}}", containerName)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
// If the container doesn't exist or there's another error, wait and retry
|
||||
time.Sleep(retryInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
isRunning := strings.TrimSpace(out.String()) == "true"
|
||||
if isRunning {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Container exists but isn't running yet, wait and retry
|
||||
time.Sleep(retryInterval)
|
||||
}
|
||||
|
||||
return fmt.Errorf("container %s did not start within %v seconds", containerName, maxAttempts*int(retryInterval.Seconds()))
|
||||
}
|
||||
|
||||
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 | |
|
||||
291
internationalization/es.md
Normal file
@@ -0,0 +1,291 @@
|
||||
## Authentication Site
|
||||
|
||||
|
||||
| EN | ES | Notes |
|
||||
| -------------------------------------------------------- | ------------------------------------------------------------ | ---------- |
|
||||
| Powered by [Pangolin](https://github.com/fosrl/pangolin) | Desarrollado por [Pangolin](https://github.com/fosrl/pangolin) | |
|
||||
| Authentication Required | Se requiere autenticación | |
|
||||
| Choose your preferred method to access {resource} | Elije tu método requerido para acceder a {resource} | |
|
||||
| PIN | PIN | |
|
||||
| User | Usuario | |
|
||||
| 6-digit PIN Code | Código PIN de 6 dígitos | pin login |
|
||||
| Login in with PIN | Registrate con PIN | pin login |
|
||||
| Email | Email | user login |
|
||||
| Enter your email | Introduce tu email | user login |
|
||||
| Password | Contraseña | user login |
|
||||
| Enter your password | Introduce tu contraseña | user login |
|
||||
| Forgot your password? | ¿Olvidaste tu contraseña? | user login |
|
||||
| Log in | Iniciar sesión | user login |
|
||||
|
||||
|
||||
## Login site
|
||||
|
||||
| EN | ES | Notes |
|
||||
| --------------------- | ---------------------------------- | ----------- |
|
||||
| Welcome to Pangolin | Binvenido a Pangolin | |
|
||||
| Log in to get started | Registrate para comenzar | |
|
||||
| Email | Email | |
|
||||
| Enter your email | Introduce tu email | placeholder |
|
||||
| Password | Contraseña | |
|
||||
| Enter your password | Introduce tu contraseña | placeholder |
|
||||
| Forgot your password? | ¿Olvidaste tu contraseña? | |
|
||||
| Log in | Iniciar sesión | |
|
||||
|
||||
# Ogranization site after successful login
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ----------------------------------------- | -------------------------------------------- | ----- |
|
||||
| Welcome to Pangolin | Binvenido a Pangolin | |
|
||||
| You're a member of {number} organization. | Eres miembro de la organización {number}. | |
|
||||
|
||||
## Shared Header, Navbar and Footer
|
||||
##### Header
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------- | ------------------- | ----- |
|
||||
| Documentation | Documentación | |
|
||||
| Support | Soporte | |
|
||||
| Organization {name} | Organización {name} | |
|
||||
##### Organization selector
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ---------------- | ----------------- | ----- |
|
||||
| Search… | Buscar… | |
|
||||
| Create | Crear | |
|
||||
| New Organization | Nueva Organización| |
|
||||
| Organizations | Organizaciones | |
|
||||
|
||||
##### Navbar
|
||||
|
||||
| EN | ES | Notes |
|
||||
| --------------- | -----------------------| ----- |
|
||||
| Sites | Sitios | |
|
||||
| Resources | Recursos | |
|
||||
| User & Roles | Usuarios y roles | |
|
||||
| Shareable Links | Enlaces para compartir | |
|
||||
| General | General | |
|
||||
|
||||
##### Footer
|
||||
| EN | ES | |
|
||||
| ------------------------- | --------------------------- | -------|
|
||||
| Page {number} of {number} | Página {number} de {number} | footer |
|
||||
| Rows per page | Filas por página | footer |
|
||||
| Pangolin | Pangolin | footer |
|
||||
| Built by Fossorial | Construido por Fossorial | footer |
|
||||
| Open Source | Código abierto | footer |
|
||||
| Documentation | Documentación | footer |
|
||||
| {version} | {version} | footer |
|
||||
|
||||
## Main “Sites”
|
||||
##### “Hero” section
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||
| Newt (Recommended) | Newt (Recomendado) | |
|
||||
| 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. | Para obtener la mejor experiencia de usuario, utiliza Newt. Utiliza WireGuard internamente y te permite abordar tus recursos privados mediante tu dirección LAN en tu red privada desde el panel de Pangolin. | |
|
||||
| Runs in Docker | Se ejecuta en Docker | |
|
||||
| Runs in shell on macOS, Linux, and Windows | Se ejecuta en shell en macOS, Linux y Windows | |
|
||||
| Install Newt | Instalar Newt | |
|
||||
| Basic WireGuard<br> | WireGuard básico<br> | |
|
||||
| Compatible with all WireGuard clients<br> | Compatible con todos los clientes WireGuard<br> | |
|
||||
| Manual configuration required | Se requiere configuración manual | |
|
||||
|
||||
##### Content
|
||||
|
||||
| EN | ES | Notes |
|
||||
| --------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------- |
|
||||
| Manage Sites | Administrar sitios | |
|
||||
| Allow connectivity to your network through secure tunnels | Permitir la conectividad a tu red a través de túneles seguros| |
|
||||
| Search sites | Buscar sitios | placeholder |
|
||||
| Add Site | Agregar sitio | |
|
||||
| Name | Nombre | table header |
|
||||
| Online | Conectado | table header |
|
||||
| Site | Sitio | table header |
|
||||
| Data In | Datos en | table header |
|
||||
| Data Out | Datos de salida | table header |
|
||||
| Connection Type | Tipo de conexión | table header |
|
||||
| Online | Conectado | site state |
|
||||
| Offline | Desconectado | site state |
|
||||
| Edit → | Editar → | |
|
||||
| View settings | Ver configuración | Popup after clicking “…” on site |
|
||||
| Delete | Borrar | Popup after clicking “…” on site |
|
||||
|
||||
##### Add Site Popup
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------ | ----------------------------------------------------------- | ----------- |
|
||||
| Create Site | Crear sitio | |
|
||||
| Create a new site to start connection for this site | Crear un nuevo sitio para iniciar la conexión para este sitio | |
|
||||
| Name | Nombre | |
|
||||
| Site name | Nombre del sitio | placeholder |
|
||||
| This is the name that will be displayed for this site. | Este es el nombre que se mostrará para este sitio. | desc |
|
||||
| Method | Método | |
|
||||
| Local | Local | |
|
||||
| Newt | Newt | |
|
||||
| WireGuard | WireGuard | |
|
||||
| This is how you will expose connections. | Así es como expondrás las conexiones. | |
|
||||
| You will only be able to see the configuration once. | Solo podrás ver la configuración una vez. | |
|
||||
| Learn how to install Newt on your system | Aprende a instalar Newt en tu sistema | |
|
||||
| I have copied the config | He copiado la configuración | |
|
||||
| Create Site | Crear sitio | |
|
||||
| Close | Cerrar | |
|
||||
|
||||
## Main “Resources”
|
||||
|
||||
##### “Hero” section
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||
| Resources | Recursos | |
|
||||
| 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. |Los recursos son servidores proxy para aplicaciones que se ejecutan en su red privada. Cree un recurso para cada aplicación HTTP o HTTPS en su red privada. Cada recurso debe estar conectado a un sitio web para proporcionar una conexión privada y segura a través del túnel cifrado WireGuard. | |
|
||||
| Secure connectivity with WireGuard encryption | Conectividad segura con encriptación WireGuard | |
|
||||
| Configure multiple authentication methods | Configura múltiples métodos de autenticación | |
|
||||
| User and role-based access control | Control de acceso basado en usuarios y roles | |
|
||||
|
||||
##### Content
|
||||
|
||||
| EN | ES | Notes |
|
||||
| -------------------------------------------------- | ---------------------------------------------------------- | -------------------- |
|
||||
| Manage Resources | Administrar recursos | |
|
||||
| Create secure proxies to your private applications | Crea servidores proxy seguros para tus aplicaciones privadas | |
|
||||
| Search resources | Buscar recursos | placeholder |
|
||||
| Name | Nombre | |
|
||||
| Site | Sitio | |
|
||||
| Full URL | URL completa | |
|
||||
| Authentication | Autenticación | |
|
||||
| Not Protected | No protegido | authentication state |
|
||||
| Protected | Protegido | authentication state |
|
||||
| Edit → | Editar → | |
|
||||
| Add Resource | Agregar recurso | |
|
||||
|
||||
##### Add Resource Popup
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------- |
|
||||
| Create Resource | Crear recurso | |
|
||||
| Create a new resource to proxy request to your app | Crea un nuevo recurso para enviar solicitudes a tu aplicación | |
|
||||
| Name | Nombre | |
|
||||
| My Resource | Mi recurso | name placeholder |
|
||||
| This is the name that will be displayed for this resource. | Este es el nombre que se mostrará para este recurso. | |
|
||||
| Subdomain | Subdominio | |
|
||||
| Enter subdomain | Ingresar subdominio | |
|
||||
| This is the fully qualified domain name that will be used to access the resource. | Este es el nombre de dominio completo que se utilizará para acceder al recurso. | |
|
||||
| Site | Sitio | |
|
||||
| Search site… | Buscar sitio… | Site selector popup |
|
||||
| This is the site that will be used in the dashboard. | Este es el sitio que se utilizará en el panel de control. | |
|
||||
| Create Resource | Crear recurso | |
|
||||
| Close | Cerrar | |
|
||||
|
||||
## Main “User & Roles”
|
||||
##### Content
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------- |
|
||||
| Manage User & Roles | Administrar usuarios y roles | |
|
||||
| Invite users and add them to roles to manage access to your organization | Invita a usuarios y agrégalos a roles para administrar el acceso a tu organización | |
|
||||
| Users | Usuarios | sidebar item |
|
||||
| Roles | Roles | sidebar item |
|
||||
| **User tab** | **Pestaña de usuario** | |
|
||||
| Search users | Buscar usuarios | placeholder |
|
||||
| Invite User | Invitar usuario | addbutton |
|
||||
| Email | Email | table header |
|
||||
| Status | Estado | table header |
|
||||
| Role | Role | table header |
|
||||
| Confirmed | Confirmado | account status |
|
||||
| Not confirmed (?) | No confirmado (?) | unknown for me account status |
|
||||
| Owner | Dueño | role |
|
||||
| Admin | Administrador | role |
|
||||
| Member | Miembro | role |
|
||||
| **Roles Tab** | **Pestaña Roles** | |
|
||||
| Search roles | Buscar roles | placeholder |
|
||||
| Add Role | Agregar rol | addbutton |
|
||||
| Name | Nombre | table header |
|
||||
| Description | Descripción | table header |
|
||||
| Admin | Administrador | role |
|
||||
| Member | Miembro | role |
|
||||
| Admin role with the most permissions | Rol de administrador con más permisos | admin role desc |
|
||||
| Members can only view resources | Los miembros sólo pueden ver los recursos | member role desc |
|
||||
|
||||
##### Invite User popup
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ----------------- | ------------------------------------------------------- | ----------- |
|
||||
| Invite User | Invitar usuario | |
|
||||
| Email | Email | |
|
||||
| Enter an email | Introduzca un email | placeholder |
|
||||
| Role | Rol | |
|
||||
| Select role | Seleccionar rol | placeholder |
|
||||
| Gültig für | Válido para | |
|
||||
| 1 day | 1 día | |
|
||||
| 2 days | 2 días | |
|
||||
| 3 days | 3 días | |
|
||||
| 4 days | 4 días | |
|
||||
| 5 days | 5 días | |
|
||||
| 6 days | 6 días | |
|
||||
| 7 days | 7 días | |
|
||||
| Create Invitation | Crear invitación | |
|
||||
| Close | Cerrar | |
|
||||
|
||||
|
||||
## Main “Shareable Links”
|
||||
##### “Hero” section
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||
| Shareable Links | Enlaces para compartir | |
|
||||
| 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. | Crear enlaces que se puedan compartir a tus recursos. Los enlaces proporcionan acceso temporal o ilimitado a tu recurso. Puedes configurar la duración de caducidad del enlace cuando lo creas. | |
|
||||
| Easy to create and share | Fácil de crear y compartir | |
|
||||
| Configurable expiration duration | Duración de expiración configurable | |
|
||||
| Secure and revocable | Seguro y revocable | |
|
||||
##### Content
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------- |
|
||||
| Manage Shareable Links | Administrar enlaces compartibles | |
|
||||
| Create shareable links to grant temporary or permanent access to your resources | Crear enlaces compartibles para otorgar acceso temporal o permanente a tus recursos | |
|
||||
| Search links | Buscar enlaces | placeholder |
|
||||
| Create Share Link | Crear enlace para compartir | addbutton |
|
||||
| Resource | Recurso | table header |
|
||||
| Title | Título | table header |
|
||||
| Created | Creado | table header |
|
||||
| Expires | Caduca | table header |
|
||||
| No links. Create one to get started. | No hay enlaces. Crea uno para comenzar. | table placeholder |
|
||||
|
||||
##### Create Shareable Link popup
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------- |
|
||||
| Create Shareable Link | Crear un enlace para compartir | |
|
||||
| Anyone with this link can access the resource | Cualquier persona con este enlace puede acceder al recurso. | |
|
||||
| Resource | Recurso | |
|
||||
| Select resource | Seleccionar recurso | |
|
||||
| Search resources… | Buscar recursos… | resource selector popup |
|
||||
| Title (optional) | Título (opcional) | |
|
||||
| Enter title | Introducir título | placeholder |
|
||||
| Expire in | Caduca en | |
|
||||
| Minutes | Minutos | |
|
||||
| Hours | Horas | |
|
||||
| Days | Días | |
|
||||
| Months | Meses | |
|
||||
| Years | Años | |
|
||||
| Never expire | Nunca caduca | |
|
||||
| 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. | El tiempo de expiración es el tiempo durante el cual el enlace se podrá utilizar y brindará acceso al recurso. Después de este tiempo, el enlace dejará de funcionar y los usuarios que lo hayan utilizado perderán el acceso al recurso. | |
|
||||
| Create Link | Crear enlace | |
|
||||
| Close | Cerrar | |
|
||||
|
||||
|
||||
## Main “General”
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------ |
|
||||
| General | General | |
|
||||
| Configure your organization’s general settings | Configura los ajustes generales de tu organización | |
|
||||
| General | General | sidebar item |
|
||||
| Organization Settings | Configuración de la organización | |
|
||||
| Manage your organization details and configuration | Administra los detalles y la configuración de tu organización| |
|
||||
| Name | Nombre | |
|
||||
| This is the display name of the org | Este es el nombre para mostrar de la organización. | |
|
||||
| Save Settings | Guardar configuración | |
|
||||
| Danger Zone | Zona de peligro | |
|
||||
| Once you delete this org, there is no going back. Please be certain. | Una vez que elimines esta organización, no habrá vuelta atrás. Asegúrate de hacerlo. | |
|
||||
| Delete Organization Data | Eliminar datos de la organización | |
|
||||
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 | |
|
||||
@@ -2,7 +2,8 @@
|
||||
const nextConfig = {
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
}
|
||||
},
|
||||
output: "standalone"
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
17119
package-lock.json
generated
Normal file
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fosrl/pangolin",
|
||||
"version": "1.0.0-beta.6",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
|
||||
@@ -50,7 +50,6 @@
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.5",
|
||||
"drizzle-orm": "0.38.3",
|
||||
"emblor": "1.4.7",
|
||||
"eslint": "9.17.0",
|
||||
"eslint-config-next": "15.1.3",
|
||||
"express": "4.21.2",
|
||||
@@ -58,19 +57,24 @@
|
||||
"glob": "11.0.0",
|
||||
"helmet": "8.0.0",
|
||||
"http-errors": "2.0.0",
|
||||
"i": "^0.3.7",
|
||||
"input-otp": "1.4.1",
|
||||
"js-yaml": "4.1.0",
|
||||
"lucide-react": "0.469.0",
|
||||
"moment": "2.30.1",
|
||||
"next": "15.1.3",
|
||||
"next-themes": "0.4.4",
|
||||
"node-cache": "5.1.2",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "6.9.16",
|
||||
"npm": "^11.2.0",
|
||||
"oslo": "1.2.1",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-easy-sort": "^1.6.0",
|
||||
"react-hook-form": "7.54.2",
|
||||
"react-icons": "^5.5.0",
|
||||
"rebuild": "0.1.2",
|
||||
"semver": "7.6.3",
|
||||
"tailwind-merge": "2.6.0",
|
||||
|
||||
BIN
public/logo/word_mark.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 577 KiB |
BIN
public/screenshots/collage.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 447 KiB |
BIN
public/screenshots/resources.png
Normal file
|
After Width: | Height: | Size: 706 KiB |
|
Before Width: | Height: | Size: 484 KiB |
|
Before Width: | Height: | Size: 438 KiB After Width: | Height: | Size: 729 KiB |
|
Before Width: | Height: | Size: 415 KiB |
@@ -51,13 +51,18 @@ export enum ActionsEnum {
|
||||
// removeUserAction = "removeUserAction",
|
||||
// removeUserSite = "removeUserSite",
|
||||
getOrgUser = "getOrgUser",
|
||||
"setResourcePassword" = "setResourcePassword",
|
||||
"setResourcePincode" = "setResourcePincode",
|
||||
"setResourceWhitelist" = "setResourceWhitelist",
|
||||
"getResourceWhitelist" = "getResourceWhitelist",
|
||||
"generateAccessToken" = "generateAccessToken",
|
||||
"deleteAcessToken" = "deleteAcessToken",
|
||||
"listAccessTokens" = "listAccessTokens"
|
||||
setResourcePassword = "setResourcePassword",
|
||||
setResourcePincode = "setResourcePincode",
|
||||
setResourceWhitelist = "setResourceWhitelist",
|
||||
getResourceWhitelist = "getResourceWhitelist",
|
||||
generateAccessToken = "generateAccessToken",
|
||||
deleteAcessToken = "deleteAcessToken",
|
||||
listAccessTokens = "listAccessTokens",
|
||||
createResourceRule = "createResourceRule",
|
||||
deleteResourceRule = "deleteResourceRule",
|
||||
listResourceRules = "listResourceRules",
|
||||
updateResourceRule = "updateResourceRule",
|
||||
listOrgDomains = "listOrgDomains",
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
||||
@@ -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
|
||||
.string()
|
||||
.min(8, { message: "Password must be at least 8 characters long" })
|
||||
.max(64, { message: "Password must be at most 64 characters long" })
|
||||
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$/, {
|
||||
.max(128, { message: "Password must be at most 128 characters long" })
|
||||
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[~!`@#$%^&*()_\-+={}[\]|\\:;"'<>,.\/?]).*$/, {
|
||||
message: `Your password must meet the following conditions:
|
||||
at least one uppercase English letter,
|
||||
at least one lowercase English letter,
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function sendResourceOtpEmail(
|
||||
}),
|
||||
{
|
||||
to: email,
|
||||
from: config.getRawConfig().email?.no_reply,
|
||||
from: config.getNoReplyEmail(),
|
||||
subject: `Your one-time code to access ${resourceName}`
|
||||
}
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function sendEmailVerificationCode(
|
||||
}),
|
||||
{
|
||||
to: email,
|
||||
from: config.getRawConfig().email?.no_reply,
|
||||
from: config.getNoReplyEmail(),
|
||||
subject: "Verify your email address"
|
||||
}
|
||||
);
|
||||
|
||||
@@ -3,9 +3,15 @@ import {
|
||||
encodeHexLowerCase
|
||||
} from "@oslojs/encoding";
|
||||
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 { eq } from "drizzle-orm";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import config from "@server/lib/config";
|
||||
import type { RandomReader } from "@oslojs/crypto/random";
|
||||
import { generateRandomString } from "@oslojs/crypto/random";
|
||||
@@ -13,9 +19,13 @@ import logger from "@server/logger";
|
||||
|
||||
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 const SESSION_COOKIE_EXPIRES =
|
||||
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 {
|
||||
const bytes = new Uint8Array(20);
|
||||
@@ -65,47 +75,73 @@ export async function validateSessionToken(
|
||||
session.expiresAt = new Date(
|
||||
Date.now() + SESSION_COOKIE_EXPIRES
|
||||
).getTime();
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({
|
||||
expiresAt: session.expiresAt
|
||||
})
|
||||
.where(eq(sessions.sessionId, session.sessionId));
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.update(sessions)
|
||||
.set({
|
||||
expiresAt: session.expiresAt
|
||||
})
|
||||
.where(eq(sessions.sessionId, session.sessionId));
|
||||
|
||||
await trx
|
||||
.update(resourceSessions)
|
||||
.set({
|
||||
expiresAt: session.expiresAt
|
||||
})
|
||||
.where(eq(resourceSessions.userSessionId, session.sessionId));
|
||||
});
|
||||
}
|
||||
return { session, user };
|
||||
}
|
||||
|
||||
export async function invalidateSession(sessionId: string): Promise<void> {
|
||||
await db.delete(sessions).where(eq(sessions.sessionId, sessionId));
|
||||
try {
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(resourceSessions)
|
||||
.where(eq(resourceSessions.userSessionId, sessionId));
|
||||
await trx.delete(sessions).where(eq(sessions.sessionId, sessionId));
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("Failed to invalidate session", e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function invalidateAllSessions(userId: string): Promise<void> {
|
||||
await db.delete(sessions).where(eq(sessions.userId, userId));
|
||||
try {
|
||||
await db.transaction(async (trx) => {
|
||||
const userSessions = await trx
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.userId, userId));
|
||||
await trx.delete(resourceSessions).where(
|
||||
inArray(
|
||||
resourceSessions.userSessionId,
|
||||
userSessions.map((s) => s.sessionId)
|
||||
)
|
||||
);
|
||||
await trx.delete(sessions).where(eq(sessions.userId, userId));
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("Failed to all invalidate user sessions", e);
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeSessionCookie(
|
||||
token: string,
|
||||
isSecure: boolean
|
||||
isSecure: boolean,
|
||||
expiresAt: Date
|
||||
): string {
|
||||
if (isSecure) {
|
||||
logger.debug("Setting cookie for secure origin");
|
||||
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}`;
|
||||
}
|
||||
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
||||
} else {
|
||||
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`;
|
||||
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/;`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createBlankSessionTokenCookie(isSecure: boolean): string {
|
||||
if (isSecure) {
|
||||
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}`;
|
||||
}
|
||||
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
||||
} else {
|
||||
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/;`;
|
||||
}
|
||||
|
||||
@@ -6,19 +6,19 @@ import { eq, and } from "drizzle-orm";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
export const SESSION_COOKIE_NAME =
|
||||
config.getRawConfig().server.resource_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();
|
||||
config.getRawConfig().server.session_cookie_name;
|
||||
export const SESSION_COOKIE_EXPIRES =
|
||||
1000 * 60 * 60 * config.getRawConfig().server.resource_session_length_hours;
|
||||
|
||||
export async function createResourceSession(opts: {
|
||||
token: string;
|
||||
resourceId: number;
|
||||
passwordId?: number;
|
||||
pincodeId?: number;
|
||||
whitelistId?: number;
|
||||
accessTokenId?: string;
|
||||
usedOtp?: boolean;
|
||||
isRequestToken?: boolean;
|
||||
passwordId?: number | null;
|
||||
pincodeId?: number | null;
|
||||
userSessionId?: string | null;
|
||||
whitelistId?: number | null;
|
||||
accessTokenId?: string | null;
|
||||
doNotExtend?: boolean;
|
||||
expiresAt?: number | null;
|
||||
sessionLength?: number | null;
|
||||
@@ -27,7 +27,8 @@ export async function createResourceSession(opts: {
|
||||
!opts.passwordId &&
|
||||
!opts.pincodeId &&
|
||||
!opts.whitelistId &&
|
||||
!opts.accessTokenId
|
||||
!opts.accessTokenId &&
|
||||
!opts.userSessionId
|
||||
) {
|
||||
throw new Error("Auth method must be provided");
|
||||
}
|
||||
@@ -47,7 +48,9 @@ export async function createResourceSession(opts: {
|
||||
pincodeId: opts.pincodeId || null,
|
||||
whitelistId: opts.whitelistId || null,
|
||||
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);
|
||||
@@ -162,22 +165,34 @@ export async function invalidateAllSessions(
|
||||
|
||||
export function serializeResourceSessionCookie(
|
||||
cookieName: string,
|
||||
token: string
|
||||
domain: string,
|
||||
token: string,
|
||||
isHttp: boolean = false,
|
||||
expiresAt?: Date
|
||||
): string {
|
||||
if (SECURE_COOKIES) {
|
||||
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
||||
const now = new Date().getTime();
|
||||
if (!isHttp) {
|
||||
if (expiresAt === undefined) {
|
||||
return `${cookieName}_s.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Secure; Domain=${"." + domain}`;
|
||||
}
|
||||
return `${cookieName}_s.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${"." + domain}`;
|
||||
} else {
|
||||
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
|
||||
if (expiresAt === undefined) {
|
||||
return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=${"." + domain}`;
|
||||
}
|
||||
return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Domain=${"." + domain}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createBlankResourceSessionTokenCookie(
|
||||
cookieName: string
|
||||
cookieName: string,
|
||||
domain: string,
|
||||
isHttp: boolean = false
|
||||
): string {
|
||||
if (SECURE_COOKIES) {
|
||||
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
||||
if (!isHttp) {
|
||||
return `${cookieName}_s=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${"." + domain}`;
|
||||
} else {
|
||||
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
|
||||
return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Domain=${"." + domain}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const domains = sqliteTable("domains", {
|
||||
domainId: text("domainId").primaryKey(),
|
||||
baseDomain: text("baseDomain").notNull(),
|
||||
configManaged: integer("configManaged", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false)
|
||||
});
|
||||
|
||||
export const orgs = sqliteTable("orgs", {
|
||||
orgId: text("orgId").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
domain: text("domain").notNull()
|
||||
name: text("name").notNull()
|
||||
});
|
||||
|
||||
export const orgDomains = sqliteTable("orgDomains", {
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
domainId: text("domainId")
|
||||
.notNull()
|
||||
.references(() => domains.domainId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const sites = sqliteTable("sites", {
|
||||
@@ -41,14 +57,24 @@ export const resources = sqliteTable("resources", {
|
||||
})
|
||||
.notNull(),
|
||||
name: text("name").notNull(),
|
||||
subdomain: text("subdomain").notNull(),
|
||||
fullDomain: text("fullDomain").notNull().unique(),
|
||||
subdomain: text("subdomain"),
|
||||
fullDomain: text("fullDomain"),
|
||||
domainId: text("domainId").references(() => domains.domainId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||
blockAccess: integer("blockAccess", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
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" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
isBaseDomain: integer("isBaseDomain", { mode: "boolean" }),
|
||||
applyRules: integer("applyRules", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false)
|
||||
});
|
||||
@@ -61,10 +87,9 @@ export const targets = sqliteTable("targets", {
|
||||
})
|
||||
.notNull(),
|
||||
ip: text("ip").notNull(),
|
||||
method: text("method").notNull(),
|
||||
method: text("method"),
|
||||
port: integer("port").notNull(),
|
||||
internalPort: integer("internalPort"),
|
||||
protocol: text("protocol"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
|
||||
});
|
||||
|
||||
@@ -313,6 +338,10 @@ export const resourceSessions = sqliteTable("resourceSessions", {
|
||||
doNotExtend: integer("doNotExtend", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
isRequestToken: integer("isRequestToken", { mode: "boolean" }),
|
||||
userSessionId: text("userSessionId").references(() => sessions.sessionId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
passwordId: integer("passwordId").references(
|
||||
() => resourcePassword.passwordId,
|
||||
{
|
||||
@@ -364,6 +393,27 @@ export const versionMigrations = sqliteTable("versionMigrations", {
|
||||
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 const supporterKey = sqliteTable("supporterKey", {
|
||||
keyId: integer("keyId").primaryKey({ autoIncrement: true }),
|
||||
key: text("key").notNull(),
|
||||
githubUsername: text("githubUsername").notNull(),
|
||||
phrase: text("phrase"),
|
||||
tier: text("tier"),
|
||||
valid: integer("valid", { mode: "boolean" }).notNull().default(false)
|
||||
});
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -396,3 +446,6 @@ export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
|
||||
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
|
||||
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
||||
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
||||
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
||||
export type Domain = InferSelectModel<typeof domains>;
|
||||
export type SupporterKey = InferSelectModel<typeof supporterKey>;
|
||||
|
||||
@@ -3,30 +3,34 @@ export * from "@server/emails/sendEmail";
|
||||
import nodemailer from "nodemailer";
|
||||
import config from "@server/lib/config";
|
||||
import logger from "@server/logger";
|
||||
import SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||
|
||||
function createEmailClient() {
|
||||
const emailConfig = config.getRawConfig().email;
|
||||
if (
|
||||
!emailConfig?.smtp_host ||
|
||||
!emailConfig?.smtp_pass ||
|
||||
!emailConfig?.smtp_port ||
|
||||
!emailConfig?.smtp_user
|
||||
) {
|
||||
logger.warn(
|
||||
"Email SMTP configuration is missing. Emails will not be sent.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!emailConfig) {
|
||||
logger.warn(
|
||||
"Email SMTP configuration is missing. Emails will not be sent."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
return nodemailer.createTransport({
|
||||
const settings = {
|
||||
host: emailConfig.smtp_host,
|
||||
port: emailConfig.smtp_port,
|
||||
secure: false,
|
||||
secure: emailConfig.smtp_secure || false,
|
||||
auth: {
|
||||
user: emailConfig.smtp_user,
|
||||
pass: emailConfig.smtp_pass,
|
||||
},
|
||||
});
|
||||
pass: emailConfig.smtp_pass
|
||||
}
|
||||
} as SMTPTransport.Options;
|
||||
|
||||
if (emailConfig.smtp_tls_reject_unauthorized !== undefined) {
|
||||
settings.tls = {
|
||||
rejectUnauthorized: emailConfig.smtp_tls_reject_unauthorized
|
||||
};
|
||||
}
|
||||
|
||||
return nodemailer.createTransport(settings);
|
||||
}
|
||||
|
||||
export const emailClient = createEmailClient();
|
||||
|
||||
@@ -44,7 +44,7 @@ export const ResourceOTPCode = ({
|
||||
<EmailLetterHead />
|
||||
|
||||
<EmailHeading>
|
||||
Your One-Time Password for {resourceName}
|
||||
Your One-Time Code for {resourceName}
|
||||
</EmailHeading>
|
||||
|
||||
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { runSetupFunctions } from "./setup";
|
||||
import { createApiServer } from "./apiServer";
|
||||
import { createNextServer } from "./nextServer";
|
||||
import { createInternalServer } from "./internalServer";
|
||||
import { User, UserOrg } from "./db/schema";
|
||||
import { Session, User, UserOrg } from "./db/schema";
|
||||
|
||||
async function startServers() {
|
||||
await runSetupFunctions();
|
||||
@@ -24,6 +24,7 @@ declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: User;
|
||||
session?: Session;
|
||||
userOrg?: UserOrg;
|
||||
userOrgRoleId?: number;
|
||||
userOrgId?: string;
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import {
|
||||
__DIRNAME,
|
||||
APP_PATH,
|
||||
APP_VERSION,
|
||||
configFilePath1,
|
||||
configFilePath2
|
||||
} from "@server/lib/consts";
|
||||
import { loadAppVersion } from "@server/lib/loadAppVersion";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import stoi from "./stoi";
|
||||
import db from "@server/db";
|
||||
import { SupporterKey, supporterKey } from "@server/db/schema";
|
||||
import { suppressDeprecationWarnings } from "moment";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||
const hostnameSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$/
|
||||
)
|
||||
.or(z.literal("localhost"));
|
||||
|
||||
const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
|
||||
return process.env[envVar] ?? valFromYaml;
|
||||
@@ -31,37 +27,58 @@ const configSchema = z.object({
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("APP_DASHBOARDURL"))
|
||||
.pipe(z.string().url())
|
||||
.transform((url) => url.toLowerCase()),
|
||||
base_domain: hostnameSchema
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("APP_BASEDOMAIN"))
|
||||
.pipe(hostnameSchema),
|
||||
log_level: z.enum(["debug", "info", "warn", "error"]),
|
||||
save_logs: z.boolean()
|
||||
save_logs: z.boolean(),
|
||||
log_failed_attempts: z.boolean().optional()
|
||||
}),
|
||||
domains: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
base_domain: z
|
||||
.string()
|
||||
.nonempty("base_domain must not be empty")
|
||||
.transform((url) => url.toLowerCase()),
|
||||
cert_resolver: z.string().optional(),
|
||||
prefer_wildcard_cert: z.boolean().optional()
|
||||
})
|
||||
)
|
||||
.refine(
|
||||
(domains) => {
|
||||
const keys = Object.keys(domains);
|
||||
|
||||
if (keys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "At least one domain must be defined"
|
||||
}
|
||||
),
|
||||
server: z.object({
|
||||
external_port: portSchema
|
||||
.optional()
|
||||
.transform(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),
|
||||
external_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||
internal_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||
next_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||
internal_hostname: z.string().transform((url) => url.toLowerCase()),
|
||||
secure_cookies: z.boolean(),
|
||||
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(),
|
||||
@@ -75,19 +92,13 @@ const configSchema = z.object({
|
||||
traefik: z.object({
|
||||
http_entrypoint: z.string(),
|
||||
https_entrypoint: z.string().optional(),
|
||||
cert_resolver: z.string().optional(),
|
||||
prefer_wildcard_cert: z.boolean().optional()
|
||||
additional_middlewares: z.array(z.string()).optional()
|
||||
}),
|
||||
gerbil: z.object({
|
||||
start_port: portSchema
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("GERBIL_STARTPORT"))
|
||||
.transform(stoi)
|
||||
.pipe(portSchema),
|
||||
start_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||
base_endpoint: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("GERBIL_BASEENDPOINT"))
|
||||
.pipe(z.string())
|
||||
.transform((url) => url.toLowerCase()),
|
||||
use_subdomain: z.boolean(),
|
||||
@@ -109,11 +120,13 @@ const configSchema = z.object({
|
||||
}),
|
||||
email: z
|
||||
.object({
|
||||
smtp_host: z.string(),
|
||||
smtp_port: portSchema,
|
||||
smtp_user: z.string(),
|
||||
smtp_pass: z.string(),
|
||||
no_reply: z.string().email()
|
||||
smtp_host: z.string().optional(),
|
||||
smtp_port: portSchema.optional(),
|
||||
smtp_user: z.string().optional(),
|
||||
smtp_pass: z.string().optional(),
|
||||
smtp_secure: z.boolean().optional(),
|
||||
smtp_tls_reject_unauthorized: z.boolean().optional(),
|
||||
no_reply: z.string().email().optional()
|
||||
})
|
||||
.optional(),
|
||||
users: z.object({
|
||||
@@ -123,7 +136,8 @@ const configSchema = z.object({
|
||||
.email()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("USERS_SERVERADMIN_EMAIL"))
|
||||
.pipe(z.string().email()),
|
||||
.pipe(z.string().email())
|
||||
.transform((v) => v.toLowerCase()),
|
||||
password: passwordSchema
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD"))
|
||||
@@ -134,7 +148,10 @@ const configSchema = z.object({
|
||||
.object({
|
||||
require_email_verification: 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(),
|
||||
allow_local_sites: z.boolean().optional()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
@@ -142,16 +159,14 @@ const configSchema = z.object({
|
||||
export class Config {
|
||||
private rawConfig!: z.infer<typeof configSchema>;
|
||||
|
||||
supporterData: SupporterKey | null = null;
|
||||
|
||||
supporterHiddenUntil: number | null = null;
|
||||
|
||||
constructor() {
|
||||
this.loadConfig();
|
||||
|
||||
if (process.env.GENERATE_TRAEFIK_CONFIG === "true") {
|
||||
this.createTraefikConfig();
|
||||
}
|
||||
}
|
||||
|
||||
public loadEnvironment() {}
|
||||
|
||||
public loadConfig() {
|
||||
const loadConfig = (configPath: string) => {
|
||||
try {
|
||||
@@ -174,45 +189,17 @@ export class Config {
|
||||
} else if (fs.existsSync(configFilePath2)) {
|
||||
environment = loadConfig(configFilePath2);
|
||||
}
|
||||
if (!environment) {
|
||||
const exampleConfigPath = path.join(
|
||||
__DIRNAME,
|
||||
"config.example.yml"
|
||||
|
||||
if (process.env.APP_BASE_DOMAIN) {
|
||||
console.log(
|
||||
"You're using deprecated environment variables. Transition to the configuration file. https://docs.fossorial.io/"
|
||||
);
|
||||
if (fs.existsSync(exampleConfigPath)) {
|
||||
try {
|
||||
const exampleConfigContent = fs.readFileSync(
|
||||
exampleConfigPath,
|
||||
"utf8"
|
||||
);
|
||||
fs.writeFileSync(
|
||||
configFilePath1,
|
||||
exampleConfigContent,
|
||||
"utf8"
|
||||
);
|
||||
environment = loadConfig(configFilePath1);
|
||||
} 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) {
|
||||
throw new Error(
|
||||
`Error creating configuration file from example: ${
|
||||
error.message
|
||||
}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
"No configuration file found and no example configuration available"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("No configuration file found");
|
||||
throw new Error(
|
||||
"No configuration file found. Please create one. https://docs.fossorial.io/"
|
||||
);
|
||||
}
|
||||
|
||||
const parsedConfig = configSchema.safeParse(environment);
|
||||
@@ -222,11 +209,7 @@ export class Config {
|
||||
throw new Error(`Invalid configuration file: ${errors}`);
|
||||
}
|
||||
|
||||
const appVersion = loadAppVersion();
|
||||
if (!appVersion) {
|
||||
throw new Error("Could not load the application version");
|
||||
}
|
||||
process.env.APP_VERSION = appVersion;
|
||||
process.env.APP_VERSION = APP_VERSION;
|
||||
|
||||
process.env.NEXT_PORT = parsedConfig.data.server.next_port.toString();
|
||||
process.env.SERVER_EXTERNAL_PORT =
|
||||
@@ -237,10 +220,12 @@ export class Config {
|
||||
?.require_email_verification
|
||||
? "true"
|
||||
: "false";
|
||||
process.env.FLAGS_ALLOW_RAW_RESOURCES = parsedConfig.data.flags
|
||||
?.allow_raw_resources
|
||||
? "true"
|
||||
: "false";
|
||||
process.env.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.DISABLE_SIGNUP_WITHOUT_INVITE = parsedConfig.data.flags
|
||||
?.disable_signup_without_invite
|
||||
@@ -252,6 +237,15 @@ export class Config {
|
||||
: "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.checkSupporterKey();
|
||||
|
||||
this.rawConfig = parsedConfig.data;
|
||||
}
|
||||
@@ -260,75 +254,99 @@ export class Config {
|
||||
return this.rawConfig;
|
||||
}
|
||||
|
||||
public getBaseDomain(): string {
|
||||
return this.rawConfig.app.base_domain;
|
||||
public getNoReplyEmail(): string | undefined {
|
||||
return (
|
||||
this.rawConfig.email?.no_reply || this.rawConfig.email?.smtp_user
|
||||
);
|
||||
}
|
||||
|
||||
private createTraefikConfig() {
|
||||
public getDomain(domainId: string) {
|
||||
return this.rawConfig.domains[domainId];
|
||||
}
|
||||
|
||||
public hideSupporterKey(days: number = 7) {
|
||||
const now = new Date().getTime();
|
||||
|
||||
if (this.supporterHiddenUntil && now < this.supporterHiddenUntil) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.supporterHiddenUntil = now + 1000 * 60 * 60 * 24 * days;
|
||||
}
|
||||
|
||||
public isSupporterKeyHidden() {
|
||||
const now = new Date().getTime();
|
||||
|
||||
if (this.supporterHiddenUntil && now < this.supporterHiddenUntil) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async checkSupporterKey() {
|
||||
const [key] = await db.select().from(supporterKey).limit(1);
|
||||
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { key: licenseKey, githubUsername } = key;
|
||||
|
||||
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 response = await fetch(
|
||||
"https://api.dev.fossorial.io/api/v1/license/validate",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
licenseKey,
|
||||
githubUsername
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
const traefikPath = path.join(APP_PATH, "traefik");
|
||||
if (!fs.existsSync(traefikPath)) {
|
||||
if (!response.ok) {
|
||||
this.supporterData = key;
|
||||
return;
|
||||
}
|
||||
|
||||
// load default configs
|
||||
let traefikConfig = fs.readFileSync(
|
||||
defaultTraefikConfigPath,
|
||||
"utf8"
|
||||
);
|
||||
let dynamicConfig = fs.readFileSync(
|
||||
defaultDynamicConfigPath,
|
||||
"utf8"
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
traefikConfig = traefikConfig
|
||||
.split("{{.LetsEncryptEmail}}")
|
||||
.join(this.rawConfig.users.server_admin.email);
|
||||
traefikConfig = traefikConfig
|
||||
.split("{{.INTERNAL_PORT}}")
|
||||
.join(this.rawConfig.server.internal_port.toString());
|
||||
if (!data.data.valid) {
|
||||
this.supporterData = {
|
||||
...key,
|
||||
valid: false
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
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());
|
||||
this.supporterData = {
|
||||
...key,
|
||||
tier: data.data.tier,
|
||||
valid: true
|
||||
};
|
||||
|
||||
// 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");
|
||||
// update the supporter key in the database
|
||||
await db
|
||||
.update(supporterKey)
|
||||
.set({
|
||||
tier: data.data.tier || null,
|
||||
phrase: data.data.cutePhrase || null,
|
||||
valid: true
|
||||
})
|
||||
.where(eq(supporterKey.keyId, key.keyId));
|
||||
} catch (e) {
|
||||
console.log(
|
||||
"Failed to generate the Traefik configuration files. Please create them manually."
|
||||
);
|
||||
console.error(e);
|
||||
this.supporterData = key;
|
||||
console.error("Failed to validate supporter key", e);
|
||||
}
|
||||
}
|
||||
|
||||
public getSupporterData() {
|
||||
return this.supporterData;
|
||||
}
|
||||
}
|
||||
|
||||
export const config = new Config();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
// This is a placeholder value replaced by the build process
|
||||
export const APP_VERSION = "1.1.0";
|
||||
|
||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||
export const __DIRNAME = path.dirname(__FILENAME);
|
||||
|
||||
127
server/lib/ip.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { cidrToRange, findNextAvailableCidr } from "./ip";
|
||||
import { assertEquals } from "@test/assert";
|
||||
|
||||
// Test cases
|
||||
function testFindNextAvailableCidr() {
|
||||
console.log("Running findNextAvailableCidr tests...");
|
||||
|
||||
// Test 1: Basic IPv4 allocation
|
||||
{
|
||||
const existing = ["10.0.0.0/16", "10.1.0.0/16"];
|
||||
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/8");
|
||||
assertEquals(result, "10.2.0.0/16", "Basic IPv4 allocation failed");
|
||||
}
|
||||
|
||||
// Test 2: Finding gap between allocations
|
||||
{
|
||||
const existing = ["10.0.0.0/16", "10.2.0.0/16"];
|
||||
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/8");
|
||||
assertEquals(result, "10.1.0.0/16", "Finding gap between allocations failed");
|
||||
}
|
||||
|
||||
// Test 3: No available space
|
||||
{
|
||||
const existing = ["10.0.0.0/8"];
|
||||
const result = findNextAvailableCidr(existing, 8, "10.0.0.0/8");
|
||||
assertEquals(result, null, "No available space test failed");
|
||||
}
|
||||
|
||||
// // Test 4: IPv6 allocation
|
||||
// {
|
||||
// const existing = ["2001:db8::/32", "2001:db8:1::/32"];
|
||||
// const result = findNextAvailableCidr(existing, 32, "2001:db8::/16");
|
||||
// assertEquals(result, "2001:db8:2::/32", "Basic IPv6 allocation failed");
|
||||
// }
|
||||
|
||||
// // Test 5: Mixed IP versions
|
||||
// {
|
||||
// const existing = ["10.0.0.0/16", "2001:db8::/32"];
|
||||
// assertThrows(
|
||||
// () => findNextAvailableCidr(existing, 16),
|
||||
// "All CIDRs must be of the same IP version",
|
||||
// "Mixed IP versions test failed"
|
||||
// );
|
||||
// }
|
||||
|
||||
// Test 6: Empty input
|
||||
{
|
||||
const existing: string[] = [];
|
||||
const result = findNextAvailableCidr(existing, 16);
|
||||
assertEquals(result, null, "Empty input test failed");
|
||||
}
|
||||
|
||||
// Test 7: Block size alignment
|
||||
{
|
||||
const existing = ["10.0.0.0/24"];
|
||||
const result = findNextAvailableCidr(existing, 24, "10.0.0.0/16");
|
||||
assertEquals(result, "10.0.1.0/24", "Block size alignment test failed");
|
||||
}
|
||||
|
||||
// Test 8: Block size alignment
|
||||
{
|
||||
const existing: string[] = [];
|
||||
const result = findNextAvailableCidr(existing, 24, "10.0.0.0/16");
|
||||
assertEquals(result, "10.0.0.0/24", "Block size alignment test failed");
|
||||
}
|
||||
|
||||
// Test 9: Large block size request
|
||||
{
|
||||
const existing = ["10.0.0.0/24", "10.0.1.0/24"];
|
||||
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/16");
|
||||
assertEquals(result, null, "Large block size request test failed");
|
||||
}
|
||||
|
||||
console.log("All findNextAvailableCidr tests passed!");
|
||||
}
|
||||
|
||||
// function testCidrToRange() {
|
||||
// console.log("Running cidrToRange tests...");
|
||||
|
||||
// // Test 1: Basic IPv4 conversion
|
||||
// {
|
||||
// const result = cidrToRange("192.168.0.0/24");
|
||||
// assertEqualsObj(result, {
|
||||
// start: BigInt("3232235520"),
|
||||
// end: BigInt("3232235775")
|
||||
// }, "Basic IPv4 conversion failed");
|
||||
// }
|
||||
|
||||
// // Test 2: IPv6 conversion
|
||||
// {
|
||||
// const result = cidrToRange("2001:db8::/32");
|
||||
// assertEqualsObj(result, {
|
||||
// start: BigInt("42540766411282592856903984951653826560"),
|
||||
// end: BigInt("42540766411282592875350729025363378175")
|
||||
// }, "IPv6 conversion failed");
|
||||
// }
|
||||
|
||||
// // Test 3: Invalid prefix length
|
||||
// {
|
||||
// assertThrows(
|
||||
// () => cidrToRange("192.168.0.0/33"),
|
||||
// "Invalid prefix length for IPv4",
|
||||
// "Invalid IPv4 prefix test failed"
|
||||
// );
|
||||
// }
|
||||
|
||||
// // Test 4: Invalid IPv6 prefix
|
||||
// {
|
||||
// assertThrows(
|
||||
// () => cidrToRange("2001:db8::/129"),
|
||||
// "Invalid prefix length for IPv6",
|
||||
// "Invalid IPv6 prefix test failed"
|
||||
// );
|
||||
// }
|
||||
|
||||
// console.log("All cidrToRange tests passed!");
|
||||
// }
|
||||
|
||||
// Run all tests
|
||||
try {
|
||||
// testCidrToRange();
|
||||
testFindNextAvailableCidr();
|
||||
console.log("All tests passed successfully!");
|
||||
} catch (error) {
|
||||
console.error("Test failed:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
154
server/lib/ip.ts
@@ -3,58 +3,162 @@ interface IPRange {
|
||||
end: bigint;
|
||||
}
|
||||
|
||||
type IPVersion = 4 | 6;
|
||||
|
||||
/**
|
||||
* Converts IP address string to BigInt for numerical operations
|
||||
* Detects IP version from address string
|
||||
*/
|
||||
function detectIpVersion(ip: string): IPVersion {
|
||||
return ip.includes(':') ? 6 : 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts IPv4 or IPv6 address string to BigInt for numerical operations
|
||||
*/
|
||||
function ipToBigInt(ip: string): bigint {
|
||||
return ip.split('.')
|
||||
.reduce((acc, octet) => BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(parseInt(octet))), BigInt(0));
|
||||
const version = detectIpVersion(ip);
|
||||
|
||||
if (version === 4) {
|
||||
return ip.split('.')
|
||||
.reduce((acc, octet) => {
|
||||
const num = parseInt(octet);
|
||||
if (isNaN(num) || num < 0 || num > 255) {
|
||||
throw new Error(`Invalid IPv4 octet: ${octet}`);
|
||||
}
|
||||
return BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(num));
|
||||
}, BigInt(0));
|
||||
} else {
|
||||
// Handle IPv6
|
||||
// Expand :: notation
|
||||
let fullAddress = ip;
|
||||
if (ip.includes('::')) {
|
||||
const parts = ip.split('::');
|
||||
if (parts.length > 2) throw new Error('Invalid IPv6 address: multiple :: found');
|
||||
const missing = 8 - (parts[0].split(':').length + parts[1].split(':').length);
|
||||
const padding = Array(missing).fill('0').join(':');
|
||||
fullAddress = `${parts[0]}:${padding}:${parts[1]}`;
|
||||
}
|
||||
|
||||
return fullAddress.split(':')
|
||||
.reduce((acc, hextet) => {
|
||||
const num = parseInt(hextet || '0', 16);
|
||||
if (isNaN(num) || num < 0 || num > 65535) {
|
||||
throw new Error(`Invalid IPv6 hextet: ${hextet}`);
|
||||
}
|
||||
return BigInt.asUintN(128, (acc << BigInt(16)) + BigInt(num));
|
||||
}, BigInt(0));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts BigInt to IP address string
|
||||
*/
|
||||
function bigIntToIp(num: bigint): string {
|
||||
const octets: number[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
octets.unshift(Number(num & BigInt(255)));
|
||||
num = num >> BigInt(8);
|
||||
function bigIntToIp(num: bigint, version: IPVersion): string {
|
||||
if (version === 4) {
|
||||
const octets: number[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
octets.unshift(Number(num & BigInt(255)));
|
||||
num = num >> BigInt(8);
|
||||
}
|
||||
return octets.join('.');
|
||||
} else {
|
||||
const hextets: string[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
hextets.unshift(Number(num & BigInt(65535)).toString(16).padStart(4, '0'));
|
||||
num = num >> BigInt(16);
|
||||
}
|
||||
// Compress zero sequences
|
||||
let maxZeroStart = -1;
|
||||
let maxZeroLength = 0;
|
||||
let currentZeroStart = -1;
|
||||
let currentZeroLength = 0;
|
||||
|
||||
for (let i = 0; i < hextets.length; i++) {
|
||||
if (hextets[i] === '0000') {
|
||||
if (currentZeroStart === -1) currentZeroStart = i;
|
||||
currentZeroLength++;
|
||||
if (currentZeroLength > maxZeroLength) {
|
||||
maxZeroLength = currentZeroLength;
|
||||
maxZeroStart = currentZeroStart;
|
||||
}
|
||||
} else {
|
||||
currentZeroStart = -1;
|
||||
currentZeroLength = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxZeroLength > 1) {
|
||||
hextets.splice(maxZeroStart, maxZeroLength, '');
|
||||
if (maxZeroStart === 0) hextets.unshift('');
|
||||
if (maxZeroStart + maxZeroLength === 8) hextets.push('');
|
||||
}
|
||||
|
||||
return hextets.map(h => h === '0000' ? '0' : h.replace(/^0+/, '')).join(':');
|
||||
}
|
||||
return octets.join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts CIDR to IP range
|
||||
*/
|
||||
function cidrToRange(cidr: string): IPRange {
|
||||
export function cidrToRange(cidr: string): IPRange {
|
||||
const [ip, prefix] = cidr.split('/');
|
||||
const version = detectIpVersion(ip);
|
||||
const prefixBits = parseInt(prefix);
|
||||
const ipBigInt = ipToBigInt(ip);
|
||||
const mask = BigInt.asUintN(64, (BigInt(1) << BigInt(32 - prefixBits)) - BigInt(1));
|
||||
|
||||
// Validate prefix length
|
||||
const maxPrefix = version === 4 ? 32 : 128;
|
||||
if (prefixBits < 0 || prefixBits > maxPrefix) {
|
||||
throw new Error(`Invalid prefix length for IPv${version}: ${prefix}`);
|
||||
}
|
||||
|
||||
const shiftBits = BigInt(maxPrefix - prefixBits);
|
||||
const mask = BigInt.asUintN(version === 4 ? 64 : 128, (BigInt(1) << shiftBits) - BigInt(1));
|
||||
const start = ipBigInt & ~mask;
|
||||
const end = start | mask;
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the next available CIDR block given existing allocations
|
||||
* @param existingCidrs Array of existing CIDR blocks
|
||||
* @param blockSize Desired prefix length for the new block (e.g., 24 for /24)
|
||||
* @param startCidr Optional CIDR to start searching from (default: "0.0.0.0/0")
|
||||
* @param blockSize Desired prefix length for the new block
|
||||
* @param startCidr Optional CIDR to start searching from
|
||||
* @returns Next available CIDR block or null if none found
|
||||
*/
|
||||
export function findNextAvailableCidr(
|
||||
existingCidrs: string[],
|
||||
blockSize: number,
|
||||
startCidr: string = "0.0.0.0/0"
|
||||
startCidr?: string
|
||||
): string | null {
|
||||
|
||||
if (!startCidr && existingCidrs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If no existing CIDRs, use the IP version from startCidr
|
||||
const version = startCidr
|
||||
? detectIpVersion(startCidr.split('/')[0])
|
||||
: 4; // Default to IPv4 if no startCidr provided
|
||||
|
||||
// Use appropriate default startCidr if none provided
|
||||
startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0");
|
||||
|
||||
// If there are existing CIDRs, ensure all are same version
|
||||
if (existingCidrs.length > 0 &&
|
||||
existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) {
|
||||
throw new Error('All CIDRs must be of the same IP version');
|
||||
}
|
||||
|
||||
// Convert existing CIDRs to ranges and sort them
|
||||
const existingRanges = existingCidrs
|
||||
.map(cidr => cidrToRange(cidr))
|
||||
.sort((a, b) => (a.start < b.start ? -1 : 1));
|
||||
|
||||
// Calculate block size
|
||||
const blockSizeBigInt = BigInt(1) << BigInt(32 - blockSize);
|
||||
const maxPrefix = version === 4 ? 32 : 128;
|
||||
const blockSizeBigInt = BigInt(1) << BigInt(maxPrefix - blockSize);
|
||||
|
||||
// Start from the beginning of the given CIDR
|
||||
let current = cidrToRange(startCidr).start;
|
||||
@@ -63,7 +167,6 @@ export function findNextAvailableCidr(
|
||||
// Iterate through existing ranges
|
||||
for (let i = 0; i <= existingRanges.length; i++) {
|
||||
const nextRange = existingRanges[i];
|
||||
|
||||
// Align current to block size
|
||||
const alignedCurrent = current + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt);
|
||||
|
||||
@@ -74,7 +177,7 @@ export function findNextAvailableCidr(
|
||||
|
||||
// If we're at the end of existing ranges or found a gap
|
||||
if (!nextRange || alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start) {
|
||||
return `${bigIntToIp(alignedCurrent)}/${blockSize}`;
|
||||
return `${bigIntToIp(alignedCurrent, version)}/${blockSize}`;
|
||||
}
|
||||
|
||||
// Move current pointer to after the current range
|
||||
@@ -85,12 +188,19 @@ export function findNextAvailableCidr(
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given IP address is within a CIDR range
|
||||
* @param ip IP address to check
|
||||
* @param cidr CIDR range to check against
|
||||
* @returns boolean indicating if IP is within the CIDR range
|
||||
*/
|
||||
* Checks if a given IP address is within a CIDR range
|
||||
* @param ip IP address to check
|
||||
* @param cidr CIDR range to check against
|
||||
* @returns boolean indicating if IP is within the CIDR range
|
||||
*/
|
||||
export function isIpInCidr(ip: string, cidr: string): boolean {
|
||||
const ipVersion = detectIpVersion(ip);
|
||||
const cidrVersion = detectIpVersion(cidr.split('/')[0]);
|
||||
|
||||
if (ipVersion !== cidrVersion) {
|
||||
throw new Error('IP address and CIDR must be of the same version');
|
||||
}
|
||||
|
||||
const ipBigInt = ipToBigInt(ip);
|
||||
const range = cidrToRange(cidr);
|
||||
return ipBigInt >= range.start && ipBigInt <= range.end;
|
||||
|
||||
@@ -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")
|
||||
.transform((val) => val.toLowerCase());
|
||||
|
||||
71
server/lib/validators.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { isValidUrlGlobPattern } from "./validators";
|
||||
import { assertEquals } from "@test/assert";
|
||||
|
||||
function runTests() {
|
||||
console.log('Running URL pattern validation tests...');
|
||||
|
||||
// Test valid patterns
|
||||
assertEquals(isValidUrlGlobPattern('simple'), true, 'Simple path segment should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('simple/path'), true, 'Simple path with slash should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('/leading/slash'), true, 'Path with leading slash should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path/'), true, 'Path with trailing slash should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path/*'), true, 'Path with wildcard segment should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('*'), true, 'Single wildcard should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('*/subpath'), true, 'Wildcard with subpath should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path/*/more'), true, 'Path with wildcard in the middle should be valid');
|
||||
|
||||
// Test with special characters
|
||||
assertEquals(isValidUrlGlobPattern('path-with-dash'), true, 'Path with dash should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path_with_underscore'), true, 'Path with underscore should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path.with.dots'), true, 'Path with dots should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path~with~tilde'), true, 'Path with tilde should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path!with!exclamation'), true, 'Path with exclamation should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path$with$dollar'), true, 'Path with dollar should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path&with&ersand'), true, 'Path with ampersand should be valid');
|
||||
assertEquals(isValidUrlGlobPattern("path'with'quote"), true, "Path with quote should be valid");
|
||||
assertEquals(isValidUrlGlobPattern('path(with)parentheses'), true, 'Path with parentheses should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path+with+plus'), true, 'Path with plus should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path,with,comma'), true, 'Path with comma should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path;with;semicolon'), true, 'Path with semicolon should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path=with=equals'), true, 'Path with equals should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path:with:colon'), true, 'Path with colon should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path@with@at'), true, 'Path with at should be valid');
|
||||
|
||||
// Test with percent encoding
|
||||
assertEquals(isValidUrlGlobPattern('path%20with%20spaces'), true, 'Path with percent-encoded spaces should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path%2Fwith%2Fencoded%2Fslashes'), true, 'Path with percent-encoded slashes should be valid');
|
||||
|
||||
// Test with wildcards in segments (the fixed functionality)
|
||||
assertEquals(isValidUrlGlobPattern('padbootstrap*'), true, 'Path with wildcard at the end of segment should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('pad*bootstrap'), true, 'Path with wildcard in the middle of segment should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('*bootstrap'), true, 'Path with wildcard at the start of segment should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('multiple*wildcards*in*segment'), true, 'Path with multiple wildcards in segment should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('wild*/cards/in*/different/seg*ments'), true, 'Path with wildcards in different segments should be valid');
|
||||
|
||||
// Test invalid patterns
|
||||
assertEquals(isValidUrlGlobPattern(''), false, 'Empty string should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('//double/slash'), false, 'Path with double slash should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('path//end'), false, 'Path with double slash in the middle should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('invalid<char>'), false, 'Path with invalid characters should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('invalid|char'), false, 'Path with invalid pipe character should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('invalid"char'), false, 'Path with invalid quote character should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('invalid`char'), false, 'Path with invalid backtick character should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('invalid^char'), false, 'Path with invalid caret character should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('invalid\\char'), false, 'Path with invalid backslash character should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('invalid[char]'), false, 'Path with invalid square brackets should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('invalid{char}'), false, 'Path with invalid curly braces should be invalid');
|
||||
|
||||
// Test invalid percent encoding
|
||||
assertEquals(isValidUrlGlobPattern('invalid%2'), false, 'Path with incomplete percent encoding should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('invalid%GZ'), false, 'Path with invalid hex in percent encoding should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('invalid%'), false, 'Path with isolated percent sign should be invalid');
|
||||
|
||||
console.log('All tests passed!');
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
try {
|
||||
runTests();
|
||||
} catch (error) {
|
||||
console.error('Test failed:', error);
|
||||
}
|
||||
91
server/lib/validators.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
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), except at the end
|
||||
if (!segment && i !== segments.length - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check each character in the segment
|
||||
for (let j = 0; j < segment.length; j++) {
|
||||
const char = segment[j];
|
||||
|
||||
// Check for percent-encoded sequences
|
||||
if (char === "%" && j + 2 < segment.length) {
|
||||
const hex1 = segment[j + 1];
|
||||
const hex2 = segment[j + 2];
|
||||
if (
|
||||
!/^[0-9A-Fa-f]$/.test(hex1) ||
|
||||
!/^[0-9A-Fa-f]$/.test(hex2)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
j += 2; // Skip the next two characters
|
||||
continue;
|
||||
}
|
||||
|
||||
// Allow:
|
||||
// - unreserved (A-Z a-z 0-9 - . _ ~)
|
||||
// - sub-delims (! $ & ' ( ) * + , ; =)
|
||||
// - @ : for compatibility with some systems
|
||||
if (!/^[A-Za-z0-9\-._~!$&'()*+,;#=@:]$/.test(char)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isUrlValid(url: string | undefined) {
|
||||
if (!url) return true; // the link is optional in the schema so if it's empty it's valid
|
||||
var pattern = new RegExp(
|
||||
"^(https?:\\/\\/)?" + // protocol
|
||||
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name
|
||||
"((\\d{1,3}\\.){3}\\d{1,3}))" + // OR ip (v4) address
|
||||
"(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // port and path
|
||||
"(\\?[;&a-z\\d%_.~+=-]*)?" + // query string
|
||||
"(\\#[-a-z\\d_]*)?$",
|
||||
"i"
|
||||
);
|
||||
return !!pattern.test(url);
|
||||
}
|
||||
|
||||
export function isTargetValid(value: string | undefined) {
|
||||
if (!value) return true;
|
||||
|
||||
const DOMAIN_REGEX =
|
||||
/^[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?(?:\.[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?)*$/;
|
||||
const IPV4_REGEX =
|
||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
|
||||
|
||||
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return DOMAIN_REGEX.test(value);
|
||||
}
|
||||
@@ -14,3 +14,4 @@ export * from "./verifyAdmin";
|
||||
export * from "./verifySetResourceUsers";
|
||||
export * from "./verifyUserInRole";
|
||||
export * from "./verifyAccessTokenAccess";
|
||||
export * from "./verifyUserIsServerAdmin";
|
||||
@@ -13,7 +13,7 @@ export async function verifyAdmin(
|
||||
const userId = req.user?.userId;
|
||||
const orgId = req.userOrgId;
|
||||
|
||||
if (!userId) {
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User does not have orgId")
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import HttpCode from "@server/types/HttpCode";
|
||||
import config from "@server/lib/config";
|
||||
import { verifySession } from "@server/auth/sessions/verifySession";
|
||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export const verifySessionUserMiddleware = async (
|
||||
req: any,
|
||||
@@ -16,6 +17,9 @@ export const verifySessionUserMiddleware = async (
|
||||
) => {
|
||||
const { session, user } = await verifySession(req);
|
||||
if (!session || !user) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(`User session not found. IP: ${req.ip}.`);
|
||||
}
|
||||
return next(unauthorized());
|
||||
}
|
||||
|
||||
@@ -25,6 +29,9 @@ export const verifySessionUserMiddleware = async (
|
||||
.where(eq(users.userId, user.userId));
|
||||
|
||||
if (!existingUser || !existingUser[0]) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(`User session not found. IP: ${req.ip}.`);
|
||||
}
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "User does not exist")
|
||||
);
|
||||
|
||||
37
server/middlewares/verifyUserIsServerAdmin.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export async function verifyUserIsServerAdmin(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const userId = req.user!.userId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!req.user?.serverAdmin) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User is not a server admin"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (e) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying organization access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
resources,
|
||||
userResources,
|
||||
roleResources,
|
||||
resourceAccessToken
|
||||
resourceAccessToken,
|
||||
sites
|
||||
} from "@server/db/schema";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -59,7 +60,8 @@ function queryAccessTokens(
|
||||
title: resourceAccessToken.title,
|
||||
description: resourceAccessToken.description,
|
||||
createdAt: resourceAccessToken.createdAt,
|
||||
resourceName: resources.name
|
||||
resourceName: resources.name,
|
||||
siteName: sites.name
|
||||
};
|
||||
|
||||
if (orgId) {
|
||||
@@ -70,6 +72,10 @@ function queryAccessTokens(
|
||||
resources,
|
||||
eq(resourceAccessToken.resourceId, resources.resourceId)
|
||||
)
|
||||
.leftJoin(
|
||||
sites,
|
||||
eq(resources.resourceId, sites.siteId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
@@ -91,6 +97,10 @@ function queryAccessTokens(
|
||||
resources,
|
||||
eq(resourceAccessToken.resourceId, resources.resourceId)
|
||||
)
|
||||
.leftJoin(
|
||||
sites,
|
||||
eq(resources.resourceId, sites.siteId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
|
||||
@@ -79,6 +79,11 @@ export async function disable2fa(
|
||||
);
|
||||
|
||||
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(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
|
||||
@@ -20,7 +20,10 @@ import { verifySession } from "@server/auth/sessions/verifySession";
|
||||
|
||||
export const loginBodySchema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
email: z
|
||||
.string()
|
||||
.email()
|
||||
.transform((v) => v.toLowerCase()),
|
||||
password: z.string(),
|
||||
code: z.string().optional()
|
||||
})
|
||||
@@ -68,9 +71,14 @@ export async function login(
|
||||
.from(users)
|
||||
.where(eq(users.email, email));
|
||||
if (!existingUserRes || !existingUserRes.length) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"Username or password is incorrect"
|
||||
)
|
||||
);
|
||||
@@ -83,9 +91,14 @@ export async function login(
|
||||
existingUser.passwordHash
|
||||
);
|
||||
if (!validPassword) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"Username or password is incorrect"
|
||||
)
|
||||
);
|
||||
@@ -109,9 +122,14 @@ export async function login(
|
||||
);
|
||||
|
||||
if (!validOTP) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Two-factor code incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"The two-factor code you entered is incorrect"
|
||||
)
|
||||
);
|
||||
@@ -119,9 +137,13 @@ export async function login(
|
||||
}
|
||||
|
||||
const token = generateSessionToken();
|
||||
await createSession(token, existingUser.userId);
|
||||
const sess = await createSession(token, existingUser.userId);
|
||||
const isSecure = req.protocol === "https";
|
||||
const cookie = serializeSessionCookie(token, isSecure);
|
||||
const cookie = serializeSessionCookie(
|
||||
token,
|
||||
isSecure,
|
||||
new Date(sess.expiresAt)
|
||||
);
|
||||
|
||||
res.appendHeader("Set-Cookie", cookie);
|
||||
|
||||
|
||||
@@ -5,18 +5,23 @@ import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import {
|
||||
createBlankSessionTokenCookie,
|
||||
invalidateSession,
|
||||
SESSION_COOKIE_NAME
|
||||
invalidateSession
|
||||
} from "@server/auth/sessions/app";
|
||||
import { verifySession } from "@server/auth/sessions/verifySession";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
export async function logout(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const sessionId = req.cookies[SESSION_COOKIE_NAME];
|
||||
|
||||
if (!sessionId) {
|
||||
const { user, session } = await verifySession(req);
|
||||
if (!user || !session) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Log out failed because missing or invalid session. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
@@ -26,7 +31,12 @@ export async function logout(
|
||||
}
|
||||
|
||||
try {
|
||||
await invalidateSession(sessionId);
|
||||
try {
|
||||
await invalidateSession(session.sessionId);
|
||||
} catch (error) {
|
||||
logger.error("Failed to invalidate session", error)
|
||||
}
|
||||
|
||||
const isSecure = req.protocol === "https";
|
||||
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
|
||||
|
||||
|
||||
@@ -8,10 +8,8 @@ import { db } from "@server/db";
|
||||
import { passwordResetTokens, users } from "@server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
|
||||
import { encodeHex } from "oslo/encoding";
|
||||
import { createDate } from "oslo";
|
||||
import logger from "@server/logger";
|
||||
import { generateIdFromEntropySize } from "@server/auth/sessions/app";
|
||||
import { TimeSpan } from "oslo";
|
||||
import config from "@server/lib/config";
|
||||
import { sendEmail } from "@server/emails";
|
||||
@@ -20,7 +18,10 @@ import { hashPassword } from "@server/auth/password";
|
||||
|
||||
export const requestPasswordResetBody = z
|
||||
.object({
|
||||
email: z.string().email()
|
||||
email: z
|
||||
.string()
|
||||
.email()
|
||||
.transform((v) => v.toLowerCase())
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -63,10 +64,7 @@ export async function requestPasswordReset(
|
||||
);
|
||||
}
|
||||
|
||||
const token = generateRandomString(
|
||||
8,
|
||||
alphabet("0-9", "A-Z", "a-z")
|
||||
);
|
||||
const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(passwordResetTokens)
|
||||
@@ -84,6 +82,12 @@ export async function requestPasswordReset(
|
||||
|
||||
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(
|
||||
ResetPasswordCode({
|
||||
email,
|
||||
@@ -91,7 +95,7 @@ export async function requestPasswordReset(
|
||||
link: url
|
||||
}),
|
||||
{
|
||||
from: config.getRawConfig().email?.no_reply,
|
||||
from: config.getNoReplyEmail(),
|
||||
to: email,
|
||||
subject: "Reset your password"
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
|
||||
export const resetPasswordBody = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
email: z
|
||||
.string()
|
||||
.email()
|
||||
.transform((v) => v.toLowerCase()),
|
||||
token: z.string(), // reset secret code
|
||||
newPassword: passwordSchema,
|
||||
code: z.string().optional() // 2fa code
|
||||
@@ -57,6 +60,11 @@ export async function resetPassword(
|
||||
.where(eq(passwordResetTokens.email, email));
|
||||
|
||||
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(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
@@ -106,6 +114,11 @@ export async function resetPassword(
|
||||
);
|
||||
|
||||
if (!validOTP) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Two-factor authentication code is incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
@@ -121,6 +134,11 @@ export async function resetPassword(
|
||||
);
|
||||
|
||||
if (!isTokenValid) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Password reset code is incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
@@ -131,8 +149,6 @@ export async function resetPassword(
|
||||
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
|
||||
await invalidateAllSessions(resetRequest[0].userId);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.update(users)
|
||||
@@ -144,11 +160,21 @@ export async function resetPassword(
|
||||
.where(eq(passwordResetTokens.email, email));
|
||||
});
|
||||
|
||||
await sendEmail(ConfirmPasswordReset({ email }), {
|
||||
from: config.getRawConfig().email?.no_reply,
|
||||
to: email,
|
||||
subject: "Password Reset Confirmation"
|
||||
});
|
||||
try {
|
||||
await invalidateAllSessions(resetRequest[0].userId);
|
||||
} catch (e) {
|
||||
logger.error("Failed to invalidate user sessions", e);
|
||||
}
|
||||
|
||||
try {
|
||||
await sendEmail(ConfirmPasswordReset({ email }), {
|
||||
from: config.getNoReplyEmail(),
|
||||
to: email,
|
||||
subject: "Password Reset Confirmation"
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("Failed to send password reset confirmation email", e);
|
||||
}
|
||||
|
||||
return response<ResetPasswordResponse>(res, {
|
||||
data: null,
|
||||
|
||||
@@ -23,7 +23,10 @@ import { checkValidInvite } from "@server/auth/checkValidInvite";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
|
||||
export const signupBodySchema = z.object({
|
||||
email: z.string().email(),
|
||||
email: z
|
||||
.string()
|
||||
.email()
|
||||
.transform((v) => v.toLowerCase()),
|
||||
password: passwordSchema,
|
||||
inviteToken: z.string().optional(),
|
||||
inviteId: z.string().optional()
|
||||
@@ -60,6 +63,11 @@ export async function signup(
|
||||
|
||||
if (config.getRawConfig().flags?.disable_signup_without_invite) {
|
||||
if (!inviteToken || !inviteId) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Signup blocked without invite. Email: ${email}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
@@ -84,6 +92,11 @@ export async function signup(
|
||||
}
|
||||
|
||||
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(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
@@ -157,9 +170,13 @@ export async function signup(
|
||||
// });
|
||||
|
||||
const token = generateSessionToken();
|
||||
await createSession(token, userId);
|
||||
const sess = await createSession(token, userId);
|
||||
const isSecure = req.protocol === "https";
|
||||
const cookie = serializeSessionCookie(token, isSecure);
|
||||
const cookie = serializeSessionCookie(
|
||||
token,
|
||||
isSecure,
|
||||
new Date(sess.expiresAt)
|
||||
);
|
||||
res.appendHeader("Set-Cookie", cookie);
|
||||
|
||||
if (config.getRawConfig().flags?.require_email_verification) {
|
||||
@@ -185,6 +202,11 @@ export async function signup(
|
||||
});
|
||||
} catch (e) {
|
||||
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(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
|
||||
@@ -75,6 +75,11 @@ export async function verifyEmail(
|
||||
.where(eq(users.userId, user.userId));
|
||||
});
|
||||
} else {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Email verification code incorrect. Email: ${user.email}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
|
||||
@@ -96,6 +96,11 @@ export async function verifyTotp(
|
||||
}
|
||||
|
||||
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(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
|
||||
194
server/routers/badger/exchangeSession.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
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();
|
||||
|
||||
let expiresAt: number | null = null;
|
||||
|
||||
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
|
||||
});
|
||||
expiresAt = res.expiresAt;
|
||||
}
|
||||
} 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
|
||||
});
|
||||
expiresAt = res.expiresAt;
|
||||
}
|
||||
} else {
|
||||
const expires = new Date(
|
||||
Date.now() + SESSION_COOKIE_EXPIRES
|
||||
).getTime();
|
||||
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: expires,
|
||||
sessionLength: RESOURCE_SESSION_COOKIE_EXPIRES
|
||||
});
|
||||
expiresAt = expires;
|
||||
}
|
||||
|
||||
const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
|
||||
const cookie = serializeResourceSessionCookie(
|
||||
cookieName,
|
||||
resource.fullDomain!,
|
||||
token,
|
||||
!resource.ssl,
|
||||
expiresAt ? new Date(expiresAt) : undefined
|
||||
);
|
||||
|
||||
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 "./exchangeSession";
|
||||
|
||||
67
server/routers/badger/verifySession.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { isPathAllowed } from './verifySession';
|
||||
import { assertEquals } from '@test/assert';
|
||||
|
||||
function runTests() {
|
||||
console.log('Running path matching tests...');
|
||||
|
||||
// Test exact matching
|
||||
assertEquals(isPathAllowed('foo', 'foo'), true, 'Exact match should be allowed');
|
||||
assertEquals(isPathAllowed('foo', 'bar'), false, 'Different segments should not match');
|
||||
assertEquals(isPathAllowed('foo/bar', 'foo/bar'), true, 'Exact multi-segment match should be allowed');
|
||||
assertEquals(isPathAllowed('foo/bar', 'foo/baz'), false, 'Partial multi-segment match should not be allowed');
|
||||
|
||||
// Test with leading and trailing slashes
|
||||
assertEquals(isPathAllowed('/foo', 'foo'), true, 'Pattern with leading slash should match');
|
||||
assertEquals(isPathAllowed('foo/', 'foo'), true, 'Pattern with trailing slash should match');
|
||||
assertEquals(isPathAllowed('/foo/', 'foo'), true, 'Pattern with both leading and trailing slashes should match');
|
||||
assertEquals(isPathAllowed('foo', '/foo/'), true, 'Path with leading and trailing slashes should match');
|
||||
|
||||
// Test simple wildcard matching
|
||||
assertEquals(isPathAllowed('*', 'foo'), true, 'Single wildcard should match any single segment');
|
||||
assertEquals(isPathAllowed('*', 'foo/bar'), true, 'Single wildcard should match multiple segments');
|
||||
assertEquals(isPathAllowed('*/bar', 'foo/bar'), true, 'Wildcard prefix should match');
|
||||
assertEquals(isPathAllowed('foo/*', 'foo/bar'), true, 'Wildcard suffix should match');
|
||||
assertEquals(isPathAllowed('foo/*/baz', 'foo/bar/baz'), true, 'Wildcard in middle should match');
|
||||
|
||||
// Test multiple wildcards
|
||||
assertEquals(isPathAllowed('*/*', 'foo/bar'), true, 'Multiple wildcards should match corresponding segments');
|
||||
assertEquals(isPathAllowed('*/*/*', 'foo/bar/baz'), true, 'Three wildcards should match three segments');
|
||||
assertEquals(isPathAllowed('foo/*/*', 'foo/bar/baz'), true, 'Specific prefix with wildcards should match');
|
||||
assertEquals(isPathAllowed('*/*/baz', 'foo/bar/baz'), true, 'Wildcards with specific suffix should match');
|
||||
|
||||
// Test wildcard consumption behavior
|
||||
assertEquals(isPathAllowed('*', ''), true, 'Wildcard should optionally consume segments');
|
||||
assertEquals(isPathAllowed('foo/*', 'foo'), true, 'Trailing wildcard should be optional');
|
||||
assertEquals(isPathAllowed('*/*', 'foo'), true, 'Multiple wildcards can match fewer segments');
|
||||
assertEquals(isPathAllowed('*/*/*', 'foo/bar'), true, 'Extra wildcards can be skipped');
|
||||
|
||||
// Test complex nested paths
|
||||
assertEquals(isPathAllowed('api/*/users', 'api/v1/users'), true, 'API versioning pattern should match');
|
||||
assertEquals(isPathAllowed('api/*/users/*', 'api/v1/users/123'), true, 'API resource pattern should match');
|
||||
assertEquals(isPathAllowed('api/*/users/*/profile', 'api/v1/users/123/profile'), true, 'Nested API pattern should match');
|
||||
|
||||
// Test for the requested padbootstrap* pattern
|
||||
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap'), true, 'padbootstrap* should match padbootstrap');
|
||||
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrapv1'), true, 'padbootstrap* should match padbootstrapv1');
|
||||
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap/files'), false, 'padbootstrap* should not match padbootstrap/files');
|
||||
assertEquals(isPathAllowed('padbootstrap*/*', 'padbootstrap/files'), true, 'padbootstrap*/* should match padbootstrap/files');
|
||||
assertEquals(isPathAllowed('padbootstrap*/files', 'padbootstrapv1/files'), true, 'padbootstrap*/files should not match padbootstrapv1/files (wildcard is segment-based, not partial)');
|
||||
|
||||
// Test wildcard edge cases
|
||||
assertEquals(isPathAllowed('*/*/*/*/*/*', 'a/b'), true, 'Many wildcards can match few segments');
|
||||
assertEquals(isPathAllowed('a/*/b/*/c', 'a/anything/b/something/c'), true, 'Multiple wildcards in pattern should match corresponding segments');
|
||||
|
||||
// Test patterns with partial segment matches
|
||||
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap-123'), true, 'Wildcards in isPathAllowed should be segment-based, not character-based');
|
||||
assertEquals(isPathAllowed('test*', 'testuser'), true, 'Asterisk as part of segment name is treated as a literal, not a wildcard');
|
||||
assertEquals(isPathAllowed('my*app', 'myapp'), true, 'Asterisk in middle of segment name is treated as a literal, not a wildcard');
|
||||
|
||||
console.log('All tests passed!');
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
try {
|
||||
runTests();
|
||||
} catch (error) {
|
||||
console.error('Test failed:', error);
|
||||
}
|
||||
@@ -1,32 +1,43 @@
|
||||
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 { response } from "@server/lib/response";
|
||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
||||
import db from "@server/db";
|
||||
import {
|
||||
ResourceAccessToken,
|
||||
resourceAccessToken,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resources,
|
||||
resourceWhitelist,
|
||||
User,
|
||||
userOrgs
|
||||
} from "@server/db/schema";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import config from "@server/lib/config";
|
||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||
import {
|
||||
createResourceSession,
|
||||
serializeResourceSessionCookie,
|
||||
validateResourceSessionToken
|
||||
} from "@server/auth/sessions/resource";
|
||||
import { Resource, roleResources, userResources } from "@server/db/schema";
|
||||
import logger from "@server/logger";
|
||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||
import { generateSessionToken } from "@server/auth";
|
||||
import db from "@server/db";
|
||||
import {
|
||||
Resource,
|
||||
ResourceAccessToken,
|
||||
ResourcePassword,
|
||||
resourcePassword,
|
||||
ResourcePincode,
|
||||
resourcePincode,
|
||||
ResourceRule,
|
||||
resourceRules,
|
||||
resources,
|
||||
roleResources,
|
||||
sessions,
|
||||
userOrgs,
|
||||
userResources,
|
||||
users
|
||||
} from "@server/db/schema";
|
||||
import config from "@server/lib/config";
|
||||
import { isIpInCidr } from "@server/lib/ip";
|
||||
import { response } from "@server/lib/response";
|
||||
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({
|
||||
sessions: z.record(z.string()).optional(),
|
||||
@@ -36,7 +47,8 @@ const verifyResourceSessionSchema = z.object({
|
||||
path: z.string(),
|
||||
method: z.string(),
|
||||
accessToken: z.string().optional(),
|
||||
tls: z.boolean()
|
||||
tls: z.boolean(),
|
||||
requestIp: z.string().optional()
|
||||
});
|
||||
|
||||
export type VerifyResourceSessionSchema = z.infer<
|
||||
@@ -53,7 +65,7 @@ export async function verifyResourceSession(
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): 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);
|
||||
|
||||
@@ -67,29 +79,67 @@ export async function verifyResourceSession(
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessions, host, originalRequestURL, accessToken: token } =
|
||||
parsedBody.data;
|
||||
const {
|
||||
sessions,
|
||||
host,
|
||||
originalRequestURL,
|
||||
requestIp,
|
||||
path,
|
||||
accessToken: token
|
||||
} = parsedBody.data;
|
||||
|
||||
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);
|
||||
const clientIp = requestIp?.split(":")[0];
|
||||
|
||||
const resource = result?.resources;
|
||||
const pincode = result?.resourcePincode;
|
||||
const password = result?.resourcePassword;
|
||||
let cleanHost = host;
|
||||
// if the host ends with :443 or :80 remove it
|
||||
if (cleanHost.endsWith(":443")) {
|
||||
cleanHost = cleanHost.slice(0, -4);
|
||||
} else if (cleanHost.endsWith(":80")) {
|
||||
cleanHost = cleanHost.slice(0, -3);
|
||||
}
|
||||
|
||||
const resourceCacheKey = `resource:${cleanHost}`;
|
||||
let resourceData:
|
||||
| {
|
||||
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, cleanHost))
|
||||
.limit(1);
|
||||
|
||||
if (!result) {
|
||||
logger.debug("Resource not found", cleanHost);
|
||||
return notAllowed(res);
|
||||
}
|
||||
|
||||
resourceData = {
|
||||
resource: result.resources,
|
||||
pincode: result.resourcePincode,
|
||||
password: result.resourcePassword
|
||||
};
|
||||
|
||||
cache.set(resourceCacheKey, resourceData);
|
||||
}
|
||||
|
||||
const { resource, pincode, password } = resourceData;
|
||||
|
||||
if (!resource) {
|
||||
logger.debug("Resource not found", host);
|
||||
logger.debug("Resource not found", cleanHost);
|
||||
return notAllowed(res);
|
||||
}
|
||||
|
||||
@@ -100,6 +150,25 @@ export async function verifyResourceSession(
|
||||
return notAllowed(res);
|
||||
}
|
||||
|
||||
// check the rules
|
||||
if (resource.applyRules) {
|
||||
const action = await checkRules(
|
||||
resource.resourceId,
|
||||
clientIp,
|
||||
path
|
||||
);
|
||||
|
||||
if (action == "ACCEPT") {
|
||||
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
|
||||
}
|
||||
|
||||
if (
|
||||
!resource.sso &&
|
||||
!pincode &&
|
||||
@@ -110,24 +179,32 @@ export async function verifyResourceSession(
|
||||
return allowed(res);
|
||||
}
|
||||
|
||||
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
||||
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(
|
||||
resource.resourceId
|
||||
)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
||||
|
||||
// check for access token
|
||||
let validAccessToken: ResourceAccessToken | undefined;
|
||||
if (token) {
|
||||
const [accessTokenId, accessToken] = token.split(".");
|
||||
const { valid, error, tokenItem } = await verifyResourceAccessToken(
|
||||
{
|
||||
resource,
|
||||
accessTokenId,
|
||||
accessToken
|
||||
}
|
||||
{ resource, accessTokenId, accessToken }
|
||||
);
|
||||
|
||||
if (error) {
|
||||
logger.debug("Access token invalid: " + error);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -142,40 +219,48 @@ export async function verifyResourceSession(
|
||||
}
|
||||
|
||||
if (!sessions) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Missing resource sessions. Resource ID: ${
|
||||
resource.resourceId
|
||||
}. IP: ${clientIp}.`
|
||||
);
|
||||
}
|
||||
return notAllowed(res);
|
||||
}
|
||||
|
||||
const sessionToken =
|
||||
sessions[config.getRawConfig().server.session_cookie_name];
|
||||
|
||||
// check for unified login
|
||||
if (sso && sessionToken) {
|
||||
const { session, user } = await validateSessionToken(sessionToken);
|
||||
if (session && user) {
|
||||
const isAllowed = await isUserAllowedToAccessResource(
|
||||
user,
|
||||
resource
|
||||
);
|
||||
|
||||
if (isAllowed) {
|
||||
logger.debug(
|
||||
"Resource allowed because user session is valid"
|
||||
);
|
||||
return allowed(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resourceSessionToken =
|
||||
sessions[
|
||||
`${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`
|
||||
];
|
||||
const resourceSessionToken = extractResourceSessionToken(
|
||||
sessions,
|
||||
resource.ssl
|
||||
);
|
||||
|
||||
if (resourceSessionToken) {
|
||||
const { resourceSession } = await validateResourceSessionToken(
|
||||
resourceSessionToken,
|
||||
resource.resourceId
|
||||
);
|
||||
const sessionCacheKey = `session:${resourceSessionToken}`;
|
||||
let resourceSession: any = cache.get(sessionCacheKey);
|
||||
|
||||
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 (pincode && resourceSession.pincodeId) {
|
||||
@@ -208,11 +293,36 @@ export async function verifyResourceSession(
|
||||
);
|
||||
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.
|
||||
// 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,
|
||||
@@ -222,6 +332,14 @@ export async function verifyResourceSession(
|
||||
}
|
||||
|
||||
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);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -234,6 +352,50 @@ export async function verifyResourceSession(
|
||||
}
|
||||
}
|
||||
|
||||
function extractResourceSessionToken(
|
||||
sessions: Record<string, string>,
|
||||
ssl: boolean
|
||||
) {
|
||||
const prefix = `${config.getRawConfig().server.session_cookie_name}${
|
||||
ssl ? "_s" : ""
|
||||
}`;
|
||||
|
||||
const all: { cookieName: string; token: string; priority: number }[] =
|
||||
[];
|
||||
|
||||
for (const [key, value] of Object.entries(sessions)) {
|
||||
const parts = key.split(".");
|
||||
const timestamp = parts[parts.length - 1];
|
||||
|
||||
// check if string is only numbers
|
||||
if (!/^\d+$/.test(timestamp)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// cookie name is the key without the timestamp
|
||||
const cookieName = key.slice(0, -timestamp.length - 1);
|
||||
|
||||
if (cookieName === prefix) {
|
||||
all.push({
|
||||
cookieName,
|
||||
token: value,
|
||||
priority: parseInt(timestamp)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// sort by priority in desc order
|
||||
all.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
const latest = all[0];
|
||||
|
||||
if (!latest) {
|
||||
return;
|
||||
}
|
||||
|
||||
return latest.token;
|
||||
}
|
||||
|
||||
function notAllowed(res: Response, redirectUrl?: string) {
|
||||
const data = {
|
||||
data: { valid: false, redirectUrl },
|
||||
@@ -264,7 +426,7 @@ async function createAccessTokenSession(
|
||||
tokenItem: ResourceAccessToken
|
||||
) {
|
||||
const token = generateSessionToken();
|
||||
await createResourceSession({
|
||||
const sess = await createResourceSession({
|
||||
resourceId: resource.resourceId,
|
||||
token,
|
||||
accessTokenId: tokenItem.accessTokenId,
|
||||
@@ -272,10 +434,16 @@ async function createAccessTokenSession(
|
||||
expiresAt: tokenItem.expiresAt,
|
||||
doNotExtend: tokenItem.expiresAt ? true : false
|
||||
});
|
||||
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
|
||||
const cookie = serializeResourceSessionCookie(cookieName, token);
|
||||
const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
|
||||
const cookie = serializeResourceSessionCookie(
|
||||
cookieName,
|
||||
resource.fullDomain!,
|
||||
token,
|
||||
!resource.ssl,
|
||||
new Date(sess.expiresAt)
|
||||
);
|
||||
res.appendHeader("Set-Cookie", cookie);
|
||||
logger.debug("Access token is valid, creating new session")
|
||||
logger.debug("Access token is valid, creating new session");
|
||||
return response<VerifyUserResponse>(res, {
|
||||
data: { valid: true },
|
||||
success: true,
|
||||
@@ -286,9 +454,22 @@ async function createAccessTokenSession(
|
||||
}
|
||||
|
||||
async function isUserAllowedToAccessResource(
|
||||
user: User,
|
||||
userSessionId: string,
|
||||
resource: Resource
|
||||
): Promise<boolean> {
|
||||
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
|
||||
@@ -343,3 +524,173 @@ async function isUserAllowedToAccessResource(
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export 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 full segment 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;
|
||||
}
|
||||
|
||||
// Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix")
|
||||
if (currentPatternPart.includes("*")) {
|
||||
logger.debug(
|
||||
`${indent}Found in-segment wildcard in "${currentPatternPart}"`
|
||||
);
|
||||
|
||||
// Convert the pattern segment to a regex pattern
|
||||
const regexPattern = currentPatternPart
|
||||
.replace(/\*/g, ".*") // Replace * with .* for regex wildcard
|
||||
.replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
|
||||
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
|
||||
if (regex.test(currentPathPart)) {
|
||||
logger.debug(
|
||||
`${indent}Segment with wildcard matches: "${currentPatternPart}" matches "${currentPathPart}"`
|
||||
);
|
||||
return matchSegments(patternIndex + 1, pathIndex + 1);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`${indent}Segment with wildcard mismatch: "${currentPatternPart}" doesn't match "${currentPathPart}"`
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
1
server/routers/domain/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./listDomains";
|
||||
109
server/routers/domain/listDomains.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { domains, orgDomains, users } from "@server/db/schema";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const listDomainsParamsSchema = z
|
||||
.object({
|
||||
orgId: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const listDomainsSchema = z
|
||||
.object({
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative()),
|
||||
offset: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative())
|
||||
})
|
||||
.strict();
|
||||
|
||||
async function queryDomains(orgId: string, limit: number, offset: number) {
|
||||
const res = await db
|
||||
.select({
|
||||
domainId: domains.domainId,
|
||||
baseDomain: domains.baseDomain
|
||||
})
|
||||
.from(orgDomains)
|
||||
.where(eq(orgDomains.orgId, orgId))
|
||||
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
return res;
|
||||
}
|
||||
|
||||
export type ListDomainsResponse = {
|
||||
domains: NonNullable<Awaited<ReturnType<typeof queryDomains>>>;
|
||||
pagination: { total: number; limit: number; offset: number };
|
||||
};
|
||||
|
||||
export async function listDomains(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listDomainsSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
|
||||
const parsedParams = listDomainsParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
const domains = await queryDomains(orgId.toString(), limit, offset);
|
||||
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users);
|
||||
|
||||
return response<ListDomainsResponse>(res, {
|
||||
data: {
|
||||
domains,
|
||||
pagination: {
|
||||
total: count,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Users retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,12 @@ import config from "@server/lib/config";
|
||||
import * as site from "./site";
|
||||
import * as org from "./org";
|
||||
import * as resource from "./resource";
|
||||
import * as domain from "./domain";
|
||||
import * as target from "./target";
|
||||
import * as user from "./user";
|
||||
import * as auth from "./auth";
|
||||
import * as role from "./role";
|
||||
import * as supporterKey from "./supporterKey";
|
||||
import * as accessToken from "./accessToken";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import {
|
||||
@@ -21,12 +23,15 @@ import {
|
||||
verifyRoleAccess,
|
||||
verifySetResourceUsers,
|
||||
verifyUserAccess,
|
||||
getUserOrgs
|
||||
getUserOrgs,
|
||||
verifyUserIsServerAdmin
|
||||
} from "@server/middlewares";
|
||||
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import { verifyUserIsOrgOwner } from "../middlewares/verifyUserIsOrgOwner";
|
||||
import { createNewt, getToken } from "./newt";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import createHttpError from "http-errors";
|
||||
|
||||
// Root routes
|
||||
export const unauthenticated = Router();
|
||||
@@ -131,6 +136,13 @@ authenticated.get(
|
||||
resource.listResources
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/domains",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.listOrgDomains),
|
||||
domain.listDomains
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/create-invite",
|
||||
verifyOrgAccess,
|
||||
@@ -184,6 +196,32 @@ authenticated.get(
|
||||
verifyUserHasAction(ActionsEnum.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(
|
||||
"/target/:targetId",
|
||||
verifyTargetAccess,
|
||||
@@ -308,6 +346,13 @@ authenticated.get(
|
||||
resource.getResourceWhitelist
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/transfer`,
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateResource),
|
||||
resource.transferResource
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/access-token`,
|
||||
verifyResourceAccess,
|
||||
@@ -338,6 +383,9 @@ authenticated.get(
|
||||
|
||||
authenticated.get(`/org/:orgId/overview`, verifyOrgAccess, org.getOrgOverview);
|
||||
|
||||
authenticated.post(`/supporter-key/validate`, supporterKey.validateSupporterKey);
|
||||
authenticated.post(`/supporter-key/hide`, supporterKey.hideSupporterKey);
|
||||
|
||||
unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo);
|
||||
|
||||
// authenticated.get(
|
||||
@@ -371,6 +419,13 @@ unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo);
|
||||
|
||||
unauthenticated.get("/user", verifySessionMiddleware, user.getUser);
|
||||
|
||||
authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers);
|
||||
authenticated.delete(
|
||||
"/user/:userId",
|
||||
verifyUserIsServerAdmin,
|
||||
user.adminRemoveUser
|
||||
);
|
||||
|
||||
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
|
||||
authenticated.get(
|
||||
"/org/:orgId/users",
|
||||
@@ -445,22 +500,61 @@ authRouter.post(
|
||||
);
|
||||
authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa);
|
||||
authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail);
|
||||
|
||||
authRouter.post(
|
||||
"/verify-email/request",
|
||||
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
|
||||
);
|
||||
|
||||
// authRouter.post(
|
||||
// "/change-password",
|
||||
// verifySessionUserMiddleware,
|
||||
// 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("/resource/:resourceId/password", resource.authWithPassword);
|
||||
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(
|
||||
"/resource/:resourceId/access-token",
|
||||
resource.authWithAccessToken
|
||||
|
||||
@@ -11,6 +11,7 @@ import config from "@server/lib/config";
|
||||
import { getUniqueExitNodeEndpointName } from '@server/db/names';
|
||||
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||
import { fromError } from 'zod-validation-error';
|
||||
import { getAllowedIps } from '../target/helpers';
|
||||
// Define Zod schema for request validation
|
||||
const getConfigSchema = z.object({
|
||||
publicKey: z.string(),
|
||||
@@ -83,22 +84,9 @@ export async function getConfig(req: Request, res: Response, next: NextFunction)
|
||||
});
|
||||
|
||||
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 {
|
||||
publicKey: site.pubKey,
|
||||
allowedIps: targetIps.flat(),
|
||||
allowedIps: await getAllowedIps(site.siteId)
|
||||
};
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { Router } from "express";
|
||||
import * as gerbil from "@server/routers/gerbil";
|
||||
import * as badger from "@server/routers/badger";
|
||||
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 supporterKey from "@server/routers/supporterKey";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import {
|
||||
verifyResourceAccess,
|
||||
verifySessionUserMiddleware
|
||||
} from "@server/middlewares";
|
||||
|
||||
// Root routes
|
||||
const internalRouter = Router();
|
||||
@@ -13,9 +19,22 @@ internalRouter.get("/", (_, res) => {
|
||||
});
|
||||
|
||||
internalRouter.get("/traefik-config", traefik.traefikConfigProvider);
|
||||
|
||||
internalRouter.get(
|
||||
"/resource-session/:resourceId/:token",
|
||||
auth.checkResourceSession,
|
||||
auth.checkResourceSession
|
||||
);
|
||||
|
||||
internalRouter.post(
|
||||
`/resource/:resourceId/get-exchange-token`,
|
||||
verifySessionUserMiddleware,
|
||||
verifyResourceAccess,
|
||||
resource.getExchangeToken
|
||||
);
|
||||
|
||||
internalRouter.get(
|
||||
`/supporter-key/visible`,
|
||||
supporterKey.isSupporterKeyVisible
|
||||
);
|
||||
|
||||
// Gerbil routes
|
||||
@@ -30,5 +49,6 @@ const badgerRouter = Router();
|
||||
internalRouter.use("/badger", badgerRouter);
|
||||
|
||||
badgerRouter.post("/verify-session", badger.verifyResourceSession);
|
||||
badgerRouter.post("/exchange-session", badger.exchangeSession);
|
||||
|
||||
export default internalRouter;
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import {
|
||||
generateSessionToken,
|
||||
} from "@server/auth/sessions/app";
|
||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||
import db from "@server/db";
|
||||
import { newts } from "@server/db/schema";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -10,8 +8,13 @@ import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
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 logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
export const newtGetTokenBodySchema = z.object({
|
||||
newtId: z.string(),
|
||||
@@ -43,6 +46,11 @@ export async function getToken(
|
||||
if (token) {
|
||||
const { session, newt } = await validateNewtSessionToken(token);
|
||||
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, {
|
||||
data: null,
|
||||
success: true,
|
||||
@@ -73,6 +81,11 @@ export async function getToken(
|
||||
existingNewt.secretHash
|
||||
);
|
||||
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(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect")
|
||||
);
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import db from "@server/db";
|
||||
import { MessageHandler } from "../ws";
|
||||
import { exitNodes, resources, sites, targets } from "@server/db/schema";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
exitNodes,
|
||||
resources,
|
||||
sites,
|
||||
Target,
|
||||
targets
|
||||
} from "@server/db/schema";
|
||||
import { eq, and, sql, inArray } from "drizzle-orm";
|
||||
import { addPeer, deletePeer } from "../gerbil/peers";
|
||||
import logger from "@server/logger";
|
||||
|
||||
@@ -69,37 +75,84 @@ export const handleRegisterMessage: MessageHandler = async (context) => {
|
||||
allowedIps: [site.subnet]
|
||||
});
|
||||
|
||||
const siteResources = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.siteId, siteId));
|
||||
// Improved version
|
||||
const allResources = await db.transaction(async (tx) => {
|
||||
// First get all resources for the site
|
||||
const resourcesList = await tx
|
||||
.select({
|
||||
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
|
||||
})
|
||||
.from(resources)
|
||||
.where(eq(resources.siteId, siteId));
|
||||
|
||||
// get the targets from the resourceIds
|
||||
const siteTargets = await db
|
||||
.select()
|
||||
.from(targets)
|
||||
.where(
|
||||
inArray(
|
||||
targets.resourceId,
|
||||
siteResources.map((resource) => resource.resourceId)
|
||||
// Get all enabled targets for these resources in a single query
|
||||
const resourceIds = resourcesList.map((r) => r.resourceId);
|
||||
const allTargets =
|
||||
resourceIds.length > 0
|
||||
? await tx
|
||||
.select({
|
||||
resourceId: targets.resourceId,
|
||||
targetId: targets.targetId,
|
||||
ip: targets.ip,
|
||||
method: targets.method,
|
||||
port: targets.port,
|
||||
internalPort: targets.internalPort,
|
||||
enabled: targets.enabled
|
||||
})
|
||||
.from(targets)
|
||||
.where(
|
||||
and(
|
||||
inArray(targets.resourceId, resourceIds),
|
||||
eq(targets.enabled, true)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
|
||||
// Combine the data in JS instead of using SQL for the JSON
|
||||
return resourcesList.map((resource) => ({
|
||||
...resource,
|
||||
targets: allTargets.filter(
|
||||
(target) => target.resourceId === resource.resourceId
|
||||
)
|
||||
);
|
||||
}));
|
||||
});
|
||||
|
||||
const udpTargets = siteTargets
|
||||
.filter((target) => target.protocol === "udp")
|
||||
.map((target) => {
|
||||
return `${target.internalPort ? target.internalPort + ":" : ""}${
|
||||
target.ip
|
||||
}:${target.port}`;
|
||||
});
|
||||
const { tcpTargets, udpTargets } = allResources.reduce(
|
||||
(acc, resource) => {
|
||||
// Skip resources with no targets
|
||||
if (!resource.targets?.length) return acc;
|
||||
|
||||
const tcpTargets = siteTargets
|
||||
.filter((target) => target.protocol === "tcp")
|
||||
.map((target) => {
|
||||
return `${target.internalPort ? target.internalPort + ":" : ""}${
|
||||
target.ip
|
||||
}:${target.port}`;
|
||||
});
|
||||
// Format valid targets into strings
|
||||
const formattedTargets = resource.targets
|
||||
.filter(
|
||||
(target: Target) =>
|
||||
target?.internalPort && target?.ip && target?.port
|
||||
)
|
||||
.map(
|
||||
(target: Target) =>
|
||||
`${target.internalPort}:${target.ip}:${target.port}`
|
||||
);
|
||||
|
||||
// Add to the appropriate protocol array
|
||||
if (resource.protocol === "tcp") {
|
||||
acc.tcpTargets.push(...formattedTargets);
|
||||
} else {
|
||||
acc.udpTargets.push(...formattedTargets);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ tcpTargets: [] as string[], udpTargets: [] as string[] }
|
||||
);
|
||||
|
||||
return {
|
||||
message: {
|
||||
|
||||
@@ -1,73 +1,44 @@
|
||||
import { Target } from "@server/db/schema";
|
||||
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
|
||||
const udpTargets = targets
|
||||
.filter((target) => target.protocol === "udp")
|
||||
.map((target) => {
|
||||
return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
|
||||
});
|
||||
const payloadTargets = targets.map((target) => {
|
||||
return `${target.internalPort ? target.internalPort + ":" : ""}${
|
||||
target.ip
|
||||
}:${target.port}`;
|
||||
});
|
||||
|
||||
const tcpTargets = targets
|
||||
.filter((target) => target.protocol === "tcp")
|
||||
.map((target) => {
|
||||
return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
|
||||
});
|
||||
|
||||
if (udpTargets.length > 0) {
|
||||
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);
|
||||
}
|
||||
const payload = {
|
||||
type: `newt/${protocol}/add`,
|
||||
data: {
|
||||
targets: payloadTargets
|
||||
}
|
||||
};
|
||||
sendToClient(newtId, payload);
|
||||
}
|
||||
|
||||
|
||||
export async function removeTargets(newtId: string, targets: Target[]): Promise<void> {
|
||||
export function removeTargets(
|
||||
newtId: string,
|
||||
targets: Target[],
|
||||
protocol: string
|
||||
) {
|
||||
//create a list of udp and tcp targets
|
||||
const udpTargets = targets
|
||||
.filter((target) => target.protocol === "udp")
|
||||
.map((target) => {
|
||||
return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
|
||||
});
|
||||
const payloadTargets = targets.map((target) => {
|
||||
return `${target.internalPort ? target.internalPort + ":" : ""}${
|
||||
target.ip
|
||||
}:${target.port}`;
|
||||
});
|
||||
|
||||
const tcpTargets = targets
|
||||
.filter((target) => target.protocol === "tcp")
|
||||
.map((target) => {
|
||||
return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
|
||||
});
|
||||
|
||||
if (udpTargets.length > 0) {
|
||||
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);
|
||||
}
|
||||
const payload = {
|
||||
type: `newt/${protocol}/remove`,
|
||||
data: {
|
||||
targets: payloadTargets
|
||||
}
|
||||
};
|
||||
sendToClient(newtId, payload);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,15 @@ import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Org, orgs, roleActions, roles, userOrgs } from "@server/db/schema";
|
||||
import {
|
||||
domains,
|
||||
Org,
|
||||
orgDomains,
|
||||
orgs,
|
||||
roleActions,
|
||||
roles,
|
||||
userOrgs
|
||||
} from "@server/db/schema";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -16,7 +24,6 @@ const createOrgSchema = z
|
||||
.object({
|
||||
orgId: z.string(),
|
||||
name: z.string().min(1).max(255)
|
||||
// domain: z.string().min(1).max(255).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -82,14 +89,16 @@ export async function createOrg(
|
||||
let org: Org | null = null;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const domain = config.getBaseDomain();
|
||||
const allDomains = await trx
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(eq(domains.configManaged, true));
|
||||
|
||||
const newOrg = await trx
|
||||
.insert(orgs)
|
||||
.values({
|
||||
orgId,
|
||||
name,
|
||||
domain
|
||||
name
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -109,6 +118,13 @@ export async function createOrg(
|
||||
return;
|
||||
}
|
||||
|
||||
await trx.insert(orgDomains).values(
|
||||
allDomains.map((domain) => ({
|
||||
orgId: newOrg[0].orgId,
|
||||
domainId: domain.domainId
|
||||
}))
|
||||
);
|
||||
|
||||
await trx.insert(userOrgs).values({
|
||||
userId: req.user!.userId,
|
||||
orgId: newOrg[0].orgId,
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||
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 response from "@server/lib/response";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq } 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 {
|
||||
createResourceSession,
|
||||
serializeResourceSessionCookie
|
||||
} from "@server/auth/sessions/resource";
|
||||
import config from "@server/lib/config";
|
||||
import { createResourceSession } from "@server/auth/sessions/resource";
|
||||
import logger from "@server/logger";
|
||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const authWithAccessTokenBodySchema = z
|
||||
.object({
|
||||
@@ -86,6 +83,11 @@ export async function authWithAccessToken(
|
||||
});
|
||||
|
||||
if (!valid) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Resource access token invalid. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
@@ -108,13 +110,11 @@ export async function authWithAccessToken(
|
||||
resourceId,
|
||||
token,
|
||||
accessTokenId: tokenItem.accessTokenId,
|
||||
sessionLength: tokenItem.sessionLength,
|
||||
expiresAt: tokenItem.expiresAt,
|
||||
doNotExtend: tokenItem.expiresAt ? true : false
|
||||
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<AuthWithAccessTokenResponse>(res, {
|
||||
data: {
|
||||
|
||||
@@ -9,13 +9,10 @@ import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import {
|
||||
createResourceSession,
|
||||
serializeResourceSessionCookie
|
||||
} from "@server/auth/sessions/resource";
|
||||
import config from "@server/lib/config";
|
||||
import { createResourceSession } from "@server/auth/sessions/resource";
|
||||
import logger from "@server/logger";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
export const authWithPasswordBodySchema = z
|
||||
.object({
|
||||
@@ -84,7 +81,7 @@ export async function authWithPassword(
|
||||
|
||||
if (!org) {
|
||||
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
|
||||
);
|
||||
if (!validPassword) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Resource password incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password")
|
||||
);
|
||||
@@ -120,11 +122,12 @@ export async function authWithPassword(
|
||||
await createResourceSession({
|
||||
resourceId,
|
||||
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, {
|
||||
data: {
|
||||
|
||||
@@ -1,29 +1,17 @@
|
||||
import { verify } from "@node-rs/argon2";
|
||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||
import db from "@server/db";
|
||||
import {
|
||||
orgs,
|
||||
resourceOtp,
|
||||
resourcePincode,
|
||||
resources,
|
||||
resourceWhitelist
|
||||
} from "@server/db/schema";
|
||||
import { orgs, resourcePincode, resources } from "@server/db/schema";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { eq } 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 {
|
||||
createResourceSession,
|
||||
serializeResourceSessionCookie
|
||||
} from "@server/auth/sessions/resource";
|
||||
import { createResourceSession } from "@server/auth/sessions/resource";
|
||||
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 config from "@server/lib/config";
|
||||
|
||||
export const authWithPincodeBodySchema = z
|
||||
.object({
|
||||
@@ -109,19 +97,21 @@ export async function authWithPincode(
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Resource has no pincode protection"
|
||||
)
|
||||
"Resource has no pincode protection"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const validPincode = verifyPassword(
|
||||
const validPincode = await verifyPassword(
|
||||
pincode,
|
||||
definedPincode.pincodeHash
|
||||
);
|
||||
if (!validPincode) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Resource pin code incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN")
|
||||
);
|
||||
@@ -131,11 +121,12 @@ export async function authWithPincode(
|
||||
await createResourceSession({
|
||||
resourceId,
|
||||
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, {
|
||||
data: {
|
||||
|
||||
@@ -3,7 +3,6 @@ import db from "@server/db";
|
||||
import {
|
||||
orgs,
|
||||
resourceOtp,
|
||||
resourcePassword,
|
||||
resources,
|
||||
resourceWhitelist
|
||||
} from "@server/db/schema";
|
||||
@@ -14,17 +13,17 @@ import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import {
|
||||
createResourceSession,
|
||||
serializeResourceSessionCookie
|
||||
} from "@server/auth/sessions/resource";
|
||||
import config from "@server/lib/config";
|
||||
import { createResourceSession } from "@server/auth/sessions/resource";
|
||||
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const authWithWhitelistBodySchema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
email: z
|
||||
.string()
|
||||
.email()
|
||||
.transform((v) => v.toLowerCase()),
|
||||
otp: z.string().optional()
|
||||
})
|
||||
.strict();
|
||||
@@ -90,20 +89,53 @@ export async function authWithWhitelist(
|
||||
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
|
||||
.limit(1);
|
||||
|
||||
const resource = result?.resources;
|
||||
const org = result?.orgs;
|
||||
const whitelistedEmail = result?.resourceWhitelist;
|
||||
let resource = result?.resources;
|
||||
let org = result?.orgs;
|
||||
let whitelistedEmail = result?.resourceWhitelist;
|
||||
|
||||
if (!whitelistedEmail) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Email is not whitelisted"
|
||||
// if email is not found, check for wildcard email
|
||||
const wildcard = "*@" + email.split("@")[1];
|
||||
|
||||
logger.debug("Checking for wildcard email: " + wildcard);
|
||||
|
||||
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) {
|
||||
@@ -125,6 +157,11 @@ export async function authWithWhitelist(
|
||||
otp
|
||||
);
|
||||
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(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect OTP")
|
||||
);
|
||||
@@ -175,11 +212,12 @@ export async function authWithWhitelist(
|
||||
await createResourceSession({
|
||||
resourceId,
|
||||
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, {
|
||||
data: {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { SqliteError } from "better-sqlite3";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import {
|
||||
domains,
|
||||
orgDomains,
|
||||
orgs,
|
||||
Resource,
|
||||
resources,
|
||||
@@ -16,8 +17,9 @@ import createHttpError from "http-errors";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import stoi from "@server/lib/stoi";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
||||
import logger from "@server/logger";
|
||||
import { subdomainSchema } from "@server/lib/schemas";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const createResourceParamsSchema = z
|
||||
.object({
|
||||
@@ -26,12 +28,65 @@ const createResourceParamsSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const createResourceSchema = z
|
||||
const createHttpResourceSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
subdomain: subdomainSchema
|
||||
subdomain: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val?.toLowerCase()),
|
||||
isBaseDomain: z.boolean().optional(),
|
||||
siteId: z.number(),
|
||||
http: z.boolean(),
|
||||
protocol: z.string(),
|
||||
domainId: z.string()
|
||||
})
|
||||
.strict();
|
||||
.strict()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.subdomain) {
|
||||
return subdomainSchema.safeParse(data.subdomain).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: "Invalid subdomain" }
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
|
||||
if (data.isBaseDomain) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Base domain resources are not allowed"
|
||||
}
|
||||
);
|
||||
|
||||
const createRawResourceSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
siteId: z.number(),
|
||||
http: z.boolean(),
|
||||
protocol: z.string(),
|
||||
proxyPort: z.number().int().min(1).max(65535)
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!config.getRawConfig().flags?.allow_raw_resources) {
|
||||
if (data.proxyPort !== undefined) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Proxy port cannot be set"
|
||||
}
|
||||
);
|
||||
|
||||
export type CreateResourceResponse = Resource;
|
||||
|
||||
@@ -41,18 +96,6 @@ export async function createResource(
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedBody = createResourceSchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let { name, subdomain } = parsedBody.data;
|
||||
|
||||
// Validate request params
|
||||
const parsedParams = createResourceParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
@@ -88,68 +131,271 @@ export async function createResource(
|
||||
);
|
||||
}
|
||||
|
||||
const fullDomain = `${subdomain}.${org[0].domain}`;
|
||||
await db.transaction(async (trx) => {
|
||||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
siteId,
|
||||
fullDomain,
|
||||
orgId,
|
||||
name,
|
||||
subdomain,
|
||||
ssl: true
|
||||
})
|
||||
.returning();
|
||||
|
||||
const adminRole = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (adminRole.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
||||
);
|
||||
}
|
||||
|
||||
await trx.insert(roleResources).values({
|
||||
roleId: adminRole[0].roleId,
|
||||
resourceId: newResource[0].resourceId
|
||||
});
|
||||
|
||||
if (req.userOrgRoleId != adminRole[0].roleId) {
|
||||
// make sure the user can access the resource
|
||||
await trx.insert(userResources).values({
|
||||
userId: req.user?.userId!,
|
||||
resourceId: newResource[0].resourceId
|
||||
});
|
||||
}
|
||||
response<CreateResourceResponse>(res, {
|
||||
data: newResource[0],
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof SqliteError &&
|
||||
error.code === "SQLITE_CONSTRAINT_UNIQUE"
|
||||
) {
|
||||
if (typeof req.body.http !== "boolean") {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Resource with that subdomain already exists"
|
||||
)
|
||||
createHttpError(HttpCode.BAD_REQUEST, "http field is required")
|
||||
);
|
||||
}
|
||||
|
||||
const { http } = req.body;
|
||||
|
||||
if (http) {
|
||||
return await createHttpResource(
|
||||
{ req, res, next },
|
||||
{ siteId, orgId }
|
||||
);
|
||||
} else {
|
||||
return await createRawResource(
|
||||
{ req, res, next },
|
||||
{ siteId, orgId }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createHttpResource(
|
||||
route: {
|
||||
req: Request;
|
||||
res: Response;
|
||||
next: NextFunction;
|
||||
},
|
||||
meta: {
|
||||
siteId: number;
|
||||
orgId: string;
|
||||
}
|
||||
) {
|
||||
const { req, res, next } = route;
|
||||
const { siteId, orgId } = meta;
|
||||
|
||||
const parsedBody = createHttpResourceSchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { name, subdomain, isBaseDomain, http, protocol, domainId } =
|
||||
parsedBody.data;
|
||||
|
||||
const [orgDomain] = await db
|
||||
.select()
|
||||
.from(orgDomains)
|
||||
.where(
|
||||
and(eq(orgDomains.orgId, orgId), eq(orgDomains.domainId, domainId))
|
||||
)
|
||||
.leftJoin(domains, eq(orgDomains.domainId, domains.domainId));
|
||||
|
||||
if (!orgDomain || !orgDomain.domains) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Domain with ID ${parsedBody.data.domainId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const domain = orgDomain.domains;
|
||||
|
||||
let fullDomain = "";
|
||||
if (isBaseDomain) {
|
||||
fullDomain = domain.baseDomain;
|
||||
} else {
|
||||
fullDomain = `${subdomain}.${domain.baseDomain}`;
|
||||
}
|
||||
|
||||
logger.debug(`Full domain: ${fullDomain}`);
|
||||
|
||||
// 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"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let resource: Resource | undefined;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
siteId,
|
||||
fullDomain,
|
||||
domainId,
|
||||
orgId,
|
||||
name,
|
||||
subdomain,
|
||||
http,
|
||||
protocol,
|
||||
ssl: true,
|
||||
isBaseDomain
|
||||
})
|
||||
.returning();
|
||||
|
||||
const adminRole = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (adminRole.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
||||
);
|
||||
}
|
||||
|
||||
await trx.insert(roleResources).values({
|
||||
roleId: adminRole[0].roleId,
|
||||
resourceId: newResource[0].resourceId
|
||||
});
|
||||
|
||||
if (req.userOrgRoleId != adminRole[0].roleId) {
|
||||
// make sure the user can access the resource
|
||||
await trx.insert(userResources).values({
|
||||
userId: req.user?.userId!,
|
||||
resourceId: newResource[0].resourceId
|
||||
});
|
||||
}
|
||||
|
||||
resource = newResource[0];
|
||||
});
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response<CreateResourceResponse>(res, {
|
||||
data: resource,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Http resource created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
}
|
||||
|
||||
async function createRawResource(
|
||||
route: {
|
||||
req: Request;
|
||||
res: Response;
|
||||
next: NextFunction;
|
||||
},
|
||||
meta: {
|
||||
siteId: number;
|
||||
orgId: string;
|
||||
}
|
||||
) {
|
||||
const { req, res, next } = route;
|
||||
const { siteId, orgId } = meta;
|
||||
|
||||
const parsedBody = createRawResourceSchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { name, http, protocol, proxyPort } = parsedBody.data;
|
||||
|
||||
// if http is false check to see if there is already a resource with the same port and protocol
|
||||
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"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let resource: Resource | undefined;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
siteId,
|
||||
orgId,
|
||||
name,
|
||||
http,
|
||||
protocol,
|
||||
proxyPort
|
||||
})
|
||||
.returning();
|
||||
|
||||
const adminRole = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (adminRole.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
||||
);
|
||||
}
|
||||
|
||||
await trx.insert(roleResources).values({
|
||||
roleId: adminRole[0].roleId,
|
||||
resourceId: newResource[0].resourceId
|
||||
});
|
||||
|
||||
if (req.userOrgRoleId != adminRole[0].roleId) {
|
||||
// make sure the user can access the resource
|
||||
await trx.insert(userResources).values({
|
||||
userId: req.user?.userId!,
|
||||
resourceId: newResource[0].resourceId
|
||||
});
|
||||
}
|
||||
|
||||
resource = newResource[0];
|
||||
});
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response<CreateResourceResponse>(res, {
|
||||
data: resource,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Non-http resource created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
}
|
||||
|
||||
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 { addPeer } from "../gerbil/peers";
|
||||
import { removeTargets } from "../newt/targets";
|
||||
import { getAllowedIps } from "../target/helpers";
|
||||
|
||||
// Define Zod schema for request parameters validation
|
||||
const deleteResourceSchema = z
|
||||
@@ -75,25 +76,9 @@ export async function deleteResource(
|
||||
|
||||
if (site.pubKey) {
|
||||
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!, {
|
||||
publicKey: site.pubKey,
|
||||
allowedIps: targetIps.flat()
|
||||
allowedIps: await getAllowedIps(site.siteId)
|
||||
});
|
||||
} else if (site.type == "newt") {
|
||||
// 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))
|
||||
.limit(1);
|
||||
|
||||
removeTargets(newt.newtId, targetsToBeRemoved);
|
||||
removeTargets(newt.newtId, targetsToBeRemoved, deletedResource.protocol);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||