Compare commits

...

43 Commits

Author SHA1 Message Date
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
Milo Schwartz
722b877ea5 Merge pull request #125 from fosrl/dev
Hotfix Various Bugs
2025-01-31 15:11:48 -05:00
Owen Schwartz
a9477d7eb9 Complex filter generating config; Resolves #124 2025-01-31 15:07:28 -05:00
Milo Schwartz
bb5573a8f4 allow comma in password closes #121 2025-01-31 15:03:36 -05:00
synologyy
81571a8fb7 german-translation 2025-01-31 09:01:00 +01:00
Owen Schwartz
57cd776c34 Fix migrations ordering 2025-01-30 23:30:33 -05:00
Milo Schwartz
5c507cc0ec Merge pull request #118 from fosrl/dev
Small Bugfixes
2025-01-30 22:47:56 -05:00
Milo Schwartz
55c0953fde update version in migration script log 2025-01-30 22:43:47 -05:00
Milo Schwartz
844b12d363 add copy code snippets to raw tcp/udp 2025-01-30 22:31:29 -05:00
Milo Schwartz
f40d91ff9e remove secure_cookies option from config 2025-01-30 21:53:42 -05:00
Owen Schwartz
f5e894e06a Make sure secure_cookies is true 2025-01-30 21:10:24 -05:00
Owen Schwartz
8fe479f809 Add . to make it clear there is already a dot 2025-01-30 21:02:12 -05:00
Owen Schwartz
9b9c343e2d Fix missing where clause; Resolves #117 2025-01-30 20:51:37 -05:00
Milo Schwartz
cb1ccbe945 update traefik_config example and remove quotes around smtp_port 2025-01-30 17:15:07 -05:00
Owen Schwartz
5de6028136 Put replaceme back 2025-01-30 12:27:07 -05:00
Owen Schwartz
e226a5e86b Move back to * imports 2025-01-30 12:25:59 -05:00
Owen Schwartz
f0ecfbb403 Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-01-30 12:17:02 -05:00
Owen Schwartz
985418b9af Fix wrong config 2025-01-30 12:16:56 -05:00
Julian
a039168217 add ability to transfer a resource to another site 2025-01-16 21:15:41 +01:00
52 changed files with 1811 additions and 728 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

