Compare commits

...

76 Commits

Author SHA1 Message Date
Milo Schwartz
489f6bed17 Merge pull request #202 from fosrl/dev
hotfixes coming from beta13
2025-02-14 16:53:58 -05:00
Milo Schwartz
6aa4908446 bump version 2025-02-14 16:53:05 -05:00
Milo Schwartz
d5a220a004 create target validator and add url validator 2025-02-14 16:46:46 -05:00
Owen
a418195b28 Fix ip range pick initial range; add test 2025-02-14 15:49:40 -05:00
Milo Schwartz
2ff6d1d117 allow any string as target 2025-02-14 13:27:34 -05:00
Milo Schwartz
8dd30c88ab fix reset password sql error 2025-02-14 13:12:29 -05:00
Owen
7797c6c770 Allow the chars from RFC 3986 2025-02-14 12:38:28 -05:00
Owen
40922fedb8 Support v6 2025-02-14 12:32:18 -05:00
Milo Schwartz
4c1366ef91 force router refresh on save closes #198 2025-02-14 12:27:03 -05:00
Owen
f61d442989 Allow . in path; resolves #199 2025-02-14 09:51:17 -05:00
Milo Schwartz
b1702bf99a Merge pull request #194 from fosrl/dev
access control rules
2025-02-13 14:48:35 -05:00
Milo Schwartz
a35e24bc0e fix table filters and update readme 2025-02-13 14:45:32 -05:00
Milo Schwartz
c230e034cf update readme 2025-02-12 23:01:00 -05:00
Milo Schwartz
06ceff7427 change migration script text 2025-02-12 22:29:42 -05:00
Milo Schwartz
19273ddbd5 use zod for rules ip validation 2025-02-12 21:52:58 -05:00
Milo Schwartz
fdf1dfdeba rules server validation, enabled toggle, fix wildcard 2025-02-11 23:59:13 -05:00
Milo Schwartz
f14ecf50e4 add docker deployment snippets to create site form 2025-02-10 22:26:29 -05:00
Milo Schwartz
c244ef387b make subdomain input better accommodate long domains 2025-02-10 21:48:34 -05:00
Milo Schwartz
8165051dd8 fix toast dismiss causing components to rerender and clean up rules text 2025-02-10 21:35:06 -05:00
Milo Schwartz
6fba13c8d1 Merge pull request #185 from fosrl/rules
Rules
2025-02-10 21:11:57 -05:00
Owen
3c99fbb1ef Seperate ip and cidr 2025-02-10 21:06:37 -05:00
Milo Schwartz
5b44ffa2fb Merge branch 'rules' of https://github.com/fosrl/pangolin into rules 2025-02-09 23:24:09 -05:00
Milo Schwartz
6e6992e19f add rules info card 2025-02-09 23:23:55 -05:00
Owen
4bce210ff5 Be more lenient with leading and trailing slashes 2025-02-09 22:03:18 -05:00
Owen
bbc1a9eac4 Format 2025-02-09 22:00:02 -05:00
Owen
5e92aebd20 Drop first 2025-02-09 21:56:39 -05:00
Owen
2428738fa6 Fix missing ruleId issue 2025-02-09 21:47:59 -05:00
Owen
34e3fe690d Fix check on string 2025-02-09 11:33:40 -05:00
Owen
c415ceef8d Add migrations 2025-02-09 11:10:19 -05:00
Owen
73798f9e61 Add ecr login 2025-02-09 11:05:42 -05:00
Owen
9694261f3e Add enable rules toggle 2025-02-09 11:02:40 -05:00
Owen
874c67345e Adjust rule processing 2025-02-09 10:50:43 -05:00
Owen
42434ca832 Add validation 2025-02-08 17:54:01 -05:00
Owen
4a6da91faf API and rule screen working 2025-02-08 17:38:30 -05:00
Owen
8f96d0795c Add update 2025-02-08 17:10:37 -05:00
Owen
da3c8823f8 rename to resource rules and add api endpoints 2025-02-08 17:07:21 -05:00
Owen
3cd20cab55 Merge branch 'dev' into rules 2025-02-08 16:55:46 -05:00
Milo Schwartz
b1fa980f56 expand list of allowed special characters in password 2025-02-08 16:04:41 -05:00
Milo Schwartz
ef0bc9a764 add note about backup codes to mfa form 2025-02-08 15:55:49 -05:00
Milo Schwartz
dc2ec5b73b add description to whitelist email field 2025-02-08 15:51:28 -05:00
Milo Schwartz
d8a089fbc2 remove annoying debug log 2025-02-08 15:47:01 -05:00
Milo Schwartz
00a0d89d6c add allow_base_domain_resources to installer 2025-02-08 12:26:52 -05:00
Owen
2f49be69fe Initial pass at rules 2025-02-06 21:42:18 -05:00
Owen
b92639647a Add applyRules to resources 2025-02-06 21:19:55 -05:00
Owen
befdc3a002 Add table 2025-02-06 21:18:34 -05:00
Milo Schwartz
3c7025a327 add strict rate limit to endpoints that send email 2025-02-05 22:46:33 -05:00
Milo Schwartz
58a084426b allow logout to fail 2025-02-05 22:00:29 -05:00
Milo Schwartz
d070415515 fix table page size selector 2025-02-05 21:56:28 -05:00
Milo Schwartz
3fa7132534 fix update resource without subdomain 2025-02-05 21:32:49 -05:00
Milo Schwartz
feeeba5cee fix path in cicd 2025-02-04 22:46:41 -05:00
Milo Schwartz
9e5d5e8990 Merge pull request #159 from fosrl/dev
1.0.0-beta12
2025-02-04 22:45:03 -05:00
Owen
c51f1cb6a2 Add network config to compose; Resolves #155 2025-02-04 22:28:37 -05:00
Milo Schwartz
786551d86a replace version in consts file instead of package.json 2025-02-04 22:23:13 -05:00
Owen
0e73365106 Pull dashboard url for the newt config 2025-02-04 22:14:11 -05:00
Milo Schwartz
b6963a9c35 allow 80 or 443 raw resources 2025-02-04 21:39:13 -05:00
Milo Schwartz
bc0b467f1a update traefik version 2025-02-03 22:58:04 -05:00
Milo Schwartz
7cf798851c fix sorting auth column if no auth closes #149 2025-02-03 22:47:47 -05:00
Milo Schwartz
e475c1ea50 all resources at the base domain closes #137 2025-02-03 21:18:16 -05:00
Milo Schwartz
0840c166ab prevent api resource updates if raw resources is disabled 2025-02-02 16:22:00 -05:00
Milo Schwartz
65a537a670 make update raw resource port functional 2025-02-02 16:03:10 -05:00
Milo Schwartz
a7c99b016c prevent raw tcp on port 80 or 443 2025-02-02 15:47:29 -05:00
Milo Schwartz
6a8132546e reset create resource form on dialog close closes #145 2025-02-02 15:36:43 -05:00
Milo Schwartz
94ce5edc61 pull app version from consts instead of package.json 2025-02-02 15:30:41 -05:00
Milo Schwartz
889f8e1394 Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-02-01 21:19:35 -05:00
Milo Schwartz
9d36198459 fix search id value in command items 2025-02-01 21:19:24 -05:00
Owen Schwartz
673635a585 Merge pull request #64 from j4n-e4t/j4n-e4t/transfer-resource-to-new-site
Transfer a resource to another site
2025-02-01 21:14:17 -05:00
Milo Schwartz
53660a163c minor changes to verbiage and id value 2025-02-01 21:11:31 -05:00
Owen Schwartz
b5420a40ab Clean up and add target manipulation 2025-02-01 18:36:12 -05:00
Owen Schwartz
962c5fb886 Merge branch 'dev' into transfer-resource-to-new-site 2025-02-01 17:03:05 -05:00
Milo Schwartz
7d6dd9e9fd Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-02-01 16:52:30 -05:00
Milo Schwartz
dc9b1f1efd add project board to readme 2025-02-01 16:52:18 -05:00
Owen Schwartz
3257c39fca Merge pull request #130 from nkkfs/patch-3
Update pl.md
2025-02-01 10:29:40 -05:00
Kamil
8b43c6f9c5 Update pl.md
Add Authentication Site strings
2025-02-01 08:56:20 +01:00
Owen Schwartz
8b5cac40e0 Merge pull request #120 from synologyy/main
german-translation
2025-01-31 15:16:32 -05:00
synologyy
81571a8fb7 german-translation 2025-01-31 09:01:00 +01:00
Julian
a039168217 add ability to transfer a resource to another site 2025-01-16 21:15:41 +01:00
89 changed files with 3793 additions and 671 deletions

View File

@@ -35,13 +35,8 @@ jobs:
- name: Update version in package.json
run: |
TAG=${{ env.TAG }}
if [ -f package.json ]; then
jq --arg version "$TAG" '.version = $version' package.json > package.tmp.json && mv package.tmp.json package.json
echo "Updated package.json with version $TAG"
else
echo "package.json not found"
fi
cat package.json
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

View File

@@ -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 .

View File

@@ -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>
[![Documentation](https://img.shields.io/badge/docs-latest-blue.svg?style=flat-square)](https://docs.fossorial.io/)
[![Docker](https://img.shields.io/docker/pulls/fosrl/pangolin?style=flat-square)](https://hub.docker.com/r/fosrl/pangolin)
@@ -6,19 +7,28 @@
[![Discord](https://img.shields.io/discord/1325658630518865980?logo=discord&style=flat-square)](https://discord.gg/HCJR8Xhme4)
[![Youtube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@fossorial-app)
Pangolin is a self-hosted tunneled reverse proxy management server with identity and access management, designed to securely expose private resources through use with the Traefik reverse proxy and WireGuard tunnel clients like Newt. With Pangolin, you retain full control over your infrastructure while providing a user-friendly and feature-rich solution for managing proxies, authentication, and access, and simplifying complex network setups, all with a clean and simple UI.
</div>
### Installation and Documentation
<div align="center">
<h5>
<a href="https://docs.fossorial.io/Getting%20Started/quick-install">
Install Guide
</a>
<span> | </span>
<a href="https://docs.fossorial.io">
Full Documentation
</a>
</h5>
</div>
- [Installation Instructions](https://docs.fossorial.io/Getting%20Started/quick-install)
- [Full Documentation](https://docs.fossorial.io)
<h3 align="center">Tunneled Mesh Reverse Proxy Server with Access Control</h3>
<div align="center">
### Authors and Maintainers
_Your own self-hosted zero trust tunnel._
- [Milo Schwartz](https://github.com/miloschwartz)
- [Owen Schwartz](https://github.com/oschwartz10612)
</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,16 +38,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:
@@ -55,20 +67,18 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
### Easy Deployment
- Run on any cloud provider or on-premises.
- Docker Compose based setup for simplified deployment.
- Future-proof installation script for streamlined setup and feature additions.
- Run on any VPS.
- Use your preferred 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 [Fail2Ban](https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban) or [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin).
- Attach as many sites to the central server as you wish.
## Screenshots
Pangolin has a straightforward and simple dashboard UI:
<div align="center">
<table>
<tr>
@@ -94,22 +104,23 @@ Pangolin has a straightforward and simple dashboard UI:
</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 Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
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.
@@ -121,14 +132,19 @@ Pangolin has a straightforward and simple dashboard UI:
## 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 Pangolins 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.

View File

@@ -41,3 +41,4 @@ flags:
disable_signup_without_invite: true
disable_user_create_org: true
allow_raw_resources: true
allow_base_domain_resources: true

View File

@@ -37,7 +37,7 @@ services:
- 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
@@ -49,3 +49,8 @@ services:
volumes:
- ./traefik:/etc/traefik:ro # Volume to store the Traefik configuration
- ./letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
networks:
default:
driver: bridge
name: pangolin

9
eslint.config.js Normal file
View File

@@ -0,0 +1,9 @@
// eslint.config.js
export default [
{
rules: {
semi: "error",
"prefer-const": "error"
}
}
];

View File

@@ -54,3 +54,4 @@ flags:
disable_signup_without_invite: {{.DisableSignupWithoutInvite}}
disable_user_create_org: {{.DisableUserCreateOrg}}
allow_raw_resources: true
allow_base_domain_resources: true

View File

@@ -36,7 +36,7 @@ services:
{{end}}
traefik:
image: traefik:v3.1
image: traefik:v3.3.3
container_name: traefik
restart: unless-stopped
{{if .InstallGerbil}}
@@ -55,3 +55,8 @@ services:
volumes:
- ./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

267
internationalization/de.md Normal file
View 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 organizations 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 | |

View File

@@ -1,3 +1,23 @@
## 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ę PINem | 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 |

View File

@@ -1,6 +1,6 @@
{
"name": "@fosrl/pangolin",
"version": "1.0.0-beta.10",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",

BIN
public/logo/word_mark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -51,13 +51,17 @@ 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",
}
export async function checkUserActionPermission(

View File

@@ -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,

View File

@@ -11,7 +11,7 @@ import {
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";
@@ -95,11 +95,36 @@ export async function validateSessionToken(
}
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(

View File

@@ -53,7 +53,9 @@ export const resources = sqliteTable("resources", {
proxyPort: integer("proxyPort"),
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
.notNull()
.default(false)
.default(false),
isBaseDomain: integer("isBaseDomain", { mode: "boolean" }),
applyRules: integer("applyRules", { mode: "boolean" }).notNull().default(false)
});
export const targets = sqliteTable("targets", {
@@ -370,6 +372,18 @@ 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 type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;
@@ -402,3 +416,4 @@ 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>;

View File

@@ -6,10 +6,10 @@ 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";
@@ -151,7 +151,8 @@ const configSchema = z.object({
require_email_verification: z.boolean().optional(),
disable_signup_without_invite: z.boolean().optional(),
disable_user_create_org: z.boolean().optional(),
allow_raw_resources: z.boolean().optional()
allow_raw_resources: z.boolean().optional(),
allow_base_domain_resources: z.boolean().optional()
})
.optional()
});
@@ -239,11 +240,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 =
@@ -255,9 +252,9 @@ export class Config {
? "true"
: "false";
process.env.FLAGS_ALLOW_RAW_RESOURCES = parsedConfig.data.flags
?.allow_raw_resources
? "true"
: "false";
?.allow_raw_resources
? "true"
: "false";
process.env.SESSION_COOKIE_NAME =
parsedConfig.data.server.session_cookie_name;
process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false";
@@ -273,6 +270,11 @@ export class Config {
parsedConfig.data.server.resource_access_token_param;
process.env.RESOURCE_SESSION_REQUEST_PARAM =
parsedConfig.data.server.resource_session_request_param;
process.env.FLAGS_ALLOW_BASE_DOMAIN_RESOURCES = parsedConfig.data.flags
?.allow_base_domain_resources
? "true"
: "false";
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
this.rawConfig = parsedConfig.data;
}

View File

@@ -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.0.0-beta.14";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);

183
server/lib/ip.test.ts Normal file
View File

@@ -0,0 +1,183 @@
import { cidrToRange, findNextAvailableCidr } from "./ip";
/**
* Compares two objects for deep equality
* @param actual The actual value to test
* @param expected The expected value to compare against
* @param message The message to display if assertion fails
* @throws Error if objects are not equal
*/
export function assertEqualsObj<T>(actual: T, expected: T, message: string): void {
const actualStr = JSON.stringify(actual);
const expectedStr = JSON.stringify(expected);
if (actualStr !== expectedStr) {
throw new Error(`${message}\nExpected: ${expectedStr}\nActual: ${actualStr}`);
}
}
/**
* Compares two primitive values for equality
* @param actual The actual value to test
* @param expected The expected value to compare against
* @param message The message to display if assertion fails
* @throws Error if values are not equal
*/
export function assertEquals<T>(actual: T, expected: T, message: string): void {
if (actual !== expected) {
throw new Error(`${message}\nExpected: ${expected}\nActual: ${actual}`);
}
}
/**
* Tests if a function throws an expected error
* @param fn The function to test
* @param expectedError The expected error message or part of it
* @param message The message to display if assertion fails
* @throws Error if function doesn't throw or throws unexpected error
*/
export function assertThrows(
fn: () => void,
expectedError: string,
message: string
): void {
try {
fn();
throw new Error(`${message}: Expected to throw "${expectedError}"`);
} catch (error) {
if (!(error instanceof Error)) {
throw new Error(`${message}\nUnexpected error type: ${typeof error}`);
}
if (!error.message.includes(expectedError)) {
throw new Error(
`${message}\nExpected error: ${expectedError}\nActual error: ${error.message}`
);
}
}
}
// 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);
}

View File

@@ -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;

View File

@@ -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;
}
}
}

View File

@@ -8,3 +8,4 @@ export const subdomainSchema = z
)
.min(1, "Subdomain must be at least 1 character long")
.transform((val) => val.toLowerCase());

96
server/lib/validators.ts Normal file
View File

@@ -0,0 +1,96 @@
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;
}
// If segment contains *, it must be exactly *
if (segment.includes("*") && segment !== "*") {
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);
}

View File

@@ -31,7 +31,12 @@ export async function logout(
}
try {
await invalidateSession(session.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));

View File

@@ -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";
@@ -85,7 +83,9 @@ 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}.`);
logger.info(
`Password reset requested for ${email}. Token: ${token}.`
);
}
await sendEmail(

View File

@@ -149,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)
@@ -162,11 +160,21 @@ export async function resetPassword(
.where(eq(passwordResetTokens.email, email));
});
await sendEmail(ConfirmPasswordReset({ email }), {
from: config.getNoReplyEmail(),
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,

View File

@@ -1,33 +1,38 @@
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 db from "@server/db";
import {
ResourceAccessToken,
ResourcePassword,
resourcePassword,
ResourcePincode,
resourcePincode,
resources,
sessions,
userOrgs,
users
} 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 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 { generateSessionToken } from "@server/auth/sessions/app";
import { z } from "zod";
import { fromError } from "zod-validation-error";
// We'll see if this speeds anything up
const cache = new NodeCache({
@@ -79,6 +84,7 @@ export async function verifyResourceSession(
host,
originalRequestURL,
requestIp,
path,
accessToken: token
} = parsedBody.data;
@@ -146,18 +152,35 @@ export async function verifyResourceSession(
return allowed(res);
}
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
// check the rules
if (resource.applyRules) {
const action = await checkRules(
resource.resourceId,
clientIp,
path
);
if (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
}
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) {
@@ -167,7 +190,9 @@ export async function verifyResourceSession(
if (!valid) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Resource access token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
`Resource access token is invalid. Resource ID: ${
resource.resourceId
}. IP: ${clientIp}.`
);
}
}
@@ -188,7 +213,9 @@ export async function verifyResourceSession(
if (!sessions) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Missing resource sessions. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
`Missing resource sessions. Resource ID: ${
resource.resourceId
}. IP: ${clientIp}.`
);
}
return notAllowed(res);
@@ -196,7 +223,9 @@ export async function verifyResourceSession(
const resourceSessionToken =
sessions[
`${config.getRawConfig().server.session_cookie_name}${resource.ssl ? "_s" : ""}`
`${config.getRawConfig().server.session_cookie_name}${
resource.ssl ? "_s" : ""
}`
];
if (resourceSessionToken) {
@@ -219,7 +248,9 @@ export async function verifyResourceSession(
);
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Resource session is an exchange token. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
`Resource session is an exchange token. Resource ID: ${
resource.resourceId
}. IP: ${clientIp}.`
);
}
return notAllowed(res);
@@ -258,7 +289,9 @@ export async function verifyResourceSession(
}
if (resourceSession.userSessionId && sso) {
const userAccessCacheKey = `userAccess:${resourceSession.userSessionId}:${resource.resourceId}`;
const userAccessCacheKey = `userAccess:${
resourceSession.userSessionId
}:${resource.resourceId}`;
let isAllowed: boolean | undefined =
cache.get(userAccessCacheKey);
@@ -282,8 +315,8 @@ export async function verifyResourceSession(
}
}
// 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,
@@ -296,7 +329,9 @@ export async function verifyResourceSession(
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Resource access not allowed. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
`Resource access not allowed. Resource ID: ${
resource.resourceId
}. IP: ${clientIp}.`
);
}
return notAllowed(res, redirectUrl);
@@ -438,3 +473,147 @@ 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;
}
function isPathAllowed(pattern: string, path: string): boolean {
logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`);
// Normalize and split paths into segments
const normalize = (p: string) => p.split("/").filter(Boolean);
const patternParts = normalize(pattern);
const pathParts = normalize(path);
logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`);
logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`);
// Recursive function to try different wildcard matches
function matchSegments(patternIndex: number, pathIndex: number): boolean {
const indent = " ".repeat(pathIndex); // Indent based on recursion depth
const currentPatternPart = patternParts[patternIndex];
const currentPathPart = pathParts[pathIndex];
logger.debug(
`${indent}Checking patternIndex=${patternIndex} (${currentPatternPart || "END"}) vs pathIndex=${pathIndex} (${currentPathPart || "END"})`
);
// If we've consumed all pattern parts, we should have consumed all path parts
if (patternIndex >= patternParts.length) {
const result = pathIndex >= pathParts.length;
logger.debug(
`${indent}Reached end of pattern, remaining path: ${pathParts.slice(pathIndex).join("/")} -> ${result}`
);
return result;
}
// If we've consumed all path parts but still have pattern parts
if (pathIndex >= pathParts.length) {
// The only way this can match is if all remaining pattern parts are wildcards
const remainingPattern = patternParts.slice(patternIndex);
const result = remainingPattern.every((p) => p === "*");
logger.debug(
`${indent}Reached end of path, remaining pattern: ${remainingPattern.join("/")} -> ${result}`
);
return result;
}
// For wildcards, try consuming different numbers of path segments
if (currentPatternPart === "*") {
logger.debug(
`${indent}Found wildcard at pattern index ${patternIndex}`
);
// Try consuming 0 segments (skip the wildcard)
logger.debug(
`${indent}Trying to skip wildcard (consume 0 segments)`
);
if (matchSegments(patternIndex + 1, pathIndex)) {
logger.debug(
`${indent}Successfully matched by skipping wildcard`
);
return true;
}
// Try consuming current segment and recursively try rest
logger.debug(
`${indent}Trying to consume segment "${currentPathPart}" for wildcard`
);
if (matchSegments(patternIndex, pathIndex + 1)) {
logger.debug(
`${indent}Successfully matched by consuming segment for wildcard`
);
return true;
}
logger.debug(`${indent}Failed to match wildcard`);
return false;
}
// For regular segments, they must match exactly
if (currentPatternPart !== currentPathPart) {
logger.debug(
`${indent}Segment mismatch: "${currentPatternPart}" != "${currentPathPart}"`
);
return false;
}
logger.debug(
`${indent}Segments match: "${currentPatternPart}" = "${currentPathPart}"`
);
// Move to next segments in both pattern and path
return matchSegments(patternIndex + 1, pathIndex + 1);
}
const result = matchSegments(0, 0);
logger.debug(`Final result: ${result}`);
return result;
}

View File

@@ -27,6 +27,8 @@ 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();
@@ -184,6 +186,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,
@@ -203,6 +231,7 @@ authenticated.delete(
target.deleteTarget
);
authenticated.put(
"/org/:orgId/role",
verifyOrgAccess,
@@ -308,6 +337,13 @@ authenticated.get(
resource.getResourceWhitelist
);
authenticated.post(
`/resource/:resourceId/transfer`,
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.updateResource),
resource.transferResource
);
authenticated.post(
`/resource/:resourceId/access-token`,
verifyResourceAccess,
@@ -445,22 +481,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

View File

@@ -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)
};
}));

View File