@@ -6,7 +6,7 @@
[![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.
Pangolin is a self-hosted tunneled reverse proxy management server with identity and access control, 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.
### Installation and Documentation
@@ -129,6 +129,10 @@ Pangolin was inspired by several existing projects and concepts:
- **Authentik and Authelia**:
These projects inspired Pangolins centralized authentication system for proxies, enabling robust user and role management.
## Project Development / Roadmap
Pangolin is under active development, and we are continuously adding new features and 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

@@ -9,7 +9,6 @@ server:
internal_port: 3001
next_port: 3002
internal_hostname: "pangolin"
secure_cookies: true
session_cookie_name: "p_session_token"
resource_access_token_param: "p_token"
resource_session_request_param: "p_session_request"

View File

@@ -4,13 +4,7 @@ api:
providers:
http:
endpoint: "http://pangolin:3001/api/v1/traefik-config/http"
pollInterval: "5s"
udp:
endpoint: "http://pangolin:3001/api/v1/traefik-config/udp"
pollInterval: "5s"
tcp:
endpoint: "http://pangolin:3001/api/v1/traefik-config/tcp"
endpoint: "http://pangolin:{{.INTERNAL_PORT}}/api/v1/traefik-config"
pollInterval: "5s"
file:
filename: "/etc/traefik/dynamic_config.yml"

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

View File

@@ -9,7 +9,6 @@ server:
internal_port: 3001
next_port: 3002
internal_hostname: "pangolin"
secure_cookies: true
session_cookie_name: "p_session_token"
resource_access_token_param: "p_token"
resource_session_request_param: "p_session_request"
@@ -40,7 +39,7 @@ rate_limits:
{{if .EnableEmail}}
email:
smtp_host: "{{.EmailSMTPHost}}"
smtp_port: "{{.EmailSMTPPort}}"
smtp_port: {{.EmailSMTPPort}}
smtp_user: "{{.EmailSMTPUser}}"
smtp_pass: "{{.EmailSMTPPass}}"
no_reply: "{{.EmailNoReply}}"

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

View File

@@ -4,13 +4,7 @@ api:
providers:
http:
endpoint: "http://pangolin:3001/api/v1/traefik-config/http"
pollInterval: "5s"
udp:
endpoint: "http://pangolin:3001/api/v1/traefik-config/udp"
pollInterval: "5s"
tcp:
endpoint: "http://pangolin:3001/api/v1/traefik-config/tcp"
endpoint: "http://pangolin:3001/api/v1/traefik-config"
pollInterval: "5s"
file:
filename: "/etc/traefik/dynamic_config.yml"

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.9",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",

View File

@@ -1,118 +0,0 @@
import {
encodeBase32LowerCaseNoPadding,
encodeHexLowerCase,
} from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { Session, sessions, User, users } from "@server/db/schema";
import db from "@server/db";
import { eq } from "drizzle-orm";
import config from "@server/lib/config";
import type { RandomReader } from "@oslojs/crypto/random";
import { generateRandomString } from "@oslojs/crypto/random";
export const SESSION_COOKIE_NAME = config.getRawConfig().server.session_cookie_name;
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
export const COOKIE_DOMAIN = "." + config.getBaseDomain();
export function generateSessionToken(): string {
const bytes = new Uint8Array(20);
crypto.getRandomValues(bytes);
const token = encodeBase32LowerCaseNoPadding(bytes);
return token;
}
export async function createSession(
token: string,
userId: string,
): Promise<Session> {
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token)),
);
const session: Session = {
sessionId: sessionId,
userId,
expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(),
};
await db.insert(sessions).values(session);
return session;
}
export async function validateSessionToken(
token: string,
): Promise<SessionValidationResult> {
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token)),
);
const result = await db
.select({ user: users, session: sessions })
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.userId))
.where(eq(sessions.sessionId, sessionId));
if (result.length < 1) {
return { session: null, user: null };
}
const { user, session } = result[0];
if (Date.now() >= session.expiresAt) {
await db
.delete(sessions)
.where(eq(sessions.sessionId, session.sessionId));
return { session: null, user: null };
}
if (Date.now() >= session.expiresAt - SESSION_COOKIE_EXPIRES / 2) {
session.expiresAt = new Date(
Date.now() + SESSION_COOKIE_EXPIRES,
).getTime();
await db
.update(sessions)
.set({
expiresAt: session.expiresAt,
})
.where(eq(sessions.sessionId, session.sessionId));
}
return { session, user };
}
export async function invalidateSession(sessionId: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.sessionId, sessionId));
}
export async function invalidateAllSessions(userId: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.userId, userId));
}
export function serializeSessionCookie(token: string): string {
if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
}
}
export function createBlankSessionTokenCookie(): string {
if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
}
}
const random: RandomReader = {
read(bytes: Uint8Array): void {
crypto.getRandomValues(bytes);
},
};
export function generateId(length: number): string {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
return generateRandomString(random, alphabet, length);
}
export function generateIdFromEntropySize(size: number): string {
const buffer = crypto.getRandomValues(new Uint8Array(size));
return encodeBase32LowerCaseNoPadding(buffer);
}
export type SessionValidationResult =
| { session: Session; user: User }
| { session: null; user: null };

View File