@@ -1,11 +1,11 @@
import { Target } from "@server/db/schema";
import { sendToClient } from "../ws";
export async function addTargets(
export function addTargets(
newtId: string,
targets: Target[],
protocol: string
): Promise<void> {
) {
//create a list of udp and tcp targets
const payloadTargets = targets.map((target) => {
return `${target.internalPort ? target.internalPort + ":" : ""}${
@@ -22,11 +22,11 @@ export async function addTargets(
sendToClient(newtId, payload);
}
export async function removeTargets(
export function removeTargets(
newtId: string,
targets: Target[],
protocol: string
): Promise<void> {
) {
//create a list of udp and tcp targets
const payloadTargets = targets.map((target) => {
return `${target.internalPort ? target.internalPort + ":" : ""}${

View File

@@ -17,7 +17,8 @@ import { eq, and } from "drizzle-orm";
import stoi from "@server/lib/stoi";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
import { subdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config";
const createResourceParamsSchema = z
.object({
@@ -33,7 +34,8 @@ const createResourceSchema = z
siteId: z.number(),
http: z.boolean(),
protocol: z.string(),
proxyPort: z.number().optional()
proxyPort: z.number().optional(),
isBaseDomain: z.boolean().optional()
})
.refine(
(data) => {
@@ -54,7 +56,7 @@ const createResourceSchema = z
)
.refine(
(data) => {
if (data.http) {
if (data.http && !data.isBaseDomain) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
@@ -63,6 +65,43 @@ const createResourceSchema = z
message: "Invalid subdomain",
path: ["subdomain"]
}
)
.refine(
(data) => {
if (!config.getRawConfig().flags?.allow_raw_resources) {
if (data.proxyPort !== undefined) {
return false;
}
}
return true;
},
{
message: "Proxy port cannot be set"
}
)
// .refine(
// (data) => {
// if (data.proxyPort === 443 || data.proxyPort === 80) {
// return false;
// }
// return true;
// },
// {
// message: "Port 80 and 443 are reserved for http and https resources"
// }
// )
.refine(
(data) => {
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
if (data.isBaseDomain) {
return false;
}
}
return true;
},
{
message: "Base domain resources are not allowed"
}
);
export type CreateResourceResponse = Resource;
@@ -83,7 +122,7 @@ export async function createResource(
);
}
let { name, subdomain, protocol, proxyPort, http } = parsedBody.data;
let { name, subdomain, protocol, proxyPort, http, isBaseDomain } = parsedBody.data;
// Validate request params
const parsedParams = createResourceParamsSchema.safeParse(req.params);
@@ -120,7 +159,13 @@ export async function createResource(
);
}
const fullDomain = `${subdomain}.${org[0].domain}`;
let fullDomain = "";
if (isBaseDomain) {
fullDomain = org[0].domain;
} else {
fullDomain = `${subdomain}.${org[0].domain}`;
}
// if http is false check to see if there is already a resource with the same port and protocol
if (!http) {
const existingResource = await db
@@ -142,15 +187,6 @@ export async function createResource(
);
}
} else {
if (proxyPort === 443 || proxyPort === 80) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Port 80 and 443 are reserved for https resources"
)
);
}
// make sure the full domain is unique
const existingResource = await db
.select()
@@ -179,7 +215,8 @@ export async function createResource(
http,
protocol,
proxyPort,
ssl: true
ssl: true,
isBaseDomain
})
.returning();

View 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")
);
}
}

View File

@@ -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

View 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")
);
}
}

View File

@@ -16,4 +16,9 @@ export * from "./setResourceWhitelist";
export * from "./getResourceWhitelist";
export * from "./authWithWhitelist";
export * from "./authWithAccessToken";
export * from "./transferResource";
export * from "./getExchangeToken";
export * from "./createResourceRule";
export * from "./deleteResourceRule";
export * from "./listResourceRules";
export * from "./updateResourceRule";

View File