@@ -4,7 +4,7 @@ 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])(?=.*?[#?!@$%^&*-]).*$/, {
.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

@@ -24,7 +24,6 @@ export const SESSION_COOKIE_EXPIRES =
60 *
60 *
config.getRawConfig().server.dashboard_session_length_hours;
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
export const COOKIE_DOMAIN =
"." + new URL(config.getRawConfig().app.dashboard_url).hostname;
@@ -108,12 +107,7 @@ export function serializeSessionCookie(
isSecure: boolean
): string {
if (isSecure) {
logger.debug("Setting cookie for secure origin");
if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${COOKIE_DOMAIN}`;
}
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`;
}
@@ -121,11 +115,7 @@ export function serializeSessionCookie(
export function createBlankSessionTokenCookie(isSecure: boolean): string {
if (isSecure) {
if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
}
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/;`;
}

View File

@@ -9,7 +9,6 @@ export const SESSION_COOKIE_NAME =
config.getRawConfig().server.session_cookie_name;
export const SESSION_COOKIE_EXPIRES =
1000 * 60 * 60 * config.getRawConfig().server.resource_session_length_hours;
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
export async function createResourceSession(opts: {
token: string;
@@ -170,7 +169,7 @@ export function serializeResourceSessionCookie(
token: string,
isHttp: boolean = false
): string {
if (SECURE_COOKIES && !isHttp) {
if (!isHttp) {
return `${cookieName}_s=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${"." + domain}`;
} else {
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${"." + domain}`;
@@ -179,9 +178,10 @@ export function serializeResourceSessionCookie(
export function createBlankResourceSessionTokenCookie(
cookieName: string,
domain: string
domain: string,
isHttp: boolean = false
): string {
if (SECURE_COOKIES) {
if (!isHttp) {
return `${cookieName}_s=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${"." + domain}`;
} else {
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${"." + domain}`;

View File

@@ -53,7 +53,8 @@ export const resources = sqliteTable("resources", {
proxyPort: integer("proxyPort"),
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
.notNull()
.default(false)
.default(false),
isBaseDomain: integer("isBaseDomain", { mode: "boolean" })
});
export const targets = sqliteTable("targets", {

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";
@@ -60,7 +60,6 @@ const configSchema = z.object({
.transform(stoi)
.pipe(portSchema),
internal_hostname: z.string().transform((url) => url.toLowerCase()),
secure_cookies: z.boolean(),
session_cookie_name: z.string(),
resource_access_token_param: z.string(),
resource_session_request_param: z.string(),
@@ -152,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()
});
@@ -240,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 =
@@ -256,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";
@@ -274,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.12";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);

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

@@ -12,8 +12,7 @@ import {
serializeResourceSessionCookie,
validateResourceSessionToken
} from "@server/auth/sessions/resource";
import { generateSessionToken } from "@server/auth";
import { SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/app";
import { generateSessionToken, SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/app";
import { SESSION_COOKIE_EXPIRES as RESOURCE_SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import { response } from "@server/lib";

View File

@@ -26,8 +26,8 @@ import {
import { Resource, roleResources, userResources } from "@server/db/schema";
import logger from "@server/logger";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import { generateSessionToken } from "@server/auth";
import NodeCache from "node-cache";
import { generateSessionToken } from "@server/auth/sessions/app";
// We'll see if this speeds anything up
const cache = new NodeCache({

View File

@@ -308,6 +308,13 @@ authenticated.get(
resource.getResourceWhitelist
);
authenticated.post(
`/resource/:resourceId/transfer`,
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.updateResource),
resource.transferResource
);
authenticated.post(
`/resource/:resourceId/access-token`,
verifyResourceAccess,

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,12 +1,11 @@
import { Router } from "express";
import * as gerbil from "@server/routers/gerbil";
import * as traefik from "@server/routers/traefik";
import * as resource from "./resource";
import * as badger from "./badger";
import * as auth from "@server/routers/auth";
import HttpCode from "@server/types/HttpCode";
import { verifyResourceAccess, verifySessionUserMiddleware } from "@server/middlewares";
import { getExchangeToken } from "./resource/getExchangeToken";
import { verifyResourceSession } from "./badger";
import { exchangeSession } from "./badger/exchangeSession";
// Root routes
const internalRouter = Router();
@@ -26,7 +25,7 @@ internalRouter.post(
`/resource/:resourceId/get-exchange-token`,
verifySessionUserMiddleware,
verifyResourceAccess,
getExchangeToken
resource.getExchangeToken
);
// Gerbil routes
@@ -40,7 +39,7 @@ gerbilRouter.post("/receive-bandwidth", gerbil.receiveBandwidth);
const badgerRouter = Router();
internalRouter.use("/badger", badgerRouter);
badgerRouter.post("/verify-session", verifyResourceSession);
badgerRouter.post("/exchange-session", exchangeSession);
badgerRouter.post("/verify-session", badger.verifyResourceSession);
badgerRouter.post("/exchange-session", badger.exchangeSession);
export default internalRouter;

View File

@@ -106,6 +106,7 @@ export const handleRegisterMessage: MessageHandler = async (context) => {
eq(targets.enabled, true)
)
)
.where(eq(resources.siteId, siteId))
.groupBy(resources.resourceId);
let tcpTargets: string[] = [];

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

@@ -18,6 +18,7 @@ import stoi from "@server/lib/stoi";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
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

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

@@ -16,4 +16,5 @@ export * from "./setResourceWhitelist";
export * from "./getResourceWhitelist";
export * from "./authWithWhitelist";
export * from "./authWithAccessToken";
export * from "./transferResource";
export * from "./getExchangeToken";

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";
const updateResourceParamsSchema = z
.object({
@@ -27,12 +28,48 @@ 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()
})
.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 +100,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 +118,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 +175,32 @@ export async function updateResource(
);
}
const fullDomain = updateData.subdomain
? `${updateData.subdomain}.${resource[0].orgs.domain}`
: undefined;
let fullDomain = "";
if (updateData.isBaseDomain) {
fullDomain = org.domain;
} else {
fullDomain = `${updateData.subdomain}.${org.domain}`;
}
const updatePayload = {
...updateData,
...(fullDomain && { fullDomain })
};
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 +216,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

@@ -11,7 +11,7 @@ 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";
import { pickPort } from "./helpers";
// Regular expressions for validation
const DOMAIN_REGEX =

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,7 +10,7 @@ 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";
import { pickPort } from "./helpers";
// Regular expressions for validation
const DOMAIN_REGEX =

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,19 +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) {
continue;
}
if (
targets.filter(
(target: Target) => target.internalPort != null
).length == 0
) {
if (!resource.subdomain && !resource.isBaseDomain) {
continue;
}
@@ -156,7 +149,10 @@ export async function traefikConfigProvider(
: {})
};
const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || [];
logger.debug(config.getRawConfig().traefik.prefer_wildcard_cert)
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
config_output.http.routers![routerName] = {
entryPoints: [
@@ -164,7 +160,10 @@ export async function traefikConfigProvider(
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: [badgerMiddlewareName, ...additionalMiddlewares],
middlewares: [
badgerMiddlewareName,
...additionalMiddlewares
],
service: serviceName,
rule: `Host(\`${fullDomain}\`)`,
...(resource.ssl ? { tls } : {})
@@ -184,9 +183,31 @@ export async function traefikConfigProvider(
config_output.http.services![serviceName] = {
loadBalancer: {
servers: targets
.filter(
(target: Target) => target.internalPort != null
)
.filter((target: Target) => {
if (!target.enabled) {
return false;
}
if (
site.type === "local" ||
site.type === "wireguard"
) {
if (
!target.ip ||
!target.port ||
!target.method
) {
return false;
}
} else if (site.type === "newt") {
if (
!target.internalPort ||
!target.method
) {
return false;
}
}
return true;
})
.map((target: Target) => {
if (
site.type === "local" ||
@@ -213,14 +234,6 @@ export async function traefikConfigProvider(
continue;
}
if (
targets.filter(
(target: Target) => target.internalPort != null
).length == 0
) {
continue;
}
if (!config_output[protocol]) {
config_output[protocol] = {
routers: {},
@@ -237,9 +250,24 @@ export async function traefikConfigProvider(
config_output[protocol].services[serviceName] = {
loadBalancer: {
servers: targets
.filter(
(target: Target) => target.internalPort != null
)
.filter((target: Target) => {
if (!target.enabled) {
return false;
}
if (
site.type === "local" ||
site.type === "wireguard"
) {
if (!target.ip || !target.port) {
return false;
}
} else if (site.type === "newt") {
if (!target.internalPort) {
return false;
}
}
return true;
})
.map((target: Target) => {
if (
site.type === "local" ||
@@ -261,9 +289,9 @@ export async function traefikConfigProvider(
}
return res.status(HttpCode.OK).json(config_output);
} catch (e) {
logger.error(`Failed to build traefik config: ${e}`);
logger.error(`Failed to build Traefik config: ${e}`);
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
error: "Failed to build traefik config"
error: "Failed to build Traefik config"
});
}
}

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,15 +3,17 @@ import db, { exists } from "@server/db";
import path from "path";
import semver from "semver";
import { versionMigrations } from "@server/db/schema";
import { desc } from "drizzle-orm";
import { __DIRNAME, APP_PATH } 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";
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";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -23,19 +25,46 @@ const migrations = [
{ version: "1.0.0-beta.3", run: m3 },
{ version: "1.0.0-beta.5", run: m4 },
{ version: "1.0.0-beta.6", run: m5 },
{ version: "1.0.0-beta.9", run: m6 }
{ version: "1.0.0-beta.9", run: m6 },
{ version: "1.0.0-beta.10", run: m7 },
{ version: "1.0.0-beta.12", run: m8 }
// 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();
@@ -51,38 +80,44 @@ export async function runMigrations() {
}
await db
.insert(versionMigrations)
.values({
version: appVersion,
executedAt: Date.now()
})
.execute();
.insert(versionMigrations)
.values({
version: appVersion,
executedAt: Date.now()
})
.execute();
}
} catch (e) {
console.error("Error running migrations:", e);
await new Promise((resolve) => setTimeout(resolve, 1000 * 60 * 60 * 24 * 1));
await new Promise((resolve) =>
setTimeout(resolve, 1000 * 60 * 60 * 24 * 1)
);
}
}
async function executeScripts() {
try {
// Get the last executed version from the database
const lastExecuted = await db
.select()
.from(versionMigrations)
.orderBy(desc(versionMigrations.version))
.limit(1);
const startVersion = lastExecuted[0]?.version ?? "0.0.0";
console.log(`Starting migrations from version ${startVersion}`);
const lastExecuted = await db.select().from(versionMigrations);
// Filter and sort migrations
const pendingMigrations = migrations
.filter((migration) => semver.gt(migration.version, startVersion))
.sort((a, b) => semver.compare(a.version, b.version));
const pendingMigrations = lastExecuted
.map((m) => m)
.sort((a, b) => semver.compare(b.version, a.version));
const startVersion = pendingMigrations[0]?.version ?? "0.0.0";
console.log(`Starting migrations from version ${startVersion}`);
const migrationsToRun = migrations.filter((migration) =>
semver.gt(migration.version, startVersion)
);
console.log(
"Migrations to run:",
migrationsToRun.map((m) => m.version).join(", ")
);
// Run migrations in order
for (const migration of pendingMigrations) {
for (const migration of migrationsToRun) {
console.log(`Running migration ${migration.version}`);
try {
@@ -100,12 +135,19 @@ async function executeScripts() {
console.log(
`Successfully completed migration ${migration.version}`
);
} catch (error) {
} catch (e) {
if (
e instanceof SqliteError &&
e.code === "SQLITE_CONSTRAINT_UNIQUE"
) {
console.error("Migration has already run! Skipping...");
continue;
}
console.error(
`Failed to run migration ${migration.version}:`,
error
e
);
throw error; // Re-throw to stop migration process
throw e; // Re-throw to stop migration process
}
}

View File

@@ -0,0 +1,45 @@
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs";
import yaml from "js-yaml";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.10...");
try {
// Determine which config file exists
const filePaths = [configFilePath1, configFilePath2];
let filePath = "";
for (const path of filePaths) {
if (fs.existsSync(path)) {
filePath = path;
break;
}
}
if (!filePath) {
throw new Error(
`No config file found (expected config.yml or config.yaml).`
);
}
// Read and parse the YAML file
let rawConfig: any;
const fileContents = fs.readFileSync(filePath, "utf8");
rawConfig = yaml.load(fileContents);
delete rawConfig.server.secure_cookies;
// Write the updated YAML back to the file
const updatedYaml = yaml.dump(rawConfig);
fs.writeFileSync(filePath, updatedYaml, "utf8");
console.log(`Removed deprecated config option: secure_cookies.`);
} catch (e) {
console.log(
`Was unable to remove deprecated config option: secure_cookies. Error: ${e}`
);
return;
}
console.log("Done.");
}

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

@@ -62,6 +62,9 @@ import {
import { subdomainSchema } from "@server/schemas/subdomainSchema";
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({
@@ -70,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) => {
@@ -91,7 +95,7 @@ const createResourceFormSchema = z
)
.refine(
(data) => {
if (data.http) {
if (data.http && !data.isBaseDomain) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
@@ -128,22 +132,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/`
@@ -159,8 +177,6 @@ export default function CreateResourceForm({
}, [open]);
async function onSubmit(data: CreateResourceFormValues) {
console.log(data);
const res = await api
.put<AxiosResponse<Resource>>(
`/org/${orgId}/site/${data.siteId}/resource/`,
@@ -170,7 +186,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) => {
@@ -186,11 +203,21 @@ export default function CreateResourceForm({
if (res && res.status === 201) {
const id = res.data.data.resourceId;
// navigate to the resource page
router.push(`/${orgId}/settings/resources/${id}`);
setResourceId(id);
if (data.http) {
goToResource(id);
} else {
setShowSnippets(true);
}
}
}
function goToResource(id?: number) {
// navigate to the resource page
router.push(`/${orgId}/settings/resources/${id || resourceId}`);
}
return (
<>
<Credenza
@@ -211,284 +238,416 @@ export default function CreateResourceForm({
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="create-resource-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Your name"
{...field}
/>
</FormControl>
<FormDescription>
This is the name that will be
displayed for this resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!env.flags.allowRawResources || (
{!showSnippets && (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="create-resource-form"
>
<FormField
control={form.control}
name="http"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
HTTP Resource
</FormLabel>
<FormDescription>
Toggle if this is an
HTTP resource or a raw
TCP/UDP resource
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={
field.onChange
}
/>
</FormControl>
</FormItem>
)}
/>
)}
{form.watch("http") && (
<FormField
control={form.control}
name="subdomain"
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Subdomain</FormLabel>
<FormLabel>Name</FormLabel>
<FormControl>
<CustomDomainInput
value={
field.value ?? ""
}
domainSuffix={
domainSuffix
}
placeholder="Enter subdomain"
onChange={(value) =>
form.setValue(
"subdomain",
value
)
}
<Input
placeholder="Resource name"
{...field}
/>
</FormControl>
<FormDescription>
This is the fully qualified
domain name that will be
used to access the resource.
This is the name that will
be displayed for this
resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{!form.watch("http") && (
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.fossorial.io/Getting%20Started/tcp-udp"
target="_blank"
rel="noopener noreferrer"
>
<span>
Learn how to configure TCP/UDP resources
</span>
<SquareArrowOutUpRight size={14} />
</Link>
)}
{!form.watch("http") && (
<>
{!env.flags.allowRawResources || (
<FormField
control={form.control}
name="protocol"
name="http"
render={({ field }) => (
<FormItem>
<FormLabel>
Protocol
</FormLabel>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a protocol" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">
TCP
</SelectItem>
<SelectItem value="udp">
UDP
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
The protocol to use for
the resource
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
Port Number
</FormLabel>
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
HTTP Resource
</FormLabel>
<FormDescription>
Toggle if this is an
HTTP resource or a
raw TCP/UDP resource
</FormDescription>
</div>
<FormControl>
<Input
type="number"
placeholder="Enter port number"
value={
field.value ??
""
<Switch
checked={
field.value
}
onChange={(e) =>
field.onChange(
e.target
.value
? parseInt(
e
.target
.value
)
: null
)
onCheckedChange={
field.onChange
}
/>
</FormControl>
</FormItem>
)}
/>
)}
{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>
{!env.flags
.allowBaseDomainResources && (
<FormLabel>
Subdomain
</FormLabel>
)}
{domainType ===
"subdomain" ? (
<FormControl>
<CustomDomainInput
value={
field.value ??
""
}
domainSuffix={
domainSuffix
}
placeholder="Subdomain"
onChange={(
value
) =>
form.setValue(
"subdomain",
value
)
}
/>
</FormControl>
) : (
<FormControl>
<Input
value={
domainSuffix
}
readOnly
disabled
/>
</FormControl>
)}
<FormDescription>
The port number to proxy
requests to (required
for non-HTTP resources)
This is the fully
qualified domain name
that will be used to
access the resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Site</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"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="p-0">
<Command>
<CommandInput placeholder="Search site..." />
<CommandList>
<CommandEmpty>
No site found.
</CommandEmpty>
<CommandGroup>
{sites.map(
(site) => (
<CommandItem
value={
site.niceId
}
key={
site.siteId
}
onSelect={() => {
form.setValue(
"siteId",
site.siteId
);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
{
site.name
}
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
This is the site that will be
used in the dashboard.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
{!form.watch("http") && (
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.fossorial.io/Getting%20Started/tcp-udp"
target="_blank"
rel="noopener noreferrer"
>
<span>
Learn how to configure TCP/UDP
resources
</span>
<SquareArrowOutUpRight size={14} />
</Link>
)}
{!form.watch("http") && (
<>
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>
Protocol
</FormLabel>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a protocol" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">
TCP
</SelectItem>
<SelectItem value="udp">
UDP
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
The protocol to use
for the resource
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
Port Number
</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Enter port number"
value={
field.value ??
""
}
onChange={(e) =>
field.onChange(
e.target
.value
? parseInt(
e
.target
.value
)
: null
)
}
/>
</FormControl>
<FormDescription>
The port number to
proxy requests to
(required for
non-HTTP resources)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Site</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"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="p-0">
<Command>
<CommandInput placeholder="Search site..." />
<CommandList>
<CommandEmpty>
No site
found.
</CommandEmpty>
<CommandGroup>
{sites.map(
(
site
) => (
<CommandItem
value={`${site.siteId}:${site.name}:${site.niceId}`}
key={
site.siteId
}
onSelect={() => {
form.setValue(
"siteId",
site.siteId
);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
{
site.name
}
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
This is the site that will
be used in the dashboard.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
{showSnippets && (
<div>
<div className="flex items-start space-x-4 mb-6 last:mb-0">
<div className="flex-shrink-0 w-8 h-8 bg-muted text-primary-foreground rounded-full flex items-center justify-center font-bold">
1
</div>
<div className="flex-grow">
<h3 className="text-lg font-semibold mb-3">
Traefik: Add Entrypoints
</h3>
<CopyTextBox
text={`entryPoints:
${form.getValues("protocol")}-${form.getValues("proxyPort")}:
address: ":${form.getValues("proxyPort")}/${form.getValues("protocol")}"`}
wrapText={false}
/>
</div>
</div>
<div className="flex items-start space-x-4 mb-6 last:mb-0">
<div className="flex-shrink-0 w-8 h-8 bg-muted text-primary-foreground rounded-full flex items-center justify-center font-bold">
2
</div>
<div className="flex-grow">
<h3 className="text-lg font-semibold mb-3">
Gerbil: Expose Ports in Docker
Compose
</h3>
<CopyTextBox
text={`ports:
- ${form.getValues("proxyPort")}:${form.getValues("proxyPort")}${form.getValues("protocol") === "tcp" ? "" : "/" + form.getValues("protocol")}`}
wrapText={false}
/>
</div>
</div>
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.fossorial.io/Getting%20Started/tcp-udp"
target="_blank"
rel="noopener noreferrer"
>
<span>
Make sure to follow the full guide
</span>
<SquareArrowOutUpRight size={14} />
</Link>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<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>
)}
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>

View File

@@ -38,7 +38,7 @@ export type ResourceRow = {
domain: string;
site: string;
siteId: string;
hasAuth: boolean;
authState: string;
http: boolean;
protocol: string;
proxyPort: number | null;
@@ -165,9 +165,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 +175,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 +209,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

@@ -37,7 +37,7 @@ export default function CustomDomainInput({
className="rounded-r-none flex-grow"
/>
<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>
<span className="text-sm">.{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

@@ -130,11 +130,10 @@ export default function ReverseProxyTargets(props: {
const addTargetForm = useForm({
resolver: zodResolver(addTargetSchema),
defaultValues: {
ip: "localhost",
ip: "",
method: resource.http ? "http" : null,
port: resource.http ? 80 : resource.proxyPort || 1234
// protocol: "TCP",
}
} as z.infer<typeof addTargetSchema>
});
useEffect(() => {

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";
@@ -37,13 +50,18 @@ import CustomDomainInput from "../CustomDomainInput";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { pullEnv } from "@app/lib/pullEnv";
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 +82,7 @@ const GeneralFormSchema = z
)
.refine(
(data) => {
if (data.http) {
if (data.http && !data.isBaseDomain) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
@@ -75,7 +93,12 @@ 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();
@@ -84,13 +107,21 @@ export default function GeneralForm() {
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 +129,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 +155,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 +171,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 +255,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 +406,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

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

@@ -320,7 +320,7 @@ export default function CreateShareLinkForm({
) => (
<CommandItem
value={
r.resourceId.toString()
`${r.name}:${r.resourceId}`
}
key={
r.resourceId

View File

@@ -75,6 +75,7 @@ export default function CreateSiteForm({
const { toast } = useToast();
const api = createApiClient(useEnvContext());
const { env } = useEnvContext();
const [isLoading, setIsLoading] = useState(false);
const [isChecked, setIsChecked] = useState(false);
@@ -234,13 +235,7 @@ 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 ${proto}//${siteDefaults?.endpoint}`;
const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`;
return (
<div className="space-y-4">

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