@@ -0,0 +1,139 @@
import { db } from "@server/db";
import { resourceRules, resources } from "@server/db/schema";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { eq, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
const listResourceRulesParamsSchema = z
.object({
resourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
})
.strict();
const listResourceRulesSchema = z.object({
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().positive()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
});
function queryResourceRules(resourceId: number) {
let baseQuery = db
.select({
ruleId: resourceRules.ruleId,
resourceId: resourceRules.resourceId,
action: resourceRules.action,
match: resourceRules.match,
value: resourceRules.value,
priority: resourceRules.priority,
enabled: resourceRules.enabled
})
.from(resourceRules)
.leftJoin(resources, eq(resourceRules.resourceId, resources.resourceId))
.where(eq(resourceRules.resourceId, resourceId));
return baseQuery;
}
export type ListResourceRulesResponse = {
rules: Awaited<ReturnType<typeof queryResourceRules>>;
pagination: { total: number; limit: number; offset: number };
};
export async function listResourceRules(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listResourceRulesSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const { limit, offset } = parsedQuery.data;
const parsedParams = listResourceRulesParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const { resourceId } = parsedParams.data;
// Verify the resource exists
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
const baseQuery = queryResourceRules(resourceId);
let countQuery = db
.select({ count: sql<number>`cast(count(*) as integer)` })
.from(resourceRules)
.where(eq(resourceRules.resourceId, resourceId));
let rulesList = await baseQuery.limit(limit).offset(offset);
const totalCountResult = await countQuery;
const totalCount = totalCountResult[0].count;
// sort rules list by the priority in ascending order
rulesList = rulesList.sort((a, b) => a.priority - b.priority);
return response<ListResourceRulesResponse>(res, {
data: {
rules: rulesList,
pagination: {
total: totalCount,
limit,
offset
}
},
success: true,
error: false,
message: "Resource rules retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,192 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { newts, resources, sites, targets } from "@server/db/schema";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers";
import { addTargets, removeTargets } from "../newt/targets";
import { getAllowedIps } from "../target/helpers";
const transferResourceParamsSchema = z
.object({
resourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
})
.strict();
const transferResourceBodySchema = z
.object({
siteId: z.number().int().positive()
})
.strict();
export async function transferResource(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = transferResourceParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = transferResourceBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const { siteId } = parsedBody.data;
const [oldResource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!oldResource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
if (oldResource.siteId === siteId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Resource is already assigned to this site`
)
);
}
const [newSite] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (!newSite) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${siteId} not found`
)
);
}
const [oldSite] = await db
.select()
.from(sites)
.where(eq(sites.siteId, oldResource.siteId))
.limit(1);
if (!oldSite) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${oldResource.siteId} not found`
)
);
}
const [updatedResource] = await db
.update(resources)
.set({ siteId })
.where(eq(resources.resourceId, resourceId))
.returning();
if (!updatedResource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
const resourceTargets = await db
.select()
.from(targets)
.where(eq(targets.resourceId, resourceId));
if (resourceTargets.length > 0) {
////// REMOVE THE TARGETS FROM THE OLD SITE //////
if (oldSite.pubKey) {
if (oldSite.type == "wireguard") {
await addPeer(oldSite.exitNodeId!, {
publicKey: oldSite.pubKey,
allowedIps: await getAllowedIps(oldSite.siteId)
});
} else if (oldSite.type == "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, oldSite.siteId))
.limit(1);
removeTargets(
newt.newtId,
resourceTargets,
updatedResource.protocol
);
}
}
////// ADD THE TARGETS TO THE NEW SITE //////
if (newSite.pubKey) {
if (newSite.type == "wireguard") {
await addPeer(newSite.exitNodeId!, {
publicKey: newSite.pubKey,
allowedIps: await getAllowedIps(newSite.siteId)
});
} else if (newSite.type == "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, newSite.siteId))
.limit(1);
addTargets(
newt.newtId,
resourceTargets,
updatedResource.protocol
);
}
}
}
return response(res, {
data: updatedResource,
success: true,
error: false,
message: "Resource transferred successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -2,13 +2,14 @@ import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { orgs, resources, sites } from "@server/db/schema";
import { eq, or } from "drizzle-orm";
import { eq, or, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
import config from "@server/lib/config";
import { subdomainSchema } from "@server/lib/schemas";
const updateResourceParamsSchema = z
.object({
@@ -27,12 +28,49 @@ const updateResourceBodySchema = z
sso: z.boolean().optional(),
blockAccess: z.boolean().optional(),
proxyPort: z.number().int().min(1).max(65535).optional(),
emailWhitelistEnabled: z.boolean().optional()
emailWhitelistEnabled: z.boolean().optional(),
isBaseDomain: z.boolean().optional(),
applyRules: z.boolean().optional(),
})
.strict()
.refine((data) => Object.keys(data).length > 0, {
message: "At least one field must be provided for update"
});
})
.refine(
(data) => {
if (!config.getRawConfig().flags?.allow_raw_resources) {
if (data.proxyPort !== undefined) {
return false;
}
}
return true;
},
{ message: "Cannot update proxyPort" }
)
// .refine(
// (data) => {
// if (data.proxyPort === 443 || data.proxyPort === 80) {
// return false;
// }
// return true;
// },
// {
// message: "Port 80 and 443 are reserved for http and https resources"
// }
// )
.refine(
(data) => {
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
if (data.isBaseDomain) {
return false;
}
}
return true;
},
{
message: "Base domain resources are not allowed"
}
);
export async function updateResource(
req: Request,
@@ -63,13 +101,16 @@ export async function updateResource(
const { resourceId } = parsedParams.data;
const updateData = parsedBody.data;
const resource = await db
const [result] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.leftJoin(orgs, eq(resources.orgId, orgs.orgId));
if (resource.length === 0) {
const resource = result.resources;
const org = result.orgs;
if (!resource || !org) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
@@ -78,7 +119,55 @@ export async function updateResource(
);
}
if (!resource[0].orgs?.domain) {
if (updateData.subdomain) {
if (!resource.http) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Cannot update subdomain for non-http resource"
)
);
}
const valid = subdomainSchema.safeParse(
updateData.subdomain
).success;
if (!valid) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid subdomain provided"
)
);
}
}
if (updateData.proxyPort) {
const proxyPort = updateData.proxyPort;
const existingResource = await db
.select()
.from(resources)
.where(
and(
eq(resources.protocol, resource.protocol),
eq(resources.proxyPort, proxyPort!)
)
);
if (
existingResource.length > 0 &&
existingResource[0].resourceId !== resourceId
) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that protocol and port already exists"
)
);
}
}
if (!org?.domain) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@@ -87,15 +176,38 @@ export async function updateResource(
);
}
const fullDomain = updateData.subdomain
? `${updateData.subdomain}.${resource[0].orgs.domain}`
: undefined;
let fullDomain: string | undefined;
if (updateData.isBaseDomain) {
fullDomain = org.domain;
} else if (updateData.subdomain) {
fullDomain = `${updateData.subdomain}.${org.domain}`;
}
const updatePayload = {
...updateData,
...(fullDomain && { fullDomain })
};
if (
fullDomain &&
(updatePayload.subdomain !== undefined ||
updatePayload.isBaseDomain !== undefined)
) {
const [existingDomain] = await db
.select()
.from(resources)
.where(eq(resources.fullDomain, fullDomain));
if (existingDomain && existingDomain.resourceId !== resourceId) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that domain already exists"
)
);
}
}
const updatedResource = await db
.update(resources)
.set(updatePayload)
@@ -111,10 +223,6 @@ export async function updateResource(
);
}
if (resource[0].resources.ssl !== updatedResource[0].ssl) {
// invalidate all sessions?
}
return response(res, {
data: updatedResource[0],
success: true,

View File

@@ -0,0 +1,179 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourceRules, resources } from "@server/db/schema";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import {
isValidCIDR,
isValidIP,
isValidUrlGlobPattern
} from "@server/lib/validators";
// Define Zod schema for request parameters validation
const updateResourceRuleParamsSchema = z
.object({
ruleId: z.string().transform(Number).pipe(z.number().int().positive()),
resourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
})
.strict();
// Define Zod schema for request body validation
const updateResourceRuleSchema = z
.object({
action: z.enum(["ACCEPT", "DROP"]).optional(),
match: z.enum(["CIDR", "IP", "PATH"]).optional(),
value: z.string().min(1).optional(),
priority: z.number().int(),
enabled: z.boolean().optional()
})
.strict()
.refine((data) => Object.keys(data).length > 0, {
message: "At least one field must be provided for update"
});
export async function updateResourceRule(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
// Validate path parameters
const parsedParams = updateResourceRuleParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
// Validate request body
const parsedBody = updateResourceRuleSchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { ruleId, resourceId } = parsedParams.data;
const updateData = parsedBody.data;
// Verify that the resource exists
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
if (!resource.http) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Cannot create rule for non-http resource"
)
);
}
// Verify that the rule exists and belongs to the specified resource
const [existingRule] = await db
.select()
.from(resourceRules)
.where(eq(resourceRules.ruleId, ruleId))
.limit(1);
if (!existingRule) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource rule with ID ${ruleId} not found`
)
);
}
if (existingRule.resourceId !== resourceId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
`Resource rule ${ruleId} does not belong to resource ${resourceId}`
)
);
}
const match = updateData.match || existingRule.match;
const { value } = updateData;
if (value !== undefined) {
if (match === "CIDR") {
if (!isValidCIDR(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid CIDR provided"
)
);
}
} else if (match === "IP") {
if (!isValidIP(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid IP provided"
)
);
}
} else if (match === "PATH") {
if (!isValidUrlGlobPattern(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid URL glob pattern provided"
)
);
}
}
}
// Update the rule
const [updatedRule] = await db
.update(resourceRules)
.set(updateData)
.where(eq(resourceRules.ruleId, ruleId))
.returning();
return response(res, {
data: updatedRule,
success: true,
error: false,
message: "Resource rule updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -11,35 +11,8 @@ import { isIpInCidr } from "@server/lib/ip";
import { fromError } from "zod-validation-error";
import { addTargets } from "../newt/targets";
import { eq } from "drizzle-orm";
import { pickPort } from "./ports";
// Regular expressions for validation
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;
// Schema for domain names and IP addresses
const domainSchema = z
.string()
.min(1, "Domain cannot be empty")
.max(255, "Domain name too long")
.refine(
(value) => {
// Check if it's a valid IP address (v4 or v6)
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
return true;
}
// Check if it's a valid domain name
return DOMAIN_REGEX.test(value);
},
{
message: "Invalid domain name or IP address format",
path: ["domain"]
}
);
import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators";
const createTargetParamsSchema = z
.object({
@@ -52,7 +25,7 @@ const createTargetParamsSchema = z
const createTargetSchema = z
.object({
ip: domainSchema,
ip: z.string().refine(isTargetValid),
method: z.string().optional().nullable(),
port: z.number().int().min(1).max(65535),
enabled: z.boolean().default(true)

View File

@@ -10,6 +10,7 @@ import logger from "@server/logger";
import { addPeer } from "../gerbil/peers";
import { fromError } from "zod-validation-error";
import { removeTargets } from "../newt/targets";
import { getAllowedIps } from "./helpers";
const deleteTargetSchema = z
.object({
@@ -80,25 +81,9 @@ export async function deleteTarget(
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

View File

@@ -46,3 +46,21 @@ export async function pickPort(siteId: number): Promise<{
return { internalPort, targetIps };
}
export async function getAllowedIps(siteId: number) {
// TODO: is this all inefficient?
const resourcesRes = await db.query.resources.findMany({
where: eq(resources.siteId, siteId)
});
// Fetch targets for all resources of this site
const targetIps = await Promise.all(
resourcesRes.map(async (resource) => {
const targetsRes = await db.query.targets.findMany({
where: eq(targets.resourceId, resource.resourceId)
});
return targetsRes.map((target) => `${target.ip}/32`);
})
);
return targetIps.flat();
}

View File

@@ -10,35 +10,8 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers";
import { addTargets } from "../newt/targets";
import { pickPort } from "./ports";
// Regular expressions for validation
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;
// Schema for domain names and IP addresses
const domainSchema = z
.string()
.min(1, "Domain cannot be empty")
.max(255, "Domain name too long")
.refine(
(value) => {
// Check if it's a valid IP address (v4 or v6)
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
return true;
}
// Check if it's a valid domain name
return DOMAIN_REGEX.test(value);
},
{
message: "Invalid domain name or IP address format",
path: ["domain"]
}
);
import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators";
const updateTargetParamsSchema = z
.object({
@@ -48,7 +21,7 @@ const updateTargetParamsSchema = z
const updateTargetBodySchema = z
.object({
ip: domainSchema.optional(),
ip: z.string().refine(isTargetValid),
method: z.string().min(1).max(10).optional().nullable(),
port: z.number().int().min(1).max(65535).optional(),
enabled: z.boolean().optional()

View File

@@ -25,6 +25,7 @@ export async function traefikConfigProvider(
http: resources.http,
proxyPort: resources.proxyPort,
protocol: resources.protocol,
isBaseDomain: resources.isBaseDomain,
// Site fields
site: {
siteId: sites.siteId,
@@ -110,11 +111,11 @@ export async function traefikConfigProvider(
const routerName = `${resource.resourceId}-router`;
const serviceName = `${resource.resourceId}-service`;
const fullDomain = `${resource.subdomain}.${org.domain}`;
const fullDomain = `${resource.fullDomain}`;
if (resource.http) {
// HTTP configuration remains the same
if (!resource.subdomain) {
if (!resource.subdomain && !resource.isBaseDomain) {
continue;
}

View File

@@ -23,7 +23,12 @@ export async function copyInConfig() {
const allResources = await trx.select().from(resources);
for (const resource of allResources) {
const fullDomain = `${resource.subdomain}.${domain}`;
let fullDomain = "";
if (resource.isBaseDomain) {
fullDomain = domain;
} else {
fullDomain = `${resource.subdomain}.${domain}`;
}
await trx
.update(resources)
.set({ fullDomain })

View File

@@ -3,9 +3,9 @@ import db, { exists } from "@server/db";
import path from "path";
import semver from "semver";
import { versionMigrations } from "@server/db/schema";
import { __DIRNAME } from "@server/lib/consts";
import { loadAppVersion } from "@server/lib/loadAppVersion";
import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts";
import { SqliteError } from "better-sqlite3";
import fs from "fs";
import m1 from "./scripts/1.0.0-beta1";
import m2 from "./scripts/1.0.0-beta2";
import m3 from "./scripts/1.0.0-beta3";
@@ -13,6 +13,8 @@ import m4 from "./scripts/1.0.0-beta5";
import m5 from "./scripts/1.0.0-beta6";
import m6 from "./scripts/1.0.0-beta9";
import m7 from "./scripts/1.0.0-beta10";
import m8 from "./scripts/1.0.0-beta12";
import m13 from "./scripts/1.0.0-beta13";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -25,19 +27,46 @@ const migrations = [
{ version: "1.0.0-beta.5", run: m4 },
{ version: "1.0.0-beta.6", run: m5 },
{ version: "1.0.0-beta.9", run: m6 },
{ version: "1.0.0-beta.10", run: m7 }
{ version: "1.0.0-beta.10", run: m7 },
{ version: "1.0.0-beta.12", run: m8 },
{ version: "1.0.0-beta.13", run: m13 }
// Add new migrations here as they are created
] as const;
// Run the migrations
await runMigrations();
await run();
async function run() {
// backup the database
backupDb();
// run the migrations
await runMigrations();
}
function backupDb() {
// make dir config/db/backups
const appPath = APP_PATH;
const dbDir = path.join(appPath, "db");
const backupsDir = path.join(dbDir, "backups");
// check if the backups directory exists and create it if it doesn't
if (!fs.existsSync(backupsDir)) {
fs.mkdirSync(backupsDir, { recursive: true });
}
// copy the db.sqlite file to backups
// add the date to the filename
const date = new Date();
const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`;
const dbPath = path.join(dbDir, "db.sqlite");
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
fs.copyFileSync(dbPath, backupPath);
}
export async function runMigrations() {
try {
const appVersion = loadAppVersion();
if (!appVersion) {
throw new Error("APP_VERSION is not set in the environment");
}
const appVersion = APP_VERSION;
if (exists) {
await executeScripts();
@@ -109,7 +138,10 @@ async function executeScripts() {
`Successfully completed migration ${migration.version}`
);
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
if (
e instanceof SqliteError &&
e.code === "SQLITE_CONSTRAINT_UNIQUE"
) {
console.error("Migration has already run! Skipping...");
continue;
}

View File

@@ -0,0 +1,62 @@
import db from "@server/db";
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { sql } from "drizzle-orm";
import fs from "fs";
import yaml from "js-yaml";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.12...");
try {
// Determine which config file exists
const filePaths = [configFilePath1, configFilePath2];
let filePath = "";
for (const path of filePaths) {
if (fs.existsSync(path)) {
filePath = path;
break;
}
}
if (!filePath) {
throw new Error(
`No config file found (expected config.yml or config.yaml).`
);
}
// Read and parse the YAML file
let rawConfig: any;
const fileContents = fs.readFileSync(filePath, "utf8");
rawConfig = yaml.load(fileContents);
if (!rawConfig.flags) {
rawConfig.flags = {};
}
rawConfig.flags.allow_base_domain_resources = true;
// Write the updated YAML back to the file
const updatedYaml = yaml.dump(rawConfig);
fs.writeFileSync(filePath, updatedYaml, "utf8");
console.log(`Added new config option: allow_base_domain_resources`);
} catch (e) {
console.log(
`Unable to add new config option: allow_base_domain_resources. This is not critical.`
);
console.error(e);
}
try {
db.transaction((trx) => {
trx.run(sql`ALTER TABLE 'resources' ADD 'isBaseDomain' integer;`);
});
console.log(`Added new column: isBaseDomain`);
} catch (e) {
console.log("Unable to add new column: isBaseDomain");
throw e;
}
console.log("Done.");
}

View File

@@ -0,0 +1,33 @@
import db from "@server/db";
import { sql } from "drizzle-orm";
const version = "1.0.0-beta.13";
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
db.transaction((trx) => {
trx.run(sql`CREATE TABLE resourceRules (
ruleId integer PRIMARY KEY AUTOINCREMENT NOT NULL,
resourceId integer NOT NULL,
priority integer NOT NULL,
enabled integer DEFAULT true NOT NULL,
action text NOT NULL,
match text NOT NULL,
value text NOT NULL,
FOREIGN KEY (resourceId) REFERENCES resources(resourceId) ON UPDATE no action ON DELETE cascade
);`);
trx.run(
sql`ALTER TABLE resources ADD applyRules integer DEFAULT false NOT NULL;`
);
});
console.log(`Added new table and column: resourceRules, applyRules`);
} catch (e) {
console.log("Unable to add new table and column: resourceRules, applyRules");
throw e;
}
console.log(`${version} migration complete`);
}

View File

@@ -10,7 +10,7 @@ import {
FormMessage,
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios";
import { useState } from "react";
@@ -48,7 +48,6 @@ export default function CreateRoleForm({
setOpen,
afterCreate,
}: CreateRoleFormProps) {
const { toast } = useToast();
const { org } = useOrgContext();
const [loading, setLoading] = useState(false);

View File

@@ -9,7 +9,7 @@ import {
FormLabel,
FormMessage,
} from "@app/components/ui/form";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios";
import { useEffect, useState } from "react";
@@ -56,7 +56,6 @@ export default function DeleteRoleForm({
setOpen,
afterDelete,
}: CreateRoleFormProps) {
const { toast } = useToast();
const { org } = useOrgContext();
const [loading, setLoading] = useState(false);

View File

@@ -9,7 +9,7 @@ import {
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel,
getFilteredRowModel
} from "@tanstack/react-table";
import {
Table,
@@ -18,7 +18,7 @@ import {
TableContainer,
TableHead,
TableHeader,
TableRow,
TableRow
} from "@/components/ui/table";
import { Button } from "@app/components/ui/button";
import { useState } from "react";
@@ -35,7 +35,7 @@ interface DataTableProps<TData, TValue> {
export function RolesDataTable<TData, TValue>({
addRole,
columns,
data,
data
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@@ -49,14 +49,16 @@ export function RolesDataTable<TData, TValue>({
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
initialState: {
pagination: {
pageSize: 20,
pageIndex: 0
}
},
state: {
sorting,
columnFilters,
pagination: {
pageSize: 100,
pageIndex: 0,
},
},
columnFilters
}
});
return (
@@ -102,7 +104,7 @@ export function RolesDataTable<TData, TValue>({
: flexRender(
header.column.columnDef
.header,
header.getContext(),
header.getContext()
)}
</TableHead>
);
@@ -123,7 +125,7 @@ export function RolesDataTable<TData, TValue>({
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
cell.getContext()
)}
</TableCell>
))}

View File

@@ -12,7 +12,7 @@ import { ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { RolesDataTable } from "./RolesDataTable";
import { Role } from "@server/db/schema";
import CreateRoleForm from "./CreateRoleForm";
@@ -37,7 +37,6 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
const api = createApiClient(useEnvContext());
const { org } = useOrgContext();
const { toast } = useToast();
const columns: ColumnDef<RoleRow>[] = [
{

View File

@@ -17,7 +17,7 @@ import {
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { InviteUserBody, InviteUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
@@ -54,7 +54,6 @@ const formSchema = z.object({
});
export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
const { toast } = useToast();
const { org } = useOrgContext();
const { env } = useEnvContext();

View File

@@ -9,7 +9,7 @@ import {
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel,
getFilteredRowModel
} from "@tanstack/react-table";
import {
Table,
@@ -18,7 +18,7 @@ import {
TableContainer,
TableHead,
TableHeader,
TableRow,
TableRow
} from "@/components/ui/table";
import { Button } from "@app/components/ui/button";
import { useState } from "react";
@@ -35,7 +35,7 @@ interface DataTableProps<TData, TValue> {
export function UsersDataTable<TData, TValue>({
inviteUser,
columns,
data,
data
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@@ -49,14 +49,16 @@ export function UsersDataTable<TData, TValue>({
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
initialState: {
pagination: {
pageSize: 20,
pageIndex: 0
}
},
state: {
sorting,
columnFilters,
pagination: {
pageSize: 100,
pageIndex: 0,
},
},
columnFilters
}
});
return (
@@ -102,7 +104,7 @@ export function UsersDataTable<TData, TValue>({
: flexRender(
header.column.columnDef
.header,
header.getContext(),
header.getContext()
)}
</TableHead>
);
@@ -123,7 +125,7 @@ export function UsersDataTable<TData, TValue>({
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
cell.getContext()
)}
</TableCell>
))}

View File

@@ -14,7 +14,7 @@ import { useState } from "react";
import InviteUserForm from "./InviteUserForm";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/api";
@@ -47,7 +47,6 @@ export default function UsersTable({ users: u }: UsersTableProps) {
const { user, updateUser } = useUserContext();
const { org } = useOrgContext();
const { toast } = useToast();
const columns: ColumnDef<UserRow>[] = [
{

View File

@@ -16,7 +16,7 @@ import {
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { InviteUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
@@ -47,7 +47,6 @@ const formSchema = z.object({
});
export default function AccessControlsPage() {
const { toast } = useToast();
const { orgUser: user } = userOrgUserContext();
const api = createApiClient(useEnvContext());

View File

@@ -4,7 +4,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { useState } from "react";
import {
Form,
@@ -56,7 +56,6 @@ export default function GeneralPage() {
const { orgUser } = userOrgUserContext();
const router = useRouter();
const { org } = useOrgContext();
const { toast } = useToast();
const api = createApiClient(useEnvContext());
const [loadingDelete, setLoadingDelete] = useState(false);

View File

@@ -11,7 +11,7 @@ import {
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -59,10 +59,12 @@ import {
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
import { subdomainSchema } from "@server/lib/schemas";
import Link from "next/link";
import { SquareArrowOutUpRight } from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Label } from "@app/components/ui/label";
const createResourceFormSchema = z
.object({
@@ -71,7 +73,8 @@ const createResourceFormSchema = z
siteId: z.number(),
http: z.boolean(),
protocol: z.string(),
proxyPort: z.number().optional()
proxyPort: z.number().optional(),
isBaseDomain: z.boolean().optional()
})
.refine(
(data) => {
@@ -92,7 +95,7 @@ const createResourceFormSchema = z
)
.refine(
(data) => {
if (data.http) {
if (data.http && !data.isBaseDomain) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
@@ -114,8 +117,6 @@ export default function CreateResourceForm({
open,
setOpen
}: CreateResourceFormProps) {
const { toast } = useToast();
const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false);
@@ -129,26 +130,36 @@ export default function CreateResourceForm({
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [domainSuffix, setDomainSuffix] = useState<string>(org.org.domain);
const [showSnippets, setShowSnippets] = useState(false);
const [resourceId, setResourceId] = useState<number | null>(null);
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
"subdomain"
);
const form = useForm<CreateResourceFormValues>({
resolver: zodResolver(createResourceFormSchema),
defaultValues: {
subdomain: "",
name: "My Resource",
name: "",
http: true,
protocol: "tcp"
}
});
function reset() {
form.reset();
setSites([]);
setShowSnippets(false);
setResourceId(null);
}
useEffect(() => {
if (!open) {
return;
}
reset();
const fetchSites = async () => {
const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${orgId}/sites/`
@@ -173,7 +184,8 @@ export default function CreateResourceForm({
http: data.http,
protocol: data.protocol,
proxyPort: data.http ? undefined : data.proxyPort,
siteId: data.siteId
siteId: data.siteId,
isBaseDomain: data.isBaseDomain
}
)
.catch((e) => {
@@ -239,7 +251,7 @@ export default function CreateResourceForm({
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Your name"
placeholder="Resource name"
{...field}
/>
</FormControl>
@@ -284,33 +296,89 @@ export default function CreateResourceForm({
/>
)}
{form.watch("http") &&
env.flags.allowBaseDomainResources && (
<div>
<RadioGroup
className="flex space-x-4"
defaultValue={domainType}
onValueChange={(val) => {
setDomainType(
val as any
);
form.setValue(
"isBaseDomain",
val === "basedomain"
);
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="subdomain"
id="r1"
/>
<Label htmlFor="r1">
Subdomain
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="basedomain"
id="r2"
/>
<Label htmlFor="r2">
Base Domain
</Label>
</div>
</RadioGroup>
</div>
)}
{form.watch("http") && (
<FormField
control={form.control}
name="subdomain"
render={({ field }) => (
<FormItem>
<FormLabel>
Subdomain
</FormLabel>
<FormControl>
<CustomDomainInput
value={
field.value ??
""
}
domainSuffix={
domainSuffix
}
placeholder="Enter subdomain"
onChange={(value) =>
form.setValue(
"subdomain",
{!env.flags
.allowBaseDomainResources && (
<FormLabel>
Subdomain
</FormLabel>
)}
{domainType ===
"subdomain" ? (
<FormControl>
<CustomDomainInput
value={
field.value ??
""
}
domainSuffix={
domainSuffix
}
placeholder="Subdomain"
onChange={(
value
)
}
/>
</FormControl>
) =>
form.setValue(
"subdomain",
value
)
}
/>
</FormControl>
) : (
<FormControl>
<Input
value={
domainSuffix
}
readOnly
disabled
/>
</FormControl>
)}
<FormDescription>
This is the fully
qualified domain name
@@ -464,9 +532,7 @@ export default function CreateResourceForm({
site
) => (
<CommandItem
value={
site.niceId
}
value={`${site.siteId}:${site.name}:${site.niceId}`}
key={
site.siteId
}
@@ -560,21 +626,25 @@ export default function CreateResourceForm({
)}
</CredenzaBody>
<CredenzaFooter>
{!showSnippets && <Button
type="submit"
form="create-resource-form"
loading={loading}
disabled={loading}
>
Create Resource
</Button>}
{!showSnippets && (
<Button
type="submit"
form="create-resource-form"
loading={loading}
disabled={loading}
>
Create Resource
</Button>
)}
{showSnippets && <Button
loading={loading}
onClick={() => goToResource()}
>
Go to Resource
</Button>}
{showSnippets && (
<Button
loading={loading}
onClick={() => goToResource()}
>
Go to Resource
</Button>
)}
<CredenzaClose asChild>
<Button variant="outline">Close</Button>

View File

@@ -9,7 +9,7 @@ import {
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel,
getFilteredRowModel
} from "@tanstack/react-table";
import {
@@ -19,7 +19,7 @@ import {
TableContainer,
TableHead,
TableHeader,
TableRow,
TableRow
} from "@/components/ui/table";
import { Button } from "@app/components/ui/button";
import { useState } from "react";
@@ -36,7 +36,7 @@ interface ResourcesDataTableProps<TData, TValue> {
export function ResourcesDataTable<TData, TValue>({
addResource,
columns,
data,
data
}: ResourcesDataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@@ -50,14 +50,16 @@ export function ResourcesDataTable<TData, TValue>({
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
initialState: {
pagination: {
pageSize: 20,
pageIndex: 0
}
},
state: {
sorting,
columnFilters,
pagination: {
pageSize: 100,
pageIndex: 0,
},
},
columnFilters
}
});
return (
@@ -103,7 +105,7 @@ export function ResourcesDataTable<TData, TValue>({
: flexRender(
header.column.columnDef
.header,
header.getContext(),
header.getContext()
)}
</TableHead>
);
@@ -124,7 +126,7 @@ export function ResourcesDataTable<TData, TValue>({
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
cell.getContext()
)}
</TableCell>
))}

View File

@@ -42,7 +42,7 @@ export const ResourcesSplashCard = () => {
Resources
</h3>
<p className="text-sm">
Resources are proxies to applications running on your private network. Create a resource for any HTTP or HTTPS app on your private network.
Resources are proxies to applications running on your private network. Create a resource for any HTTP/HTTPS or raw TCP/UDP service on your private network.
Each resource must be connected to a site to enable private, secure connectivity through an encrypted WireGuard tunnel.
</p>
<ul className="text-sm text-muted-foreground space-y-2">

View File

@@ -26,7 +26,7 @@ import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { set } from "zod";
import { formatAxiosError } from "@app/lib/api";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import CopyToClipboard from "@app/components/CopyToClipboard";
@@ -38,7 +38,7 @@ export type ResourceRow = {
domain: string;
site: string;
siteId: string;
hasAuth: boolean;
authState: string;
http: boolean;
protocol: string;
proxyPort: number | null;
@@ -52,8 +52,6 @@ type ResourcesTableProps = {
export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const router = useRouter();
const { toast } = useToast();
const api = createApiClient(useEnvContext());
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
@@ -165,9 +163,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
header: "Protocol",
cell: ({ row }) => {
const resourceRow = row.original;
return (
<span>{resourceRow.protocol.toUpperCase()}</span>
);
return <span>{resourceRow.protocol.toUpperCase()}</span>;
}
},
{
@@ -177,17 +173,23 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const resourceRow = row.original;
return (
<div>
{!resourceRow.http ? (
<CopyToClipboard text={resourceRow.proxyPort!.toString()} isLink={false} />
) : (
<CopyToClipboard text={resourceRow.domain} isLink={true} />
)}
{!resourceRow.http ? (
<CopyToClipboard
text={resourceRow.proxyPort!.toString()}
isLink={false}
/>
) : (
<CopyToClipboard
text={resourceRow.domain}
isLink={true}
/>
)}
</div>
);
}
},
{
accessorKey: "hasAuth",
accessorKey: "authState",
header: ({ column }) => {
return (
<Button
@@ -205,23 +207,19 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const resourceRow = row.original;
return (
<div>
{!resourceRow.http ? (
{resourceRow.authState === "protected" ? (
<span className="text-green-500 flex items-center space-x-2">
<ShieldCheck className="w-4 h-4" />
<span>Protected</span>
</span>
) : resourceRow.authState === "not_protected" ? (
<span className="text-yellow-500 flex items-center space-x-2">
<ShieldOff className="w-4 h-4" />
<span>Not Protected</span>
</span>
) : (
<span>--</span>
) :
resourceRow.hasAuth ? (
<span className="text-green-500 flex items-center space-x-2">
<ShieldCheck className="w-4 h-4" />
<span>Protected</span>
</span>
) : (
<span className="text-yellow-500 flex items-center space-x-2">
<ShieldOff className="w-4 h-4" />
<span>Not Protected</span>
</span>
)
}
)}
</div>
);
}

View File

@@ -14,7 +14,7 @@ export default function CustomDomainInput({
domainSuffix,
placeholder = "Enter subdomain",
value: defaultValue,
onChange,
onChange
}: CustomDomainInputProps) {
const [value, setValue] = React.useState(defaultValue);
@@ -34,10 +34,10 @@ export default function CustomDomainInput({
placeholder={placeholder}
value={value}
onChange={handleChange}
className="rounded-r-none flex-grow"
className="rounded-r-none w-full"
/>
<div className="inline-flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-muted-foreground">
<span className="text-sm">.{domainSuffix}</span>
<div className="max-w-1/2 flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-muted-foreground">
<span className="text-sm truncate">.{domainSuffix}</span>
</div>
</div>
</div>

View File

@@ -2,11 +2,7 @@
import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
InfoIcon,
ShieldCheck,
ShieldOff
} from "lucide-react";
import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { Separator } from "@app/components/ui/separator";
@@ -26,9 +22,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
const { org } = useOrgContext();
const { resource, authInfo } = useResourceContext();
const fullUrl = `${resource.ssl ? "https" : "http"}://${
resource.subdomain
}.${org.org.domain}`;
let fullUrl = `${resource.ssl ? "https" : "http"}://`;
if (resource.isBaseDomain) {
fullUrl = fullUrl + org.org.domain;
} else {
fullUrl = fullUrl + `${resource.subdomain}.${org.org.domain}`;
}
return (
<Alert>
@@ -82,7 +81,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<InfoSection>
<InfoSectionTitle>Protocol</InfoSectionTitle>
<InfoSectionContent>
<span>{resource.protocol.toUpperCase()}</span>
<span>
{resource.protocol.toUpperCase()}
</span>
</InfoSectionContent>
</InfoSection>
<Separator orientation="vertical" />

View File

@@ -11,7 +11,7 @@ import {
FormMessage,
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -55,8 +55,6 @@ export default function SetResourcePasswordForm({
resourceId,
onSetPassword,
}: SetPasswordFormProps) {
const { toast } = useToast();
const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false);

View File

@@ -11,7 +11,7 @@ import {
FormMessage,
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -60,8 +60,6 @@ export default function SetResourcePincodeForm({
resourceId,
onSetPincode,
}: SetPincodeFormProps) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const api = createApiClient(useEnvContext());

View File

@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { ListRolesResponse } from "@server/routers/role";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { AxiosResponse } from "axios";
@@ -49,6 +49,7 @@ import {
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { InfoPopup } from "@app/components/ui/info-popup";
import { useRouter } from "next/navigation";
const UsersRolesFormSchema = z.object({
roles: z.array(
@@ -75,7 +76,6 @@ const whitelistSchema = z.object({
});
export default function ResourceAuthenticationPage() {
const { toast } = useToast();
const { org } = useOrgContext();
const { resource, updateResource, authInfo, updateAuthInfo } =
useResourceContext();
@@ -83,6 +83,7 @@ export default function ResourceAuthenticationPage() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const [pageLoading, setPageLoading] = useState(true);
@@ -237,6 +238,7 @@ export default function ResourceAuthenticationPage() {
title: "Saved successfully",
description: "Whitelist settings have been saved"
});
router.refresh();
} catch (e) {
console.error(e);
toast({
@@ -284,6 +286,7 @@ export default function ResourceAuthenticationPage() {
title: "Saved successfully",
description: "Authentication settings have been saved"
});
router.refresh();
} catch (e) {
console.error(e);
toast({
@@ -315,6 +318,7 @@ export default function ResourceAuthenticationPage() {
updateAuthInfo({
password: false
});
router.refresh();
})
.catch((e) => {
toast({
@@ -345,6 +349,7 @@ export default function ResourceAuthenticationPage() {
updateAuthInfo({
pincode: false
});
router.refresh();
})
.catch((e) => {
toast({
@@ -732,6 +737,9 @@ export default function ResourceAuthenticationPage() {
}}
/>
</FormControl>
<FormDescription>
Press enter to add an email after typing it in the input field.
</FormDescription>
</FormItem>
)}
/>

View File

@@ -45,7 +45,7 @@ import {
TableHeader,
TableRow
} from "@app/components/ui/table";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { ArrayElement } from "@server/types/ArrayElement";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
@@ -62,39 +62,11 @@ import {
SettingsSectionFooter
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { useSiteContext } from "@app/hooks/useSiteContext";
import { InfoPopup } from "@app/components/ui/info-popup";
// Regular expressions for validation
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;
// Schema for domain names and IP addresses
const domainSchema = z
.string()
.min(1, "Domain cannot be empty")
.max(255, "Domain name too long")
.refine(
(value) => {
// Check if it's a valid IP address (v4 or v6)
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
return true;
}
// Check if it's a valid domain name
return DOMAIN_REGEX.test(value);
},
{
message: "Invalid domain name or IP address format",
path: ["domain"]
}
);
import { useRouter } from "next/navigation";
import { isTargetValid } from "@server/lib/validators";
const addTargetSchema = z.object({
ip: domainSchema,
ip: z.string().refine(isTargetValid),
method: z.string().nullable(),
port: z.coerce.number().int().positive()
// protocol: z.string(),
@@ -113,7 +85,6 @@ export default function ReverseProxyTargets(props: {
}) {
const params = use(props.params);
const { toast } = useToast();
const { resource, updateResource } = useResourceContext();
const api = createApiClient(useEnvContext());
@@ -126,15 +97,15 @@ export default function ReverseProxyTargets(props: {
const [loading, setLoading] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
const router = useRouter();
const addTargetForm = useForm({
resolver: zodResolver(addTargetSchema),
defaultValues: {
ip: "",
method: resource.http ? "http" : null,
port: resource.http ? 80 : resource.proxyPort || 1234
method: resource.http ? "http" : null
// protocol: "TCP",
}
} as z.infer<typeof addTargetSchema>
});
useEffect(() => {
@@ -270,7 +241,7 @@ export default function ReverseProxyTargets(props: {
>(`/resource/${params.resourceId}/target`, data);
target.targetId = res.data.data.targetId;
} else if (target.updated) {
const res = await api.post(
await api.post(
`/target/${target.targetId}`,
data
);
@@ -291,7 +262,7 @@ export default function ReverseProxyTargets(props: {
for (const targetId of targetsToRemove) {
await api.delete(`/target/${targetId}`);
setTargets(
targets.filter((target) => target.targetId !== targetId)
targets.filter((t) => t.targetId !== targetId)
);
}
@@ -301,6 +272,7 @@ export default function ReverseProxyTargets(props: {
});
setTargetsToRemove([]);
router.refresh();
} catch (err) {
console.error(err);
toast({
@@ -317,17 +289,32 @@ export default function ReverseProxyTargets(props: {
}
async function saveSsl(val: boolean) {
const res = await api.post(`/resource/${params.resourceId}`, {
ssl: val
});
const res = await api
.post(`/resource/${params.resourceId}`, {
ssl: val
})
.catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: "Failed to update SSL configuration",
description: formatAxiosError(
err,
"An error occurred while updating the SSL configuration"
)
});
});
setSslEnabled(val);
updateResource({ ssl: val });
if (res && res.status === 200) {
setSslEnabled(val);
updateResource({ ssl: val });
toast({
title: "SSL Configuration",
description: "SSL configuration updated successfully"
});
toast({
title: "SSL Configuration",
description: "SSL configuration updated successfully"
});
router.refresh();
}
}
const columns: ColumnDef<LocalTarget>[] = [
@@ -653,7 +640,8 @@ export default function ReverseProxyTargets(props: {
</Table>
</TableContainer>
<p className="text-sm text-muted-foreground">
Adding more than one target above will enable load balancing.
Adding more than one target above will enable load
balancing.
</p>
</SettingsSectionBody>
<SettingsSectionFooter>

View File

@@ -14,6 +14,19 @@ import {
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem
} from "@/components/ui/command";
import { cn } from "@app/lib/cn";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@/components/ui/popover";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { ListSitesResponse } from "@server/routers/site";
import { useEffect, useState } from "react";
@@ -21,7 +34,7 @@ import { AxiosResponse } from "axios";
import { useParams, useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { GetResourceAuthInfoResponse } from "@server/routers/resource";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import {
SettingsContainer,
SettingsSection,
@@ -36,14 +49,18 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
import CustomDomainInput from "../CustomDomainInput";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
import { subdomainSchema } from "@server/lib/schemas";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Label } from "@app/components/ui/label";
const GeneralFormSchema = z
.object({
subdomain: z.string().optional(),
name: z.string().min(1).max(255),
proxyPort: z.number().optional(),
http: z.boolean()
http: z.boolean(),
isBaseDomain: z.boolean().optional()
})
.refine(
(data) => {
@@ -64,7 +81,7 @@ const GeneralFormSchema = z
)
.refine(
(data) => {
if (data.http) {
if (data.http && !data.isBaseDomain) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
@@ -75,22 +92,34 @@ const GeneralFormSchema = z
}
);
const TransferFormSchema = z.object({
siteId: z.number()
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
type TransferFormValues = z.infer<typeof TransferFormSchema>;
export default function GeneralForm() {
const params = useParams();
const { toast } = useToast();
const { resource, updateResource } = useResourceContext();
const { org } = useOrgContext();
const router = useRouter();
const { env } = useEnvContext();
const orgId = params.orgId;
const api = createApiClient(useEnvContext());
const api = createApiClient({ env });
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [saveLoading, setSaveLoading] = useState(false);
const [domainSuffix, setDomainSuffix] = useState(org.org.domain);
const [transferLoading, setTransferLoading] = useState(false);
const [open, setOpen] = useState(false);
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
resource.isBaseDomain ? "basedomain" : "subdomain"
);
const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema),
@@ -98,11 +127,19 @@ export default function GeneralForm() {
name: resource.name,
subdomain: resource.subdomain ? resource.subdomain : undefined,
proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
http: resource.http
http: resource.http,
isBaseDomain: resource.isBaseDomain ? true : false
},
mode: "onChange"
});
const transferForm = useForm<TransferFormValues>({
resolver: zodResolver(TransferFormSchema),
defaultValues: {
siteId: resource.siteId ? Number(resource.siteId) : undefined
}
});
useEffect(() => {
const fetchSites = async () => {
const res = await api.get<AxiosResponse<ListSitesResponse>>(
@@ -116,14 +153,13 @@ export default function GeneralForm() {
async function onSubmit(data: GeneralFormValues) {
setSaveLoading(true);
api.post<AxiosResponse<GetResourceAuthInfoResponse>>(
`resource/${resource?.resourceId}`,
{
const res = await api
.post(`resource/${resource?.resourceId}`, {
name: data.name,
subdomain: data.subdomain
// siteId: data.siteId,
}
)
subdomain: data.subdomain,
proxyPort: data.proxyPort,
isBaseDomain: data.isBaseDomain
})
.catch((e) => {
toast({
variant: "destructive",
@@ -133,18 +169,50 @@ export default function GeneralForm() {
"An error occurred while updating the resource"
)
});
});
if (res && res.status === 200) {
toast({
title: "Resource updated",
description: "The resource has been updated successfully"
});
updateResource({
name: data.name,
subdomain: data.subdomain,
proxyPort: data.proxyPort,
isBaseDomain: data.isBaseDomain
});
}
setSaveLoading(false);
}
async function onTransfer(data: TransferFormValues) {
setTransferLoading(true);
const res = await api
.post(`resource/${resource?.resourceId}/transfer`, {
siteId: data.siteId
})
.then(() => {
.catch((e) => {
toast({
title: "Resource updated",
description: "The resource has been updated successfully"
variant: "destructive",
title: "Failed to transfer resource",
description: formatAxiosError(
e,
"An error occurred while transferring the resource"
)
});
});
updateResource({ name: data.name, subdomain: data.subdomain });
router.refresh();
})
.finally(() => setSaveLoading(false));
if (res && res.status === 200) {
toast({
title: "Resource transferred",
description: "The resource has been transferred successfully"
});
router.refresh();
}
setTransferLoading(false);
}
return (
@@ -185,40 +253,103 @@ export default function GeneralForm() {
)}
/>
{resource.http ? (
<FormField
control={form.control}
name="subdomain"
render={({ field }) => (
<FormItem>
<FormLabel>Subdomain</FormLabel>
<FormControl>
<CustomDomainInput
value={
field.value || ""
}
domainSuffix={
domainSuffix
}
placeholder="Enter subdomain"
onChange={(value) =>
form.setValue(
"subdomain",
value
)
}
/>
</FormControl>
<FormDescription>
This is the subdomain that
will be used to access the
resource.
</FormDescription>
<FormMessage />
</FormItem>
{resource.http && (
<>
{env.flags.allowBaseDomainResources && (
<div>
<RadioGroup
className="flex space-x-4"
defaultValue={domainType}
onValueChange={(val) => {
setDomainType(
val as any
);
form.setValue(
"isBaseDomain",
val === "basedomain"
);
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="subdomain"
id="r1"
/>
<Label htmlFor="r1">
Subdomain
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="basedomain"
id="r2"
/>
<Label htmlFor="r2">
Base Domain
</Label>
</div>
</RadioGroup>
</div>
)}
/>
) : (
<FormField
control={form.control}
name="subdomain"
render={({ field }) => (
<FormItem>
{!env.flags
.allowBaseDomainResources && (
<FormLabel>
Subdomain
</FormLabel>
)}
{domainType ===
"subdomain" ? (
<FormControl>
<CustomDomainInput
value={
field.value ||
""
}
domainSuffix={
domainSuffix
}
placeholder="Enter subdomain"
onChange={(
value
) =>
form.setValue(
"subdomain",
value
)
}
/>
</FormControl>
) : (
<FormControl>
<Input
value={
domainSuffix
}
readOnly
disabled
/>
</FormControl>
)}
<FormDescription>
This is the subdomain
that will be used to
access the resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{!resource.http && (
<FormField
control={form.control}
name="proxyPort"
@@ -273,6 +404,130 @@ export default function GeneralForm() {
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Transfer Resource
</SettingsSectionTitle>
<SettingsSectionDescription>
Transfer this resource to a different site
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...transferForm}>
<form
onSubmit={transferForm.handleSubmit(onTransfer)}
className="space-y-4"
id="transfer-form"
>
<FormField
control={transferForm.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
Destination Site
</FormLabel>
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(site) =>
site.siteId ===
field.value
)?.name
: "Select site"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput
placeholder="Search sites..."
className="h-9"
/>
<CommandEmpty>
No sites found.
</CommandEmpty>
<CommandGroup>
{sites.map(
(site) => (
<CommandItem
value={`${site.name}:${site.siteId}`}
key={
site.siteId
}
onSelect={() => {
transferForm.setValue(
"siteId",
site.siteId
);
setOpen(
false
);
}}
>
{
site.name
}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
)
)}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
Select the new site to transfer
this resource to.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={transferLoading}
disabled={transferLoading}
form="transfer-form"
variant="destructive"
>
Transfer Resource
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}

View File

@@ -99,6 +99,11 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
href: `/{orgId}/settings/resources/{resourceId}/authentication`
// icon: <Shield className="w-4 h-4" />,
});
sidebarNavItems.push({
title: "Rules",
href: `/{orgId}/settings/resources/{resourceId}/rules`
// icon: <Shield className="w-4 h-4" />,
});
}
return (

View File

@@ -0,0 +1,783 @@
"use client";
import { useEffect, useState, use } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { AxiosResponse } from "axios";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import {
ColumnDef,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
getCoreRowModel,
useReactTable,
flexRender
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import { toast } from "@app/hooks/useToast";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { ArrayElement } from "@server/types/ArrayElement";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionFooter
} from "@app/components/Settings";
import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules";
import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { ArrowUpDown, Check, InfoIcon, X } from "lucide-react";
import {
InfoSection,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { Separator } from "@app/components/ui/separator";
import { InfoPopup } from "@app/components/ui/info-popup";
import {
isValidCIDR,
isValidIP,
isValidUrlGlobPattern
} from "@server/lib/validators";
import { Switch } from "@app/components/ui/switch";
import { useRouter } from "next/navigation";
// Schema for rule validation
const addRuleSchema = z.object({
action: z.string(),
match: z.string(),
value: z.string(),
priority: z.coerce.number().int().optional()
});
type LocalRule = ArrayElement<ListResourceRulesResponse["rules"]> & {
new?: boolean;
updated?: boolean;
};
enum RuleAction {
ACCEPT = "Always Allow",
DROP = "Always Deny"
}
enum RuleMatch {
IP = "IP",
CIDR = "IP Range",
PATH = "Path"
}
export default function ResourceRules(props: {
params: Promise<{ resourceId: number }>;
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const api = createApiClient(useEnvContext());
const [rules, setRules] = useState<LocalRule[]>([]);
const [rulesToRemove, setRulesToRemove] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
const router = useRouter();
const addRuleForm = useForm({
resolver: zodResolver(addRuleSchema),
defaultValues: {
action: "ACCEPT",
match: "IP",
value: ""
}
});
useEffect(() => {
const fetchRules = async () => {
try {
const res = await api.get<
AxiosResponse<ListResourceRulesResponse>
>(`/resource/${params.resourceId}/rules`);
if (res.status === 200) {
setRules(res.data.data.rules);
}
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: "Failed to fetch rules",
description: formatAxiosError(
err,
"An error occurred while fetching rules"
)
});
} finally {
setPageLoading(false);
}
};
fetchRules();
}, []);
async function addRule(data: z.infer<typeof addRuleSchema>) {
const isDuplicate = rules.some(
(rule) =>
rule.action === data.action &&
rule.match === data.match &&
rule.value === data.value
);
if (isDuplicate) {
toast({
variant: "destructive",
title: "Duplicate rule",
description: "A rule with these settings already exists"
});
return;
}
if (data.match === "CIDR" && !isValidCIDR(data.value)) {
toast({
variant: "destructive",
title: "Invalid CIDR",
description: "Please enter a valid CIDR value"
});
setLoading(false);
return;
}
if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) {
toast({
variant: "destructive",
title: "Invalid URL path",
description: "Please enter a valid URL path value"
});
setLoading(false);
return;
}
if (data.match === "IP" && !isValidIP(data.value)) {
toast({
variant: "destructive",
title: "Invalid IP",
description: "Please enter a valid IP address"
});
setLoading(false);
return;
}
// find the highest priority and add one
let priority = data.priority;
if (priority === undefined) {
priority = rules.reduce(
(acc, rule) => (rule.priority > acc ? rule.priority : acc),
0
);
priority++;
}
const newRule: LocalRule = {
...data,
ruleId: new Date().getTime(),
new: true,
resourceId: resource.resourceId,
priority,
enabled: true
};
setRules([...rules, newRule]);
addRuleForm.reset();
}
const removeRule = (ruleId: number) => {
setRules([...rules.filter((rule) => rule.ruleId !== ruleId)]);
if (!rules.find((rule) => rule.ruleId === ruleId)?.new) {
setRulesToRemove([...rulesToRemove, ruleId]);
}
};
async function updateRule(ruleId: number, data: Partial<LocalRule>) {
setRules(
rules.map((rule) =>
rule.ruleId === ruleId
? { ...rule, ...data, updated: true }
: rule
)
);
}
async function saveApplyRules(val: boolean) {
const res = await api
.post(`/resource/${params.resourceId}`, {
applyRules: val
})
.catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: "Failed to update rules",
description: formatAxiosError(
err,
"An error occurred while updating rules"
)
});
});
if (res && res.status === 200) {
setRulesEnabled(val);
updateResource({ applyRules: val });
toast({
title: "Enable Rules",
description: "Rule evaluation has been updated"
});
router.refresh();
}
}
function getValueHelpText(type: string) {
switch (type) {
case "CIDR":
return "Enter an address in CIDR format (e.g., 103.21.244.0/22)";
case "IP":
return "Enter an IP address (e.g., 103.21.244.12)";
case "PATH":
return "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)";
}
}
async function saveRules() {
try {
setLoading(true);
for (let rule of rules) {
const data = {
action: rule.action,
match: rule.match,
value: rule.value,
priority: rule.priority,
enabled: rule.enabled
};
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
toast({
variant: "destructive",
title: "Invalid CIDR",
description: "Please enter a valid CIDR value"
});
setLoading(false);
return;
}
if (
rule.match === "PATH" &&
!isValidUrlGlobPattern(rule.value)
) {
toast({
variant: "destructive",
title: "Invalid URL path",
description: "Please enter a valid URL path value"
});
setLoading(false);
return;
}
if (rule.match === "IP" && !isValidIP(rule.value)) {
toast({
variant: "destructive",
title: "Invalid IP",
description: "Please enter a valid IP address"
});
setLoading(false);
return;
}
if (rule.priority === undefined) {
toast({
variant: "destructive",
title: "Invalid Priority",
description: "Please enter a valid priority"
});
setLoading(false);
return;
}
// make sure no duplicate priorities
const priorities = rules.map((r) => r.priority);
if (priorities.length !== new Set(priorities).size) {
toast({
variant: "destructive",
title: "Duplicate Priorities",
description: "Please enter unique priorities"
});
setLoading(false);
return;
}
if (rule.new) {
const res = await api.put(
`/resource/${params.resourceId}/rule`,
data
);
rule.ruleId = res.data.data.ruleId;
} else if (rule.updated) {
await api.post(
`/resource/${params.resourceId}/rule/${rule.ruleId}`,
data
);
}
setRules([
...rules.map((r) => {
let res = {
...r,
new: false,
updated: false
};
return res;
})
]);
}
for (const ruleId of rulesToRemove) {
await api.delete(
`/resource/${params.resourceId}/rule/${ruleId}`
);
setRules(rules.filter((r) => r.ruleId !== ruleId));
}
toast({
title: "Rules updated",
description: "Rules updated successfully"
});
setRulesToRemove([]);
router.refresh();
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: "Operation failed",
description: formatAxiosError(
err,
"An error occurred during the save operation"
)
});
}
setLoading(false);
}
const columns: ColumnDef<LocalRule>[] = [
{
accessorKey: "priority",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Priority
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => (
<Input
defaultValue={row.original.priority}
className="w-[75px]"
type="number"
onBlur={(e) => {
const parsed = z.coerce
.number()
.int()
.optional()
.safeParse(e.target.value);
if (!parsed.data) {
toast({
variant: "destructive",
title: "Invalid IP",
description: "Please enter a valid priority"
});
setLoading(false);
return;
}
updateRule(row.original.ruleId, {
priority: parsed.data
});
}}
/>
)
},
{
accessorKey: "action",
header: "Action",
cell: ({ row }) => (
<Select
defaultValue={row.original.action}
onValueChange={(value: "ACCEPT" | "DROP") =>
updateRule(row.original.ruleId, { action: value })
}
>
<SelectTrigger className="min-w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACCEPT">
{RuleAction.ACCEPT}
</SelectItem>
<SelectItem value="DROP">{RuleAction.DROP}</SelectItem>
</SelectContent>
</Select>
)
},
{
accessorKey: "match",
header: "Match Type",
cell: ({ row }) => (
<Select
defaultValue={row.original.match}
onValueChange={(value: "CIDR" | "IP" | "PATH") =>
updateRule(row.original.ruleId, { match: value })
}
>
<SelectTrigger className="min-w-[125px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
</SelectContent>
</Select>
)
},
{
accessorKey: "value",
header: "Value",
cell: ({ row }) => (
<Input
defaultValue={row.original.value}
className="min-w-[200px]"
onBlur={(e) =>
updateRule(row.original.ruleId, {
value: e.target.value
})
}
/>
)
},
{
accessorKey: "enabled",
header: "Enabled",
cell: ({ row }) => (
<Switch
defaultChecked={row.original.enabled}
onCheckedChange={(val) =>
updateRule(row.original.ruleId, { enabled: val })
}
/>
)
},
{
id: "actions",
cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2">
<Button
variant="outline"
onClick={() => removeRule(row.original.ruleId)}
>
Delete
</Button>
</div>
)
}
];
const table = useReactTable({
data: rules,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel()
});
if (pageLoading) {
return <></>;
}
return (
<SettingsContainer>
<Alert className="hidden md:block">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">About Rules</AlertTitle>
<AlertDescription className="mt-4">
<div className="space-y-1 mb-4">
<p>
Rules allow you to control access to your resource
based on a set of criteria. You can create rules to
allow or deny access based on IP address or URL
path.
</p>
</div>
<InfoSections>
<InfoSection>
<InfoSectionTitle>Actions</InfoSectionTitle>
<ul className="text-sm text-muted-foreground space-y-1">
<li className="flex items-center gap-2">
<Check className="text-green-500 w-4 h-4" />
Always Allow: Bypass all authentication
methods
</li>
<li className="flex items-center gap-2">
<X className="text-red-500 w-4 h-4" />
Always Deny: Block all requests; no
authentication can be attempted
</li>
</ul>
</InfoSection>
<Separator orientation="vertical" />
<InfoSection>
<InfoSectionTitle>
Matching Criteria
</InfoSectionTitle>
<ul className="text-sm text-muted-foreground space-y-1">
<li className="flex items-center gap-2">
Match a specific IP address
</li>
<li className="flex items-center gap-2">
Match a range of IP addresses in CIDR
notation
</li>
<li className="flex items-center gap-2">
Match a URL path or pattern
</li>
</ul>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>Enable Rules</SettingsSectionTitle>
<SettingsSectionDescription>
Enable or disable rule evaluation for this resource
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SwitchInput
id="rules-toggle"
label="Enable Rules"
defaultChecked={rulesEnabled}
onCheckedChange={async (val) => {
await saveApplyRules(val);
}}
/>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Resource Rules Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure rules to control access to your resource
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...addRuleForm}>
<form
onSubmit={addRuleForm.handleSubmit(addRule)}
className="space-y-4"
>
<div className="grid grid-cols-3 gap-4">
<FormField
control={addRuleForm.control}
name="action"
render={({ field }) => (
<FormItem>
<FormLabel>Action</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACCEPT">
{RuleAction.ACCEPT}
</SelectItem>
<SelectItem value="DROP">
{RuleAction.DROP}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addRuleForm.control}
name="match"
render={({ field }) => (
<FormItem>
<FormLabel>Match Type</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="IP">
{RuleMatch.IP}
</SelectItem>
<SelectItem value="CIDR">
{RuleMatch.CIDR}
</SelectItem>
{resource.http && (
<SelectItem value="PATH">
{RuleMatch.PATH}
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addRuleForm.control}
name="value"
render={({ field }) => (
<FormItem>
<InfoPopup
text="Value"
info={
getValueHelpText(
addRuleForm.watch(
"match"
)
) || ""
}
/>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button
type="submit"
variant="outline"
disabled={!rulesEnabled}
>
Add Rule
</Button>
</form>
</Form>
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column
.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column
.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No rules. Add a rule using the form.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<p className="text-sm text-muted-foreground">
Rules are evaluated by priority in ascending order.
</p>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
onClick={saveRules}
loading={loading}
disabled={loading}
>
Save Rules
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}

View File

@@ -56,11 +56,14 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
protocol: resource.protocol,
proxyPort: resource.proxyPort,
http: resource.http,
hasAuth:
resource.sso ||
resource.pincodeId !== null ||
resource.pincodeId !== null ||
resource.whitelist
authState: !resource.http
? "none"
: resource.sso ||
resource.pincodeId !== null ||
resource.pincodeId !== null ||
resource.whitelist
? "protected"
: "not_protected"
};
});

View File

@@ -18,7 +18,7 @@ import {
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { InviteUserBody, InviteUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
@@ -94,7 +94,6 @@ export default function CreateShareLinkForm({
setOpen,
onCreated
}: FormProps) {
const { toast } = useToast();
const { org } = useOrgContext();
const { env } = useEnvContext();
@@ -320,7 +319,7 @@ export default function CreateShareLinkForm({
) => (
<CommandItem
value={
r.resourceId.toString()
`${r.name}:${r.resourceId}`
}
key={
r.resourceId

View File

@@ -50,13 +50,15 @@ export function ShareLinksDataTable<TData, TValue>({
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
state: {
sorting,
columnFilters,
initialState: {
pagination: {
pageSize: 100,
pageSize: 20,
pageIndex: 0
}
},
state: {
sorting,
columnFilters
}
});

View File

@@ -25,7 +25,7 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { formatAxiosError } from "@app/lib/api";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { ArrayElement } from "@server/types/ArrayElement";
@@ -54,8 +54,6 @@ export default function ShareLinksTable({
}: ShareLinksTableProps) {
const router = useRouter();
const { toast } = useToast();
const api = createApiClient(useEnvContext());
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);

View File

@@ -10,7 +10,7 @@ import {
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -38,7 +38,16 @@ import { SiteRow } from "./SitesTable";
import { AxiosResponse } from "axios";
import { Button } from "@app/components/ui/button";
import Link from "next/link";
import { ArrowUpRight, SquareArrowOutUpRight } from "lucide-react";
import {
ArrowUpRight,
ChevronsUpDown,
SquareArrowOutUpRight
} from "lucide-react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from "@app/components/ui/collapsible";
const createSiteFormSchema = z.object({
name: z
@@ -72,13 +81,14 @@ export default function CreateSiteForm({
setChecked,
orgId
}: CreateSiteFormProps) {
const { toast } = useToast();
const api = createApiClient(useEnvContext());
const { env } = useEnvContext();
const [isLoading, setIsLoading] = useState(false);
const [isChecked, setIsChecked] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [keypair, setKeypair] = useState<{
publicKey: string;
privateKey: string;
@@ -183,10 +193,9 @@ export default function CreateSiteForm({
}
const res = await api
.put<AxiosResponse<CreateSiteResponse>>(
`/org/${orgId}/site/`,
payload
)
.put<
AxiosResponse<CreateSiteResponse>
>(`/org/${orgId}/site/`, payload)
.catch((e) => {
toast({
variant: "destructive",
@@ -234,13 +243,19 @@ Endpoint = ${siteDefaults.endpoint}:${siteDefaults.listenPort}
PersistentKeepalive = 5`
: "";
// am I at http or https?
let proto = "https:";
// if (typeof window !== "undefined") {
// proto = window.location.protocol;
// }
const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`;
const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${proto}//${siteDefaults?.endpoint}`;
const newtConfigDockerCompose = `services:
newt:
image: fosrl/newt
container_name: newt
restart: unless-stopped
environment:
- PANGOLIN_ENDPOINT=${env.app.dashboardUrl}
- NEWT_ID=${siteDefaults?.newtId}
- NEWT_SECRET=${siteDefaults?.newtSecret}`;
const newtConfigDockerRun = `docker run -it fosrl/newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`;
return (
<div className="space-y-4">
@@ -312,32 +327,6 @@ PersistentKeepalive = 5`
)}
/>
<div className="w-full">
{form.watch("method") === "wireguard" && !isLoading ? (
<>
<CopyTextBox text={wgConfig} />
<span className="text-sm text-muted-foreground">
You will only be able to see the
configuration once.
</span>
</>
) : form.watch("method") === "wireguard" &&
isLoading ? (
<p>Loading WireGuard configuration...</p>
) : form.watch("method") === "newt" ? (
<>
<CopyTextBox
text={newtConfig}
wrapText={false}
/>
<span className="text-sm text-muted-foreground">
You will only be able to see the
configuration once.
</span>
</>
) : null}
</div>
{form.watch("method") === "newt" && (
<Link
className="text-sm text-primary flex items-center gap-1"
@@ -353,6 +342,81 @@ PersistentKeepalive = 5`
</Link>
)}
<div className="w-full">
{form.watch("method") === "wireguard" && !isLoading ? (
<>
<CopyTextBox text={wgConfig} />
<span className="text-sm text-muted-foreground mt-2">
You will only be able to see the
configuration once.
</span>
</>
) : form.watch("method") === "wireguard" &&
isLoading ? (
<p>Loading WireGuard configuration...</p>
) : form.watch("method") === "newt" ? (
<>
<div className="mb-2">
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className="space-y-2"
>
<div className="mx-auto">
<CopyTextBox
text={newtConfig}
wrapText={false}
/>
</div>
<div className="flex items-center justify-between space-x-4">
<CollapsibleTrigger asChild>
<Button
variant="text"
size="sm"
className="p-0 flex items-center justify-between w-full"
>
<h4 className="text-sm font-semibold">
Expand for Docker Deployment
Details
</h4>
<div>
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">
Toggle
</span>
</div>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="space-y-4">
<div className="space-y-2">
<b>Docker Compose</b>
<CopyTextBox
text={
newtConfigDockerCompose
}
wrapText={false}
/>
</div>
<div className="space-y-2">
<b>Docker Run</b>
<CopyTextBox
text={newtConfigDockerRun}
wrapText={false}
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
<span className="text-sm text-muted-foreground">
You will only be able to see the
configuration once.
</span>
</>
) : null}
</div>
{form.watch("method") === "local" && (
<Link
className="text-sm text-primary flex items-center gap-1"
@@ -360,10 +424,7 @@ PersistentKeepalive = 5`
target="_blank"
rel="noopener noreferrer"
>
<span>
{" "}
Local sites do not tunnel, learn more
</span>
<span> Local sites do not tunnel, learn more</span>
<SquareArrowOutUpRight size={14} />
</Link>
)}

View File

@@ -9,7 +9,7 @@ import {
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel,
getFilteredRowModel
} from "@tanstack/react-table";
import {
@@ -19,7 +19,7 @@ import {
TableContainer,
TableHead,
TableHeader,
TableRow,
TableRow
} from "@/components/ui/table";
import { Button } from "@app/components/ui/button";
import { useState } from "react";
@@ -36,7 +36,7 @@ interface DataTableProps<TData, TValue> {
export function SitesDataTable<TData, TValue>({
addSite,
columns,
data,
data
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@@ -50,14 +50,16 @@ export function SitesDataTable<TData, TValue>({
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
initialState: {
pagination: {
pageSize: 20,
pageIndex: 0
}
},
state: {
sorting,
columnFilters,
pagination: {
pageSize: 100,
pageIndex: 0,
},
},
columnFilters
}
});
return (
@@ -103,7 +105,7 @@ export function SitesDataTable<TData, TValue>({
: flexRender(
header.column.columnDef
.header,
header.getContext(),
header.getContext()
)}
</TableHead>
);
@@ -124,7 +126,7 @@ export function SitesDataTable<TData, TValue>({
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
cell.getContext()
)}
</TableCell>
))}

View File

@@ -22,7 +22,7 @@ import { AxiosResponse } from "axios";
import { useState } from "react";
import CreateSiteForm from "./CreateSiteForm";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -47,8 +47,6 @@ type SitesTableProps = {
export default function SitesTable({ sites, orgId }: SitesTableProps) {
const router = useRouter();
const { toast } = useToast();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);

View File

@@ -15,7 +15,7 @@ import {
import { Input } from "@/components/ui/input";
import { useSiteContext } from "@app/hooks/useSiteContext";
import { useForm } from "react-hook-form";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import {
SettingsContainer,
@@ -40,7 +40,6 @@ type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
export default function GeneralPage() {
const { site, updateSite } = useSiteContext();
const { toast } = useToast();
const api = createApiClient(useEnvContext());

View File

@@ -36,7 +36,7 @@ import {
} from "@server/routers/auth";
import { Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "../../../components/ui/alert";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/api";;
import { createApiClient } from "@app/lib/api";
@@ -96,8 +96,6 @@ export default function ResetPasswordForm({
const [state, setState] = useState<"request" | "reset" | "mfa">(getState());
const { toast } = useToast();
const api = createApiClient(useEnvContext());
const form = useForm<z.infer<typeof formSchema>>({

View File

@@ -48,7 +48,7 @@ import {
import ResourceAccessDenied from "./ResourceAccessDenied";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import Link from "next/link";
const pinSchema = z.object({
@@ -91,7 +91,6 @@ type ResourceAuthPortalProps = {
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const router = useRouter();
const { toast } = useToast();
const getNumMethods = () => {
let colLength = 0;

View File

@@ -31,7 +31,7 @@ import { AxiosResponse } from "axios";
import { VerifyEmailResponse } from "@server/routers/auth";
import { Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "../../../components/ui/alert";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/api";;
import { createApiClient } from "@app/lib/api";
@@ -61,8 +61,6 @@ export default function VerifyEmailForm({
const [isResending, setIsResending] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
const api = createApiClient(useEnvContext());
const form = useForm<z.infer<typeof FormSchema>>({

View File

@@ -25,7 +25,6 @@ export function DataTablePagination<TData>({
return (
<div className="flex items-center justify-between text-muted-foreground">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
@@ -38,7 +37,7 @@ export function DataTablePagination<TData>({
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50, 100, 200].map((pageSize) => (
{[10, 20, 30, 40, 50, 100].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>

View File

@@ -28,7 +28,7 @@ import {
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";;
import { useUserContext } from "@app/hooks/useUserContext";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
@@ -50,8 +50,6 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
const [step, setStep] = useState<"password" | "success">("password");
const { toast } = useToast();
const { user, updateUser } = useUserContext();
const api = createApiClient(useEnvContext());

View File

@@ -35,7 +35,7 @@ import {
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";;
import CopyTextBox from "@app/components/CopyTextBox";
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
@@ -64,8 +64,6 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
const [loading, setLoading] = useState(false);
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const { toast } = useToast();
const { user, updateUser } = useUserContext();
const api = createApiClient(useEnvContext());

View File

@@ -195,7 +195,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
Two-Factor Authentication
</h3>
<p className="text-sm text-muted-foreground">
Enter the code from your authenticator app.
Enter the code from your authenticator app or one of your single-use backup codes.
</p>
</div>
<Form {...mfaForm}>

View File

@@ -12,7 +12,7 @@ import {
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useToast } from "@app/hooks/useToast";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";;
import { Laptop, LogOut, Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
@@ -23,7 +23,6 @@ import Disable2FaForm from "./Disable2FaForm";
import Enable2FaForm from "./Enable2FaForm";
export default function ProfileIcon() {
const { toast } = useToast();
const { setTheme, theme } = useTheme();
const { env } = useEnvContext();
const api = createApiClient({ env });

View File

@@ -1,30 +1,53 @@
"use client"
"use client";
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@app/lib/cn"
import { cn } from "@app/lib/cn";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox }
interface CheckboxWithLabelProps
extends React.ComponentPropsWithoutRef<typeof Checkbox> {
label: string;
}
const CheckboxWithLabel = React.forwardRef<
React.ElementRef<typeof Checkbox>,
CheckboxWithLabelProps
>(({ className, label, id, ...props }, ref) => {
return (
<div className={cn("flex items-center space-x-2", className)}>
<Checkbox id={id} ref={ref} {...props} />
<label
htmlFor={id}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{label}
</label>
</div>
);
});
CheckboxWithLabel.displayName = "CheckboxWithLabel";
export { Checkbox, CheckboxWithLabel };

View File

@@ -6,12 +6,15 @@ export function pullEnv(): Env {
nextPort: process.env.NEXT_PORT as string,
externalPort: process.env.SERVER_EXTERNAL_PORT as string,
sessionCookieName: process.env.SESSION_COOKIE_NAME as string,
resourceAccessTokenParam: process.env.RESOURCE_ACCESS_TOKEN_PARAM as string,
resourceSessionRequestParam: process.env.RESOURCE_SESSION_REQUEST_PARAM as string
resourceAccessTokenParam: process.env
.RESOURCE_ACCESS_TOKEN_PARAM as string,
resourceSessionRequestParam: process.env
.RESOURCE_SESSION_REQUEST_PARAM as string
},
app: {
environment: process.env.ENVIRONMENT as string,
version: process.env.APP_VERSION as string
version: process.env.APP_VERSION as string,
dashboardUrl: process.env.DASHBOARD_URL as string,
},
email: {
emailEnabled: process.env.EMAIL_ENABLED === "true" ? true : false
@@ -29,6 +32,10 @@ export function pullEnv(): Env {
: false,
allowRawResources:
process.env.FLAGS_ALLOW_RAW_RESOURCES === "true" ? true : false,
allowBaseDomainResources:
process.env.FLAGS_ALLOW_BASE_DOMAIN_RESOURCES === "true"
? true
: false
}
};
}

View File

@@ -2,6 +2,7 @@ export type Env = {
app: {
environment: string;
version: string;
dashboardUrl: string;
},
server: {
externalPort: string;
@@ -18,5 +19,6 @@ export type Env = {
disableUserCreateOrg: boolean;
emailVerificationRequired: boolean;
allowRawResources: boolean;
allowBaseDomainResources: boolean;
}
};