Merge branch 'main' into holepunch
|
@ -22,7 +22,6 @@ next-env.d.ts
|
||||||
*.log
|
*.log
|
||||||
.machinelogs*.json
|
.machinelogs*.json
|
||||||
*-audit.json
|
*-audit.json
|
||||||
package-lock.json
|
|
||||||
install/
|
install/
|
||||||
bruno/
|
bruno/
|
||||||
LICENSE
|
LICENSE
|
||||||
|
|
2
.github/workflows/cicd.yml
vendored
|
@ -64,7 +64,7 @@ jobs:
|
||||||
- name: Build installer
|
- name: Build installer
|
||||||
working-directory: install
|
working-directory: install
|
||||||
run: |
|
run: |
|
||||||
make release
|
make go-build-release
|
||||||
|
|
||||||
- name: Upload artifacts from /install/bin
|
- name: Upload artifacts from /install/bin
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|
1
.gitignore
vendored
|
@ -23,7 +23,6 @@ next-env.d.ts
|
||||||
.machinelogs*.json
|
.machinelogs*.json
|
||||||
*-audit.json
|
*-audit.json
|
||||||
migrations
|
migrations
|
||||||
package-lock.json
|
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
config/config.yml
|
config/config.yml
|
||||||
dist
|
dist
|
||||||
|
|
19
Dockerfile
|
@ -2,9 +2,8 @@ FROM node:20-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json ./
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
@ -14,21 +13,19 @@ RUN npm run build
|
||||||
|
|
||||||
FROM node:20-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
|
|
||||||
RUN apk add --no-cache curl
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json ./
|
# Curl used for the health checks
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
RUN npm install --omit=dev
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --only=production && npm cache clean --force
|
||||||
|
|
||||||
COPY --from=builder /app/.next ./.next
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/init ./dist/init
|
COPY --from=builder /app/init ./dist/init
|
||||||
|
|
||||||
COPY config/config.example.yml ./dist/config.example.yml
|
|
||||||
COPY config/traefik/traefik_config.example.yml ./dist/traefik_config.example.yml
|
|
||||||
COPY config/traefik/dynamic_config.example.yml ./dist/dynamic_config.example.yml
|
|
||||||
COPY server/db/names.json ./dist/names.json
|
COPY server/db/names.json ./dist/names.json
|
||||||
|
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
|
71
README.md
|
@ -9,6 +9,13 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 align="center">Tunneled Mesh Reverse Proxy Server with Access Control</h3>
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
_Your own self-hosted zero trust tunnel._
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<h5>
|
<h5>
|
||||||
<a href="https://docs.fossorial.io/Getting%20Started/quick-install">
|
<a href="https://docs.fossorial.io/Getting%20Started/quick-install">
|
||||||
|
@ -18,16 +25,13 @@
|
||||||
<a href="https://docs.fossorial.io">
|
<a href="https://docs.fossorial.io">
|
||||||
Full Documentation
|
Full Documentation
|
||||||
</a>
|
</a>
|
||||||
|
<span> | </span>
|
||||||
|
<a href="mailto:numbat@fossorial.io">
|
||||||
|
Contact Us
|
||||||
|
</a>
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 align="center">Tunneled Mesh Reverse Proxy Server with Access Control</h3>
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
_Your own self-hosted zero trust tunnel._
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
Pangolin is a self-hosted tunneled reverse proxy server with identity and access control, designed to securely expose private resources on distributed networks. Acting as a central hub, it connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports.
|
Pangolin is a self-hosted tunneled reverse proxy server with identity and access control, designed to securely expose private resources on distributed networks. Acting as a central hub, it connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports.
|
||||||
|
|
||||||
<img src="public/screenshots/sites.png" alt="Preview"/>
|
<img src="public/screenshots/sites.png" alt="Preview"/>
|
||||||
|
@ -68,47 +72,27 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
||||||
### Easy Deployment
|
### Easy Deployment
|
||||||
|
|
||||||
- Run on any cloud provider or on-premises.
|
- Run on any cloud provider or on-premises.
|
||||||
- Docker Compose based setup for simplified deployment.
|
- **Docker Compose based setup** for simplified deployment.
|
||||||
- Future-proof installation script for streamlined setup and feature additions.
|
- Future-proof installation script for streamlined setup and feature additions.
|
||||||
- Use your preferred WireGuard client to connect, or use Newt, our custom user space client for the best experience.
|
- Use any WireGuard client to connect, or use **Newt, our custom user space client** for the best experience.
|
||||||
|
|
||||||
### Modular Design
|
### Modular Design
|
||||||
|
|
||||||
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [Fail2Ban](https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban) or [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin).
|
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin) and [Geoblock](github.com/PascalMinder/geoblock).
|
||||||
|
- **Automatically install and configure Crowdsec via Pangolin's installer script.**
|
||||||
- Attach as many sites to the central server as you wish.
|
- Attach as many sites to the central server as you wish.
|
||||||
|
|
||||||
## Screenshots
|
<img src="public/screenshots/collage.png" alt="Collage"/>
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td align="center"><img src="public/screenshots/sites.png" alt="Sites Example" width="200"/></td>
|
|
||||||
<td align="center"><img src="public/screenshots/users.png" alt="Users Example" width="200"/></td>
|
|
||||||
<td align="center"><img src="public/screenshots/share-link.png" alt="Share Link Example" width="200"/></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center"><b>Sites</b></td>
|
|
||||||
<td align="center"><b>Users</b></td>
|
|
||||||
<td align="center"><b>Share Link</b></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center"><img src="public/screenshots/auth.png" alt="Authentication Example" width="200"/></td>
|
|
||||||
<td align="center"><img src="public/screenshots/connectivity.png" alt="Connectivity Example" width="200"/></td>
|
|
||||||
<td align="center"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center"><b>Authentication</b></td>
|
|
||||||
<td align="center"><b>Connectivity</b></td>
|
|
||||||
<td align="center"><b></b></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## Deployment and Usage Example
|
## Deployment and Usage Example
|
||||||
|
|
||||||
1. **Deploy the Central Server**:
|
1. **Deploy the Central Server**:
|
||||||
|
|
||||||
- Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
|
- Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like RackNerd, Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Many of our users have had a great experience with [RackNerd](https://my.racknerd.com/aff.php?aff=13788). Depending on promotions, you can likely get a **VPS with 1 vCPU, 1GB RAM, and ~20GB SSD for just around $12/year**. That's a great deal!
|
||||||
|
> We are part of the [RackNerd](https://my.racknerd.com/aff.php?aff=13788) affiliate program, so if you purchase through [our link](https://my.racknerd.com/aff.php?aff=13788), we receive a small commission which helps us maintain the project and keep it free for everyone.
|
||||||
|
|
||||||
2. **Domain Configuration**:
|
2. **Domain Configuration**:
|
||||||
|
|
||||||
|
@ -119,10 +103,10 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
||||||
- Install Newt or use another WireGuard client on private sites.
|
- Install Newt or use another WireGuard client on private sites.
|
||||||
- Automatically establish a connection from these sites to the central server.
|
- Automatically establish a connection from these sites to the central server.
|
||||||
|
|
||||||
4. **Configure Users & Roles**
|
4. **Expose Resources**:
|
||||||
|
|
||||||
- Define organizations and invite users.
|
- Add resources to the central server and configure access control rules.
|
||||||
- Implement user- or role-based permissions to control resource access.
|
- Access these resources securely from anywhere.
|
||||||
|
|
||||||
**Use Case Example - Bypassing Port Restrictions in Home Lab**:
|
**Use Case Example - Bypassing Port Restrictions in Home Lab**:
|
||||||
Imagine private sites where the ISP restricts port forwarding. By connecting these sites to Pangolin via WireGuard, you can securely expose HTTP and HTTPS resources on the private network without any networking complexity.
|
Imagine private sites where the ISP restricts port forwarding. By connecting these sites to Pangolin via WireGuard, you can securely expose HTTP and HTTPS resources on the private network without any networking complexity.
|
||||||
|
@ -130,6 +114,11 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
||||||
**Use Case Example - IoT Networks**:
|
**Use Case Example - IoT Networks**:
|
||||||
IoT networks are often fragmented and difficult to manage. By deploying Pangolin on a central server, you can connect all your IoT sites via Newt or another WireGuard client. This creates a simple, secure, and centralized way to access IoT resources without the need for intricate networking setups.
|
IoT networks are often fragmented and difficult to manage. By deploying Pangolin on a central server, you can connect all your IoT sites via Newt or another WireGuard client. This creates a simple, secure, and centralized way to access IoT resources without the need for intricate networking setups.
|
||||||
|
|
||||||
|
|
||||||
|
<img src="public/screenshots/resources.png" alt="Resources"/>
|
||||||
|
|
||||||
|
_Resources page of Pangolin dashboard (dark mode) showing HTTPS and TCP resources with access control rules._
|
||||||
|
|
||||||
## Similar Projects and Inspirations
|
## Similar Projects and Inspirations
|
||||||
|
|
||||||
**Cloudflare Tunnels**:
|
**Cloudflare Tunnels**:
|
||||||
|
@ -147,7 +136,7 @@ View the [project board](https://github.com/orgs/fosrl/projects/1) for more deta
|
||||||
|
|
||||||
## Licensing
|
## Licensing
|
||||||
|
|
||||||
Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us.
|
Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io).
|
||||||
|
|
||||||
## Contributions
|
## Contributions
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,16 @@
|
||||||
|
# To see all available options, please visit the docs:
|
||||||
|
# https://docs.fossorial.io/Pangolin/Configuration/config
|
||||||
|
|
||||||
app:
|
app:
|
||||||
dashboard_url: "http://localhost:3002"
|
dashboard_url: "http://localhost:3002"
|
||||||
base_domain: "localhost"
|
|
||||||
log_level: "info"
|
log_level: "info"
|
||||||
save_logs: false
|
save_logs: false
|
||||||
|
|
||||||
|
domains:
|
||||||
|
domain1:
|
||||||
|
base_domain: "example.com"
|
||||||
|
cert_resolver: "letsencrypt"
|
||||||
|
|
||||||
server:
|
server:
|
||||||
external_port: 3000
|
external_port: 3000
|
||||||
internal_port: 3001
|
internal_port: 3001
|
||||||
|
@ -14,7 +21,6 @@ server:
|
||||||
resource_session_request_param: "p_session_request"
|
resource_session_request_param: "p_session_request"
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
cert_resolver: "letsencrypt"
|
|
||||||
http_entrypoint: "web"
|
http_entrypoint: "web"
|
||||||
https_entrypoint: "websecure"
|
https_entrypoint: "websecure"
|
||||||
|
|
||||||
|
|
|
@ -1,53 +0,0 @@
|
||||||
http:
|
|
||||||
middlewares:
|
|
||||||
redirect-to-https:
|
|
||||||
redirectScheme:
|
|
||||||
scheme: https
|
|
||||||
|
|
||||||
routers:
|
|
||||||
# HTTP to HTTPS redirect router
|
|
||||||
main-app-router-redirect:
|
|
||||||
rule: "Host(`{{.DashboardDomain}}`)"
|
|
||||||
service: next-service
|
|
||||||
entryPoints:
|
|
||||||
- web
|
|
||||||
middlewares:
|
|
||||||
- redirect-to-https
|
|
||||||
|
|
||||||
# Next.js router (handles everything except API and WebSocket paths)
|
|
||||||
next-router:
|
|
||||||
rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)"
|
|
||||||
service: next-service
|
|
||||||
entryPoints:
|
|
||||||
- websecure
|
|
||||||
tls:
|
|
||||||
certResolver: letsencrypt
|
|
||||||
|
|
||||||
# API router (handles /api/v1 paths)
|
|
||||||
api-router:
|
|
||||||
rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)"
|
|
||||||
service: api-service
|
|
||||||
entryPoints:
|
|
||||||
- websecure
|
|
||||||
tls:
|
|
||||||
certResolver: letsencrypt
|
|
||||||
|
|
||||||
# WebSocket router
|
|
||||||
ws-router:
|
|
||||||
rule: "Host(`{{.DashboardDomain}}`)"
|
|
||||||
service: api-service
|
|
||||||
entryPoints:
|
|
||||||
- websecure
|
|
||||||
tls:
|
|
||||||
certResolver: letsencrypt
|
|
||||||
|
|
||||||
services:
|
|
||||||
next-service:
|
|
||||||
loadBalancer:
|
|
||||||
servers:
|
|
||||||
- url: "http://pangolin:{{.NEXT_PORT}}" # Next.js server
|
|
||||||
|
|
||||||
api-service:
|
|
||||||
loadBalancer:
|
|
||||||
servers:
|
|
||||||
- url: "http://pangolin:{{.EXTERNAL_PORT}}" # API/WebSocket server
|
|
|
@ -1,44 +0,0 @@
|
||||||
api:
|
|
||||||
insecure: true
|
|
||||||
dashboard: true
|
|
||||||
|
|
||||||
providers:
|
|
||||||
http:
|
|
||||||
endpoint: "http://pangolin:{{.INTERNAL_PORT}}/api/v1/traefik-config"
|
|
||||||
pollInterval: "5s"
|
|
||||||
file:
|
|
||||||
filename: "/etc/traefik/dynamic_config.yml"
|
|
||||||
|
|
||||||
experimental:
|
|
||||||
plugins:
|
|
||||||
badger:
|
|
||||||
moduleName: "github.com/fosrl/badger"
|
|
||||||
version: "v1.0.0-beta.3"
|
|
||||||
|
|
||||||
log:
|
|
||||||
level: "INFO"
|
|
||||||
format: "common"
|
|
||||||
|
|
||||||
certificatesResolvers:
|
|
||||||
letsencrypt:
|
|
||||||
acme:
|
|
||||||
httpChallenge:
|
|
||||||
entryPoint: web
|
|
||||||
email: "{{.LetsEncryptEmail}}"
|
|
||||||
storage: "/letsencrypt/acme.json"
|
|
||||||
caServer: "https://acme-v02.api.letsencrypt.org/directory"
|
|
||||||
|
|
||||||
entryPoints:
|
|
||||||
web:
|
|
||||||
address: ":80"
|
|
||||||
websecure:
|
|
||||||
address: ":443"
|
|
||||||
transport:
|
|
||||||
respondingTimeouts:
|
|
||||||
readTimeout: "30m"
|
|
||||||
http:
|
|
||||||
tls:
|
|
||||||
certResolver: "letsencrypt"
|
|
||||||
|
|
||||||
serversTransport:
|
|
||||||
insecureSkipVerify: true
|
|
|
@ -1,5 +1,4 @@
|
||||||
version: "3.7"
|
name: pangolin
|
||||||
|
|
||||||
services:
|
services:
|
||||||
pangolin:
|
pangolin:
|
||||||
image: fosrl/pangolin:latest
|
image: fosrl/pangolin:latest
|
||||||
|
@ -32,7 +31,6 @@ services:
|
||||||
- SYS_MODULE
|
- SYS_MODULE
|
||||||
ports:
|
ports:
|
||||||
- 51820:51820/udp
|
- 51820:51820/udp
|
||||||
- 8080:8080 # Port for traefik because of the network_mode
|
|
||||||
- 443:443 # Port for traefik because of the network_mode
|
- 443:443 # Port for traefik because of the network_mode
|
||||||
- 80:80 # Port for traefik because of the network_mode
|
- 80:80 # Port for traefik because of the network_mode
|
||||||
|
|
||||||
|
@ -47,8 +45,8 @@ services:
|
||||||
command:
|
command:
|
||||||
- --configFile=/etc/traefik/traefik_config.yml
|
- --configFile=/etc/traefik/traefik_config.yml
|
||||||
volumes:
|
volumes:
|
||||||
- ./traefik:/etc/traefik:ro # Volume to store the Traefik configuration
|
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
|
||||||
- ./letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
|
|
|
@ -1,13 +1,24 @@
|
||||||
all: build
|
all: update-versions go-build-release put-back
|
||||||
|
|
||||||
build:
|
go-build-release:
|
||||||
CGO_ENABLED=0 go build -o bin/installer
|
|
||||||
|
|
||||||
release:
|
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f bin/installer
|
|
||||||
rm -f bin/installer_linux_amd64
|
rm -f bin/installer_linux_amd64
|
||||||
rm -f bin/installer_linux_arm64
|
rm -f bin/installer_linux_arm64
|
||||||
|
|
||||||
|
update-versions:
|
||||||
|
@echo "Fetching latest versions..."
|
||||||
|
cp main.go main.go.bak && \
|
||||||
|
PANGOLIN_VERSION=$$(curl -s https://api.github.com/repos/fosrl/pangolin/tags | jq -r '.[0].name') && \
|
||||||
|
GERBIL_VERSION=$$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') && \
|
||||||
|
BADGER_VERSION=$$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') && \
|
||||||
|
echo "Latest versions - Pangolin: $$PANGOLIN_VERSION, Gerbil: $$GERBIL_VERSION, Badger: $$BADGER_VERSION" && \
|
||||||
|
sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$$PANGOLIN_VERSION\"/" main.go && \
|
||||||
|
sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$$GERBIL_VERSION\"/" main.go && \
|
||||||
|
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$$BADGER_VERSION\"/" main.go && \
|
||||||
|
echo "Updated main.go with latest versions"
|
||||||
|
|
||||||
|
put-back:
|
||||||
|
mv main.go.bak main.go
|
353
install/config.go
Normal file
|
@ -0,0 +1,353 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TraefikConfig represents the structure of the main Traefik configuration
|
||||||
|
type TraefikConfig struct {
|
||||||
|
Experimental struct {
|
||||||
|
Plugins struct {
|
||||||
|
Badger struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
} `yaml:"badger"`
|
||||||
|
} `yaml:"plugins"`
|
||||||
|
} `yaml:"experimental"`
|
||||||
|
CertificatesResolvers struct {
|
||||||
|
LetsEncrypt struct {
|
||||||
|
Acme struct {
|
||||||
|
Email string `yaml:"email"`
|
||||||
|
} `yaml:"acme"`
|
||||||
|
} `yaml:"letsencrypt"`
|
||||||
|
} `yaml:"certificatesResolvers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DynamicConfig represents the structure of the dynamic configuration
|
||||||
|
type DynamicConfig struct {
|
||||||
|
HTTP struct {
|
||||||
|
Routers map[string]struct {
|
||||||
|
Rule string `yaml:"rule"`
|
||||||
|
} `yaml:"routers"`
|
||||||
|
} `yaml:"http"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigValues holds the extracted configuration values
|
||||||
|
type ConfigValues struct {
|
||||||
|
DashboardDomain string
|
||||||
|
LetsEncryptEmail string
|
||||||
|
BadgerVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadTraefikConfig reads and extracts values from Traefik configuration files
|
||||||
|
func ReadTraefikConfig(mainConfigPath, dynamicConfigPath string) (*ConfigValues, error) {
|
||||||
|
// Read main config file
|
||||||
|
mainConfigData, err := os.ReadFile(mainConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error reading main config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mainConfig TraefikConfig
|
||||||
|
if err := yaml.Unmarshal(mainConfigData, &mainConfig); err != nil {
|
||||||
|
return nil, fmt.Errorf("error parsing main config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read dynamic config file
|
||||||
|
dynamicConfigData, err := os.ReadFile(dynamicConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error reading dynamic config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dynamicConfig DynamicConfig
|
||||||
|
if err := yaml.Unmarshal(dynamicConfigData, &dynamicConfig); err != nil {
|
||||||
|
return nil, fmt.Errorf("error parsing dynamic config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract values
|
||||||
|
values := &ConfigValues{
|
||||||
|
BadgerVersion: mainConfig.Experimental.Plugins.Badger.Version,
|
||||||
|
LetsEncryptEmail: mainConfig.CertificatesResolvers.LetsEncrypt.Acme.Email,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract DashboardDomain from router rules
|
||||||
|
// Look for it in the main router rules
|
||||||
|
for _, router := range dynamicConfig.HTTP.Routers {
|
||||||
|
if router.Rule != "" {
|
||||||
|
// Extract domain from Host(`mydomain.com`)
|
||||||
|
if domain := extractDomainFromRule(router.Rule); domain != "" {
|
||||||
|
values.DashboardDomain = domain
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDomainFromRule extracts the domain from a router rule
|
||||||
|
func extractDomainFromRule(rule string) string {
|
||||||
|
// Look for the Host(`mydomain.com`) pattern
|
||||||
|
if start := findPattern(rule, "Host(`"); start != -1 {
|
||||||
|
end := findPattern(rule[start:], "`)")
|
||||||
|
if end != -1 {
|
||||||
|
return rule[start+6 : start+end]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// findPattern finds the start of a pattern in a string
|
||||||
|
func findPattern(s, pattern string) int {
|
||||||
|
return bytes.Index([]byte(s), []byte(pattern))
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyDockerService(sourceFile, destFile, serviceName string) error {
|
||||||
|
// Read source file
|
||||||
|
sourceData, err := os.ReadFile(sourceFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading source file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read destination file
|
||||||
|
destData, err := os.ReadFile(destFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading destination file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse source Docker Compose YAML
|
||||||
|
var sourceCompose map[string]interface{}
|
||||||
|
if err := yaml.Unmarshal(sourceData, &sourceCompose); err != nil {
|
||||||
|
return fmt.Errorf("error parsing source Docker Compose file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse destination Docker Compose YAML
|
||||||
|
var destCompose map[string]interface{}
|
||||||
|
if err := yaml.Unmarshal(destData, &destCompose); err != nil {
|
||||||
|
return fmt.Errorf("error parsing destination Docker Compose file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get services section from source
|
||||||
|
sourceServices, ok := sourceCompose["services"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("services section not found in source file or has invalid format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the specific service configuration
|
||||||
|
serviceConfig, ok := sourceServices[serviceName]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("service '%s' not found in source file", serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create services section in destination
|
||||||
|
destServices, ok := destCompose["services"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
// If services section doesn't exist, create it
|
||||||
|
destServices = make(map[string]interface{})
|
||||||
|
destCompose["services"] = destServices
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update service in destination
|
||||||
|
destServices[serviceName] = serviceConfig
|
||||||
|
|
||||||
|
// Marshal updated destination YAML
|
||||||
|
// Use yaml.v3 encoder to preserve formatting and comments
|
||||||
|
// updatedData, err := yaml.Marshal(destCompose)
|
||||||
|
updatedData, err := MarshalYAMLWithIndent(destCompose, 2)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error marshaling updated Docker Compose file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write updated YAML back to destination file
|
||||||
|
if err := os.WriteFile(destFile, updatedData, 0644); err != nil {
|
||||||
|
return fmt.Errorf("error writing to destination file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupConfig() error {
|
||||||
|
// Backup docker-compose.yml
|
||||||
|
if _, err := os.Stat("docker-compose.yml"); err == nil {
|
||||||
|
if err := copyFile("docker-compose.yml", "docker-compose.yml.backup"); err != nil {
|
||||||
|
return fmt.Errorf("failed to backup docker-compose.yml: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup config directory
|
||||||
|
if _, err := os.Stat("config"); err == nil {
|
||||||
|
cmd := exec.Command("tar", "-czvf", "config.tar.gz", "config")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("failed to backup config directory: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarshalYAMLWithIndent(data interface{}, indent int) ([]byte, error) {
|
||||||
|
buffer := new(bytes.Buffer)
|
||||||
|
encoder := yaml.NewEncoder(buffer)
|
||||||
|
encoder.SetIndent(indent)
|
||||||
|
|
||||||
|
err := encoder.Encode(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer encoder.Close()
|
||||||
|
return buffer.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceInFile(filepath, oldStr, newStr string) error {
|
||||||
|
// Read the file content
|
||||||
|
content, err := os.ReadFile(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the string
|
||||||
|
newContent := strings.Replace(string(content), oldStr, newStr, -1)
|
||||||
|
|
||||||
|
// Write the modified content back to the file
|
||||||
|
err = os.WriteFile(filepath, []byte(newContent), 0644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error writing file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckAndAddTraefikLogVolume(composePath string) error {
|
||||||
|
// Read the docker-compose.yml file
|
||||||
|
data, err := os.ReadFile(composePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading compose file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse YAML into a generic map
|
||||||
|
var compose map[string]interface{}
|
||||||
|
if err := yaml.Unmarshal(data, &compose); err != nil {
|
||||||
|
return fmt.Errorf("error parsing compose file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get services section
|
||||||
|
services, ok := compose["services"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("services section not found or invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get traefik service
|
||||||
|
traefik, ok := services["traefik"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("traefik service not found or invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check volumes
|
||||||
|
logVolume := "./config/traefik/logs:/var/log/traefik"
|
||||||
|
var volumes []interface{}
|
||||||
|
|
||||||
|
if existingVolumes, ok := traefik["volumes"].([]interface{}); ok {
|
||||||
|
// Check if volume already exists
|
||||||
|
for _, v := range existingVolumes {
|
||||||
|
if v.(string) == logVolume {
|
||||||
|
fmt.Println("Traefik log volume is already configured")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
volumes = existingVolumes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new volume
|
||||||
|
volumes = append(volumes, logVolume)
|
||||||
|
traefik["volumes"] = volumes
|
||||||
|
|
||||||
|
// Write updated config back to file
|
||||||
|
newData, err := MarshalYAMLWithIndent(compose, 2)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error marshaling updated compose file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(composePath, newData, 0644); err != nil {
|
||||||
|
return fmt.Errorf("error writing updated compose file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Added traefik log volume and created logs directory")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeYAML merges two YAML files, where the contents of the second file
|
||||||
|
// are merged into the first file. In case of conflicts, values from the
|
||||||
|
// second file take precedence.
|
||||||
|
func MergeYAML(baseFile, overlayFile string) error {
|
||||||
|
// Read the base YAML file
|
||||||
|
baseContent, err := os.ReadFile(baseFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading base file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the overlay YAML file
|
||||||
|
overlayContent, err := os.ReadFile(overlayFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading overlay file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse base YAML into a map
|
||||||
|
var baseMap map[string]interface{}
|
||||||
|
if err := yaml.Unmarshal(baseContent, &baseMap); err != nil {
|
||||||
|
return fmt.Errorf("error parsing base YAML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse overlay YAML into a map
|
||||||
|
var overlayMap map[string]interface{}
|
||||||
|
if err := yaml.Unmarshal(overlayContent, &overlayMap); err != nil {
|
||||||
|
return fmt.Errorf("error parsing overlay YAML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge the overlay into the base
|
||||||
|
merged := mergeMap(baseMap, overlayMap)
|
||||||
|
|
||||||
|
// Marshal the merged result back to YAML
|
||||||
|
mergedContent, err := MarshalYAMLWithIndent(merged, 2)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error marshaling merged YAML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the merged content back to the base file
|
||||||
|
if err := os.WriteFile(baseFile, mergedContent, 0644); err != nil {
|
||||||
|
return fmt.Errorf("error writing merged YAML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeMap recursively merges two maps
|
||||||
|
func mergeMap(base, overlay map[string]interface{}) map[string]interface{} {
|
||||||
|
result := make(map[string]interface{})
|
||||||
|
|
||||||
|
// Copy all key-values from base map
|
||||||
|
for k, v := range base {
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge overlay values
|
||||||
|
for k, v := range overlay {
|
||||||
|
// If both maps have the same key and both values are maps, merge recursively
|
||||||
|
if baseVal, ok := base[k]; ok {
|
||||||
|
if baseMap, isBaseMap := baseVal.(map[string]interface{}); isBaseMap {
|
||||||
|
if overlayMap, isOverlayMap := v.(map[string]interface{}); isOverlayMap {
|
||||||
|
result[k] = mergeMap(baseMap, overlayMap)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise, overlay value takes precedence
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
|
@ -1,9 +1,16 @@
|
||||||
|
# To see all available options, please visit the docs:
|
||||||
|
# https://docs.fossorial.io/Pangolin/Configuration/config
|
||||||
|
|
||||||
app:
|
app:
|
||||||
dashboard_url: "https://{{.DashboardDomain}}"
|
dashboard_url: "https://{{.DashboardDomain}}"
|
||||||
base_domain: "{{.BaseDomain}}"
|
|
||||||
log_level: "info"
|
log_level: "info"
|
||||||
save_logs: false
|
save_logs: false
|
||||||
|
|
||||||
|
domains:
|
||||||
|
domain1:
|
||||||
|
base_domain: "{{.BaseDomain}}"
|
||||||
|
cert_resolver: "letsencrypt"
|
||||||
|
|
||||||
server:
|
server:
|
||||||
external_port: 3000
|
external_port: 3000
|
||||||
internal_port: 3001
|
internal_port: 3001
|
||||||
|
@ -22,7 +29,6 @@ traefik:
|
||||||
cert_resolver: "letsencrypt"
|
cert_resolver: "letsencrypt"
|
||||||
http_entrypoint: "web"
|
http_entrypoint: "web"
|
||||||
https_entrypoint: "websecure"
|
https_entrypoint: "websecure"
|
||||||
prefer_wildcard_cert: false
|
|
||||||
|
|
||||||
gerbil:
|
gerbil:
|
||||||
start_port: 51820
|
start_port: 51820
|
18
install/config/crowdsec/acquis.yaml
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
filenames:
|
||||||
|
- /var/log/auth.log
|
||||||
|
- /var/log/syslog
|
||||||
|
labels:
|
||||||
|
type: syslog
|
||||||
|
---
|
||||||
|
poll_without_inotify: false
|
||||||
|
filenames:
|
||||||
|
- /var/log/traefik/*.log
|
||||||
|
labels:
|
||||||
|
type: traefik
|
||||||
|
---
|
||||||
|
listen_addr: 0.0.0.0:7422
|
||||||
|
appsec_config: crowdsecurity/appsec-default
|
||||||
|
name: myAppSecComponent
|
||||||
|
source: appsec
|
||||||
|
labels:
|
||||||
|
type: appsec
|
30
install/config/crowdsec/docker-compose.yml
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
services:
|
||||||
|
crowdsec:
|
||||||
|
image: crowdsecurity/crowdsec:latest
|
||||||
|
container_name: crowdsec
|
||||||
|
environment:
|
||||||
|
GID: "1000"
|
||||||
|
COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
|
||||||
|
ENROLL_INSTANCE_NAME: "pangolin-crowdsec"
|
||||||
|
PARSERS: crowdsecurity/whitelists
|
||||||
|
ACQUIRE_FILES: "/var/log/traefik/*.log"
|
||||||
|
ENROLL_TAGS: docker
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "cscli", "capi", "status"]
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=false" # Disable traefik for crowdsec
|
||||||
|
volumes:
|
||||||
|
# crowdsec container data
|
||||||
|
- ./config/crowdsec:/etc/crowdsec # crowdsec config
|
||||||
|
- ./config/crowdsec/db:/var/lib/crowdsec/data # crowdsec db
|
||||||
|
# log bind mounts into crowdsec
|
||||||
|
- ./config/crowdsec_logs/auth.log:/var/log/auth.log:ro # auth.log
|
||||||
|
- ./config/crowdsec_logs/syslog:/var/log/syslog:ro # syslog
|
||||||
|
- ./config/crowdsec_logs:/var/log # crowdsec logs
|
||||||
|
- ./config/traefik/logs:/var/log/traefik # traefik logs
|
||||||
|
ports:
|
||||||
|
- 6060:6060 # metrics endpoint for prometheus
|
||||||
|
expose:
|
||||||
|
- 6060 # metrics endpoint for prometheus
|
||||||
|
restart: unless-stopped
|
||||||
|
command: -t # Add test config flag to verify configuration
|
108
install/config/crowdsec/dynamic_config.yml
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
http:
|
||||||
|
middlewares:
|
||||||
|
redirect-to-https:
|
||||||
|
redirectScheme:
|
||||||
|
scheme: https
|
||||||
|
default-whitelist: # Whitelist middleware for internal IPs
|
||||||
|
ipWhiteList: # Internal IP addresses
|
||||||
|
sourceRange: # Internal IP addresses
|
||||||
|
- "10.0.0.0/8" # Internal IP addresses
|
||||||
|
- "192.168.0.0/16" # Internal IP addresses
|
||||||
|
- "172.16.0.0/12" # Internal IP addresses
|
||||||
|
# Basic security headers
|
||||||
|
security-headers:
|
||||||
|
headers:
|
||||||
|
customResponseHeaders: # Custom response headers
|
||||||
|
Server: "" # Remove server header
|
||||||
|
X-Powered-By: "" # Remove powered by header
|
||||||
|
X-Forwarded-Proto: "https" # Set forwarded proto to https
|
||||||
|
sslProxyHeaders: # SSL proxy headers
|
||||||
|
X-Forwarded-Proto: "https" # Set forwarded proto to https
|
||||||
|
hostsProxyHeaders: # Hosts proxy headers
|
||||||
|
- "X-Forwarded-Host" # Set forwarded host
|
||||||
|
contentTypeNosniff: true # Prevent MIME sniffing
|
||||||
|
customFrameOptionsValue: "SAMEORIGIN" # Set frame options
|
||||||
|
referrerPolicy: "strict-origin-when-cross-origin" # Set referrer policy
|
||||||
|
forceSTSHeader: true # Force STS header
|
||||||
|
stsIncludeSubdomains: true # Include subdomains
|
||||||
|
stsSeconds: 63072000 # STS seconds
|
||||||
|
stsPreload: true # Preload STS
|
||||||
|
# CrowdSec configuration with proper IP forwarding
|
||||||
|
crowdsec:
|
||||||
|
plugin:
|
||||||
|
crowdsec:
|
||||||
|
enabled: true # Enable CrowdSec plugin
|
||||||
|
logLevel: INFO # Log level
|
||||||
|
updateIntervalSeconds: 15 # Update interval
|
||||||
|
updateMaxFailure: 0 # Update max failure
|
||||||
|
defaultDecisionSeconds: 15 # Default decision seconds
|
||||||
|
httpTimeoutSeconds: 10 # HTTP timeout
|
||||||
|
crowdsecMode: live # CrowdSec mode
|
||||||
|
crowdsecAppsecEnabled: true # Enable AppSec
|
||||||
|
crowdsecAppsecHost: crowdsec:7422 # CrowdSec IP address which you noted down later
|
||||||
|
crowdsecAppsecFailureBlock: true # Block on failure
|
||||||
|
crowdsecAppsecUnreachableBlock: true # Block on unreachable
|
||||||
|
crowdsecLapiKey: "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK" # CrowdSec API key which you noted down later
|
||||||
|
crowdsecLapiHost: crowdsec:8080 # CrowdSec
|
||||||
|
crowdsecLapiScheme: http # CrowdSec API scheme
|
||||||
|
forwardedHeadersTrustedIPs: # Forwarded headers trusted IPs
|
||||||
|
- "0.0.0.0/0" # All IP addresses are trusted for forwarded headers (CHANGE MADE HERE)
|
||||||
|
clientTrustedIPs: # Client trusted IPs (CHANGE MADE HERE)
|
||||||
|
- "10.0.0.0/8" # Internal LAN IP addresses
|
||||||
|
- "172.16.0.0/12" # Internal LAN IP addresses
|
||||||
|
- "192.168.0.0/16" # Internal LAN IP addresses
|
||||||
|
- "100.89.137.0/20" # Internal LAN IP addresses
|
||||||
|
|
||||||
|
routers:
|
||||||
|
# HTTP to HTTPS redirect router
|
||||||
|
main-app-router-redirect:
|
||||||
|
rule: "Host(`{{.DashboardDomain}}`)" # Dynamic Domain Name
|
||||||
|
service: next-service
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
middlewares:
|
||||||
|
- redirect-to-https
|
||||||
|
|
||||||
|
# Next.js router (handles everything except API and WebSocket paths)
|
||||||
|
next-router:
|
||||||
|
rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)" # Dynamic Domain Name
|
||||||
|
service: next-service
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
middlewares:
|
||||||
|
- security-headers # Add security headers middleware
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
|
||||||
|
# API router (handles /api/v1 paths)
|
||||||
|
api-router:
|
||||||
|
rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)" # Dynamic Domain Name
|
||||||
|
service: api-service
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
middlewares:
|
||||||
|
- security-headers # Add security headers middleware
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
|
||||||
|
# WebSocket router
|
||||||
|
ws-router:
|
||||||
|
rule: "Host(`{{.DashboardDomain}}`)" # Dynamic Domain Name
|
||||||
|
service: api-service
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
middlewares:
|
||||||
|
- security-headers # Add security headers middleware
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
|
||||||
|
services:
|
||||||
|
next-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://pangolin:3002" # Next.js server
|
||||||
|
|
||||||
|
api-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://pangolin:3000" # API/WebSocket server
|
25
install/config/crowdsec/profiles.yaml
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
name: captcha_remediation
|
||||||
|
filters:
|
||||||
|
- Alert.Remediation == true && Alert.GetScope() == "Ip" && Alert.GetScenario() contains "http"
|
||||||
|
decisions:
|
||||||
|
- type: captcha
|
||||||
|
duration: 4h
|
||||||
|
on_success: break
|
||||||
|
|
||||||
|
---
|
||||||
|
name: default_ip_remediation
|
||||||
|
filters:
|
||||||
|
- Alert.Remediation == true && Alert.GetScope() == "Ip"
|
||||||
|
decisions:
|
||||||
|
- type: ban
|
||||||
|
duration: 4h
|
||||||
|
on_success: break
|
||||||
|
|
||||||
|
---
|
||||||
|
name: default_range_remediation
|
||||||
|
filters:
|
||||||
|
- Alert.Remediation == true && Alert.GetScope() == "Range"
|
||||||
|
decisions:
|
||||||
|
- type: ban
|
||||||
|
duration: 4h
|
||||||
|
on_success: break
|
87
install/config/crowdsec/traefik_config.yml
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
api:
|
||||||
|
insecure: true
|
||||||
|
dashboard: true
|
||||||
|
|
||||||
|
providers:
|
||||||
|
http:
|
||||||
|
endpoint: "http://pangolin:3001/api/v1/traefik-config"
|
||||||
|
pollInterval: "5s"
|
||||||
|
file:
|
||||||
|
filename: "/etc/traefik/dynamic_config.yml"
|
||||||
|
|
||||||
|
experimental:
|
||||||
|
plugins:
|
||||||
|
badger:
|
||||||
|
moduleName: "github.com/fosrl/badger"
|
||||||
|
version: "{{.BadgerVersion}}"
|
||||||
|
crowdsec: # CrowdSec plugin configuration added
|
||||||
|
moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
|
version: "v1.3.5"
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: "INFO"
|
||||||
|
format: "json" # Log format changed to json for better parsing
|
||||||
|
|
||||||
|
accessLog: # We enable access logs as json
|
||||||
|
filePath: "/var/log/traefik/access.log"
|
||||||
|
format: json
|
||||||
|
filters:
|
||||||
|
statusCodes:
|
||||||
|
- "200-299" # Success codes
|
||||||
|
- "400-499" # Client errors
|
||||||
|
- "500-599" # Server errors
|
||||||
|
retryAttempts: true
|
||||||
|
minDuration: "100ms" # Increased to focus on slower requests
|
||||||
|
bufferingSize: 100 # Add buffering for better performance
|
||||||
|
fields:
|
||||||
|
defaultMode: drop # Start with dropping all fields
|
||||||
|
names:
|
||||||
|
ClientAddr: keep # Keep client address for IP tracking
|
||||||
|
ClientHost: keep # Keep client host for IP tracking
|
||||||
|
RequestMethod: keep # Keep request method for tracking
|
||||||
|
RequestPath: keep # Keep request path for tracking
|
||||||
|
RequestProtocol: keep # Keep request protocol for tracking
|
||||||
|
DownstreamStatus: keep # Keep downstream status for tracking
|
||||||
|
DownstreamContentSize: keep # Keep downstream content size for tracking
|
||||||
|
Duration: keep # Keep request duration for tracking
|
||||||
|
ServiceName: keep # Keep service name for tracking
|
||||||
|
StartUTC: keep # Keep start time for tracking
|
||||||
|
TLSVersion: keep # Keep TLS version for tracking
|
||||||
|
TLSCipher: keep # Keep TLS cipher for tracking
|
||||||
|
RetryAttempts: keep # Keep retry attempts for tracking
|
||||||
|
headers:
|
||||||
|
defaultMode: drop # Start with dropping all headers
|
||||||
|
names:
|
||||||
|
User-Agent: keep # Keep user agent for tracking
|
||||||
|
X-Real-Ip: keep # Keep real IP for tracking
|
||||||
|
X-Forwarded-For: keep # Keep forwarded IP for tracking
|
||||||
|
X-Forwarded-Proto: keep # Keep forwarded protocol for tracking
|
||||||
|
Content-Type: keep # Keep content type for tracking
|
||||||
|
Authorization: redact # Redact sensitive information
|
||||||
|
Cookie: redact # Redact sensitive information
|
||||||
|
|
||||||
|
certificatesResolvers:
|
||||||
|
letsencrypt:
|
||||||
|
acme:
|
||||||
|
httpChallenge:
|
||||||
|
entryPoint: web
|
||||||
|
email: "{{.LetsEncryptEmail}}"
|
||||||
|
storage: "/letsencrypt/acme.json"
|
||||||
|
caServer: "https://acme-v02.api.letsencrypt.org/directory"
|
||||||
|
|
||||||
|
entryPoints:
|
||||||
|
web:
|
||||||
|
address: ":80"
|
||||||
|
websecure:
|
||||||
|
address: ":443"
|
||||||
|
transport:
|
||||||
|
respondingTimeouts:
|
||||||
|
readTimeout: "30m"
|
||||||
|
http:
|
||||||
|
tls:
|
||||||
|
certResolver: "letsencrypt"
|
||||||
|
middlewares:
|
||||||
|
- crowdsec@file
|
||||||
|
|
||||||
|
serversTransport:
|
||||||
|
insecureSkipVerify: true
|
|
@ -1,3 +1,4 @@
|
||||||
|
name: pangolin
|
||||||
services:
|
services:
|
||||||
pangolin:
|
pangolin:
|
||||||
image: fosrl/pangolin:{{.PangolinVersion}}
|
image: fosrl/pangolin:{{.PangolinVersion}}
|
||||||
|
@ -10,7 +11,6 @@ services:
|
||||||
interval: "3s"
|
interval: "3s"
|
||||||
timeout: "3s"
|
timeout: "3s"
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
{{if .InstallGerbil}}
|
{{if .InstallGerbil}}
|
||||||
gerbil:
|
gerbil:
|
||||||
image: fosrl/gerbil:{{.GerbilVersion}}
|
image: fosrl/gerbil:{{.GerbilVersion}}
|
||||||
|
@ -34,15 +34,13 @@ services:
|
||||||
- 443:443 # Port for traefik because of the network_mode
|
- 443:443 # Port for traefik because of the network_mode
|
||||||
- 80:80 # Port for traefik because of the network_mode
|
- 80:80 # Port for traefik because of the network_mode
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:v3.3.3
|
image: traefik:v3.3.3
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
{{if .InstallGerbil}}
|
{{if .InstallGerbil}}
|
||||||
network_mode: service:gerbil # Ports appear on the gerbil service
|
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||||
{{end}}
|
{{end}}{{if not .InstallGerbil}}
|
||||||
{{if not .InstallGerbil}}
|
|
||||||
ports:
|
ports:
|
||||||
- 443:443
|
- 443:443
|
||||||
- 80:80
|
- 80:80
|
||||||
|
@ -55,6 +53,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
|
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
|
||||||
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
||||||
|
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
137
install/crowdsec.go
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func installCrowdsec(config Config) error {
|
||||||
|
|
||||||
|
if err := stopContainers(); err != nil {
|
||||||
|
return fmt.Errorf("failed to stop containers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run installation steps
|
||||||
|
if err := backupConfig(); err != nil {
|
||||||
|
return fmt.Errorf("backup failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := createConfigFiles(config); err != nil {
|
||||||
|
fmt.Printf("Error creating config files: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.MkdirAll("config/crowdsec/db", 0755)
|
||||||
|
os.MkdirAll("config/crowdsec_logs/syslog", 0755)
|
||||||
|
os.MkdirAll("config/traefik/logs", 0755)
|
||||||
|
|
||||||
|
if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil {
|
||||||
|
fmt.Printf("Error copying docker service: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := MergeYAML("config/traefik/traefik_config.yml", "config/crowdsec/traefik_config.yml"); err != nil {
|
||||||
|
fmt.Printf("Error copying entry points: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// delete the 2nd file
|
||||||
|
if err := os.Remove("config/crowdsec/traefik_config.yml"); err != nil {
|
||||||
|
fmt.Printf("Error removing file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := MergeYAML("config/traefik/dynamic_config.yml", "config/crowdsec/dynamic_config.yml"); err != nil {
|
||||||
|
fmt.Printf("Error copying entry points: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// delete the 2nd file
|
||||||
|
if err := os.Remove("config/crowdsec/dynamic_config.yml"); err != nil {
|
||||||
|
fmt.Printf("Error removing file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove("config/crowdsec/docker-compose.yml"); err != nil {
|
||||||
|
fmt.Printf("Error removing file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := CheckAndAddTraefikLogVolume("docker-compose.yml"); err != nil {
|
||||||
|
fmt.Printf("Error checking and adding Traefik log volume: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := startContainers(); err != nil {
|
||||||
|
return fmt.Errorf("failed to start containers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get API key
|
||||||
|
apiKey, err := GetCrowdSecAPIKey()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get API key: %v", err)
|
||||||
|
}
|
||||||
|
config.TraefikBouncerKey = apiKey
|
||||||
|
|
||||||
|
if err := replaceInFile("config/traefik/dynamic_config.yml", "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK", config.TraefikBouncerKey); err != nil {
|
||||||
|
return fmt.Errorf("failed to replace bouncer key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := restartContainer("traefik"); err != nil {
|
||||||
|
return fmt.Errorf("failed to restart containers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if checkIfTextInFile("config/traefik/dynamic_config.yml", "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK") {
|
||||||
|
fmt.Println("Failed to replace bouncer key! Please retrieve the key and replace it in the config/traefik/dynamic_config.yml file using the following command:")
|
||||||
|
fmt.Println(" docker exec crowdsec cscli bouncers add traefik-bouncer")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkIsCrowdsecInstalledInCompose() bool {
|
||||||
|
// Read docker-compose.yml
|
||||||
|
content, err := os.ReadFile("docker-compose.yml")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for crowdsec service
|
||||||
|
return bytes.Contains(content, []byte("crowdsec:"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCrowdSecAPIKey() (string, error) {
|
||||||
|
// First, ensure the container is running
|
||||||
|
if err := waitForContainer("crowdsec"); err != nil {
|
||||||
|
return "", fmt.Errorf("waiting for container: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the command to get the API key
|
||||||
|
cmd := exec.Command("docker", "exec", "crowdsec", "cscli", "bouncers", "add", "traefik-bouncer", "-o", "raw")
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("executing command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim any whitespace from the output
|
||||||
|
apiKey := strings.TrimSpace(out.String())
|
||||||
|
if apiKey == "" {
|
||||||
|
return "", fmt.Errorf("empty API key returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkIfTextInFile(file, text string) bool {
|
||||||
|
// Read file
|
||||||
|
content, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for text
|
||||||
|
return bytes.Contains(content, []byte(text))
|
||||||
|
}
|
|
@ -5,4 +5,5 @@ go 1.23.0
|
||||||
require (
|
require (
|
||||||
golang.org/x/sys v0.29.0 // indirect
|
golang.org/x/sys v0.29.0 // indirect
|
||||||
golang.org/x/term v0.28.0 // indirect
|
golang.org/x/term v0.28.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
@ -2,3 +2,6 @@ golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
12
install/input.txt
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
example.com
|
||||||
|
pangolin.example.com
|
||||||
|
admin@example.com
|
||||||
|
yes
|
||||||
|
admin@example.com
|
||||||
|
Password123!
|
||||||
|
Password123!
|
||||||
|
yes
|
||||||
|
no
|
||||||
|
no
|
||||||
|
no
|
||||||
|
yes
|
308
install/main.go
|
@ -4,13 +4,16 @@ import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"bytes"
|
||||||
"text/template"
|
"text/template"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
@ -24,7 +27,7 @@ func loadVersions(config *Config) {
|
||||||
config.BadgerVersion = "replaceme"
|
config.BadgerVersion = "replaceme"
|
||||||
}
|
}
|
||||||
|
|
||||||
//go:embed fs/*
|
//go:embed config/*
|
||||||
var configFiles embed.FS
|
var configFiles embed.FS
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
@ -45,6 +48,8 @@ type Config struct {
|
||||||
EmailSMTPPass string
|
EmailSMTPPass string
|
||||||
EmailNoReply string
|
EmailNoReply string
|
||||||
InstallGerbil bool
|
InstallGerbil bool
|
||||||
|
TraefikBouncerKey string
|
||||||
|
DoCrowdsecInstall bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
@ -56,9 +61,12 @@ func main() {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var config Config
|
||||||
|
config.DoCrowdsecInstall = false
|
||||||
|
|
||||||
// check if there is already a config file
|
// check if there is already a config file
|
||||||
if _, err := os.Stat("config/config.yml"); err != nil {
|
if _, err := os.Stat("config/config.yml"); err != nil {
|
||||||
config := collectUserInput(reader)
|
config = collectUserInput(reader)
|
||||||
|
|
||||||
loadVersions(&config)
|
loadVersions(&config)
|
||||||
|
|
||||||
|
@ -67,18 +75,53 @@ func main() {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
moveFile("config/docker-compose.yml", "docker-compose.yml")
|
||||||
|
|
||||||
if !isDockerInstalled() && runtime.GOOS == "linux" {
|
if !isDockerInstalled() && runtime.GOOS == "linux" {
|
||||||
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
|
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
|
||||||
installDocker()
|
installDocker()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n=== Starting installation ===")
|
||||||
|
|
||||||
|
if isDockerInstalled() {
|
||||||
|
if readBool(reader, "Would you like to install and start the containers?", true) {
|
||||||
|
pullAndStartContainers()
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Config file already exists... skipping configuration")
|
fmt.Println("Looks like you already installed, so I am going to do the setup...")
|
||||||
}
|
}
|
||||||
|
|
||||||
if isDockerInstalled() {
|
if !checkIsCrowdsecInstalledInCompose() {
|
||||||
if readBool(reader, "Would you like to install and start the containers?", true) {
|
fmt.Println("\n=== Crowdsec Install ===")
|
||||||
pullAndStartContainers()
|
// check if crowdsec is installed
|
||||||
|
if readBool(reader, "Would you like to install Crowdsec?", true) {
|
||||||
|
|
||||||
|
if config.DashboardDomain == "" {
|
||||||
|
traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml", "config/traefik/dynamic_config.yml")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error reading config: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config.DashboardDomain = traefikConfig.DashboardDomain
|
||||||
|
config.LetsEncryptEmail = traefikConfig.LetsEncryptEmail
|
||||||
|
config.BadgerVersion = traefikConfig.BadgerVersion
|
||||||
|
|
||||||
|
// print the values and check if they are right
|
||||||
|
fmt.Println("Detected values:")
|
||||||
|
fmt.Printf("Dashboard Domain: %s\n", config.DashboardDomain)
|
||||||
|
fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail)
|
||||||
|
fmt.Printf("Badger Version: %s\n", config.BadgerVersion)
|
||||||
|
|
||||||
|
if !readBool(reader, "Are these values correct?", true) {
|
||||||
|
config = collectUserInput(reader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.DoCrowdsecInstall = true
|
||||||
|
installCrowdsec(config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,22 +142,24 @@ func readString(reader *bufio.Reader, prompt string, defaultValue string) string
|
||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
|
|
||||||
func readPassword(prompt string) string {
|
func readPassword(prompt string, reader *bufio.Reader) string {
|
||||||
fmt.Print(prompt + ": ")
|
if term.IsTerminal(int(syscall.Stdin)) {
|
||||||
|
fmt.Print(prompt + ": ")
|
||||||
// Read password without echo
|
// Read password without echo if we're in a terminal
|
||||||
password, err := term.ReadPassword(int(syscall.Stdin))
|
password, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
fmt.Println() // Add a newline since ReadPassword doesn't add one
|
fmt.Println() // Add a newline since ReadPassword doesn't add one
|
||||||
|
if err != nil {
|
||||||
if err != nil {
|
return ""
|
||||||
return ""
|
}
|
||||||
}
|
input := strings.TrimSpace(string(password))
|
||||||
|
if input == "" {
|
||||||
input := strings.TrimSpace(string(password))
|
return readPassword(prompt, reader)
|
||||||
if input == "" {
|
}
|
||||||
return readPassword(prompt)
|
return input
|
||||||
}
|
} else {
|
||||||
return input
|
// Fallback to reading from stdin if not in a terminal
|
||||||
|
return readString(reader, prompt, "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
|
func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
|
||||||
|
@ -150,8 +195,8 @@ func collectUserInput(reader *bufio.Reader) Config {
|
||||||
fmt.Println("\n=== Admin User Configuration ===")
|
fmt.Println("\n=== Admin User Configuration ===")
|
||||||
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain)
|
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain)
|
||||||
for {
|
for {
|
||||||
pass1 := readPassword("Create admin user password")
|
pass1 := readPassword("Create admin user password", reader)
|
||||||
pass2 := readPassword("Confirm admin user password")
|
pass2 := readPassword("Confirm admin user password", reader)
|
||||||
|
|
||||||
if pass1 != pass2 {
|
if pass1 != pass2 {
|
||||||
fmt.Println("Passwords do not match")
|
fmt.Println("Passwords do not match")
|
||||||
|
@ -261,31 +306,33 @@ func createConfigFiles(config Config) error {
|
||||||
os.MkdirAll("config/logs", 0755)
|
os.MkdirAll("config/logs", 0755)
|
||||||
|
|
||||||
// Walk through all embedded files
|
// Walk through all embedded files
|
||||||
err := fs.WalkDir(configFiles, "fs", func(path string, d fs.DirEntry, err error) error {
|
err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip the root fs directory itself
|
// Skip the root fs directory itself
|
||||||
if path == "fs" {
|
if path == "config" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the relative path by removing the "fs/" prefix
|
if !config.DoCrowdsecInstall && strings.Contains(path, "crowdsec") {
|
||||||
relPath := strings.TrimPrefix(path, "fs/")
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.DoCrowdsecInstall && !strings.Contains(path, "crowdsec") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// skip .DS_Store
|
// skip .DS_Store
|
||||||
if strings.Contains(relPath, ".DS_Store") {
|
if strings.Contains(path, ".DS_Store") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the full output path under "config/"
|
|
||||||
outPath := filepath.Join("config", relPath)
|
|
||||||
|
|
||||||
if d.IsDir() {
|
if d.IsDir() {
|
||||||
// Create directory
|
// Create directory
|
||||||
if err := os.MkdirAll(outPath, 0755); err != nil {
|
if err := os.MkdirAll(path, 0755); err != nil {
|
||||||
return fmt.Errorf("failed to create directory %s: %v", outPath, err)
|
return fmt.Errorf("failed to create directory %s: %v", path, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -303,14 +350,14 @@ func createConfigFiles(config Config) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure parent directory exists
|
// Ensure parent directory exists
|
||||||
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
return fmt.Errorf("failed to create parent directory for %s: %v", outPath, err)
|
return fmt.Errorf("failed to create parent directory for %s: %v", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create output file
|
// Create output file
|
||||||
outFile, err := os.Create(outPath)
|
outFile, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create %s: %v", outPath, err)
|
return fmt.Errorf("failed to create %s: %v", path, err)
|
||||||
}
|
}
|
||||||
defer outFile.Close()
|
defer outFile.Close()
|
||||||
|
|
||||||
|
@ -326,30 +373,10 @@ func createConfigFiles(config Config) error {
|
||||||
return fmt.Errorf("error walking config files: %v", err)
|
return fmt.Errorf("error walking config files: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// get the current directory
|
|
||||||
dir, err := os.Getwd()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get current directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sourcePath := filepath.Join(dir, "config/docker-compose.yml")
|
|
||||||
destPath := filepath.Join(dir, "docker-compose.yml")
|
|
||||||
|
|
||||||
// Check if source file exists
|
|
||||||
if _, err := os.Stat(sourcePath); err != nil {
|
|
||||||
return fmt.Errorf("source docker-compose.yml not found: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to move the file
|
|
||||||
err = os.Rename(sourcePath, destPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to move docker-compose.yml from %s to %s: %v",
|
|
||||||
sourcePath, destPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func installDocker() error {
|
func installDocker() error {
|
||||||
// Detect Linux distribution
|
// Detect Linux distribution
|
||||||
cmd := exec.Command("cat", "/etc/os-release")
|
cmd := exec.Command("cat", "/etc/os-release")
|
||||||
|
@ -490,3 +517,166 @@ func pullAndStartContainers() error {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bring containers down
|
||||||
|
func stopContainers() error {
|
||||||
|
fmt.Println("Stopping containers...")
|
||||||
|
|
||||||
|
// Check which docker compose command is available
|
||||||
|
var useNewStyle bool
|
||||||
|
checkCmd := exec.Command("docker", "compose", "version")
|
||||||
|
if err := checkCmd.Run(); err == nil {
|
||||||
|
useNewStyle = true
|
||||||
|
} else {
|
||||||
|
// Check if docker-compose (old style) is available
|
||||||
|
checkCmd = exec.Command("docker-compose", "version")
|
||||||
|
if err := checkCmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to execute docker compose commands
|
||||||
|
executeCommand := func(args ...string) error {
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if useNewStyle {
|
||||||
|
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("docker-compose", args...)
|
||||||
|
}
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := executeCommand("-f", "docker-compose.yml", "down"); err != nil {
|
||||||
|
return fmt.Errorf("failed to stop containers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// just start containers
|
||||||
|
func startContainers() error {
|
||||||
|
fmt.Println("Starting containers...")
|
||||||
|
|
||||||
|
// Check which docker compose command is available
|
||||||
|
var useNewStyle bool
|
||||||
|
checkCmd := exec.Command("docker", "compose", "version")
|
||||||
|
if err := checkCmd.Run(); err == nil {
|
||||||
|
useNewStyle = true
|
||||||
|
} else {
|
||||||
|
// Check if docker-compose (old style) is available
|
||||||
|
checkCmd = exec.Command("docker-compose", "version")
|
||||||
|
if err := checkCmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to execute docker compose commands
|
||||||
|
executeCommand := func(args ...string) error {
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if useNewStyle {
|
||||||
|
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("docker-compose", args...)
|
||||||
|
}
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := executeCommand("-f", "docker-compose.yml", "up", "-d"); err != nil {
|
||||||
|
return fmt.Errorf("failed to start containers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartContainer(container string) error {
|
||||||
|
fmt.Printf("Restarting %s container...\n", container)
|
||||||
|
|
||||||
|
// Check which docker compose command is available
|
||||||
|
var useNewStyle bool
|
||||||
|
checkCmd := exec.Command("docker", "compose", "version")
|
||||||
|
if err := checkCmd.Run(); err == nil {
|
||||||
|
useNewStyle = true
|
||||||
|
} else {
|
||||||
|
// Check if docker-compose (old style) is available
|
||||||
|
checkCmd = exec.Command("docker-compose", "version")
|
||||||
|
if err := checkCmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to execute docker compose commands
|
||||||
|
executeCommand := func(args ...string) error {
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if useNewStyle {
|
||||||
|
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("docker-compose", args...)
|
||||||
|
}
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := executeCommand("-f", "docker-compose.yml", "restart", container); err != nil {
|
||||||
|
return fmt.Errorf("failed to restart %s container: %v", container, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
source, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer source.Close()
|
||||||
|
|
||||||
|
destination, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer destination.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(destination, source)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveFile(src, dst string) error {
|
||||||
|
if err := copyFile(src, dst); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.Remove(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForContainer(containerName string) error {
|
||||||
|
maxAttempts := 30
|
||||||
|
retryInterval := time.Second * 2
|
||||||
|
|
||||||
|
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||||
|
// Check if container is running
|
||||||
|
cmd := exec.Command("docker", "container", "inspect", "-f", "{{.State.Running}}", containerName)
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
// If the container doesn't exist or there's another error, wait and retry
|
||||||
|
time.Sleep(retryInterval)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning := strings.TrimSpace(out.String()) == "true"
|
||||||
|
if isRunning {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container exists but isn't running yet, wait and retry
|
||||||
|
time.Sleep(retryInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("container %s did not start within %v seconds", containerName, maxAttempts*int(retryInterval.Seconds()))
|
||||||
|
}
|
291
internationalization/es.md
Normal file
|
@ -0,0 +1,291 @@
|
||||||
|
## Authentication Site
|
||||||
|
|
||||||
|
|
||||||
|
| EN | ES | Notes |
|
||||||
|
| -------------------------------------------------------- | ------------------------------------------------------------ | ---------- |
|
||||||
|
| Powered by [Pangolin](https://github.com/fosrl/pangolin) | Desarrollado por [Pangolin](https://github.com/fosrl/pangolin) | |
|
||||||
|
| Authentication Required | Se requiere autenticación | |
|
||||||
|
| Choose your preferred method to access {resource} | Elije tu método requerido para acceder a {resource} | |
|
||||||
|
| PIN | PIN | |
|
||||||
|
| User | Usuario | |
|
||||||
|
| 6-digit PIN Code | Código PIN de 6 dígitos | pin login |
|
||||||
|
| Login in with PIN | Registrate con PIN | pin login |
|
||||||
|
| Email | Email | user login |
|
||||||
|
| Enter your email | Introduce tu email | user login |
|
||||||
|
| Password | Contraseña | user login |
|
||||||
|
| Enter your password | Introduce tu contraseña | user login |
|
||||||
|
| Forgot your password? | ¿Olvidaste tu contraseña? | user login |
|
||||||
|
| Log in | Iniciar sesión | user login |
|
||||||
|
|
||||||
|
|
||||||
|
## Login site
|
||||||
|
|
||||||
|
| EN | ES | Notes |
|
||||||
|
| --------------------- | ---------------------------------- | ----------- |
|
||||||
|
| Welcome to Pangolin | Binvenido a Pangolin | |
|
||||||
|
| Log in to get started | Registrate para comenzar | |
|
||||||
|
| Email | Email | |
|
||||||
|
| Enter your email | Introduce tu email | placeholder |
|
||||||
|
| Password | Contraseña | |
|
||||||
|
| Enter your password | Introduce tu contraseña | placeholder |
|
||||||
|
| Forgot your password? | ¿Olvidaste tu contraseña? | |
|
||||||
|
| Log in | Iniciar sesión | |
|
||||||
|
|
||||||
|
# Ogranization site after successful login
|
||||||
|
|
||||||
|
| EN | ES | Notes |
|
||||||
|
| ----------------------------------------- | -------------------------------------------- | ----- |
|
||||||
|
| Welcome to Pangolin | Binvenido a Pangolin | |
|
||||||
|
| You're a member of {number} organization. | Eres miembro de la organización {number}. | |
|
||||||
|
|
||||||
|
## Shared Header, Navbar and Footer
|
||||||
|
##### Header
|
||||||
|
|
||||||
|
| EN | ES | Notes |
|
||||||
|
| ------------------- | ------------------- | ----- |
|
||||||
|
| Documentation | Documentación | |
|
||||||
|
| Support | Soporte | |
|
||||||
|
| Organization {name} | Organización {name} | |
|
||||||
|
##### Organization selector
|
||||||
|
|
||||||
|
| EN | ES | Notes |
|
||||||
|
| ---------------- | ----------------- | ----- |
|
||||||
|
| Search… | Buscar… | |
|
||||||
|
| Create | Crear | |
|
||||||
|
| New Organization | Nueva Organización| |
|
||||||
|
| Organizations | Organizaciones | |
|
||||||
|
|
||||||
|
##### Navbar
|
||||||
|
|
||||||
|
| EN | ES | Notes |
|
||||||
|
| --------------- | -----------------------| ----- |
|
||||||
|
| Sites | Sitios | |
|
||||||
|
| Resources | Recursos | |
|
||||||
|
| User & Roles | Usuarios y roles | |
|
||||||
|
| Shareable Links | Enlaces para compartir | |
|
||||||
|
| General | General | |
|
||||||
|
|
||||||
|
##### Footer
|
||||||
|
| EN | ES | |
|
||||||
|
| ------------------------- | --------------------------- | -------|
|
||||||
|
| Page {number} of {number} | Página {number} de {number} | footer |
|
||||||
|
| Rows per page | Filas por página | footer |
|
||||||
|
| Pangolin | Pangolin | footer |
|
||||||
|
| Built by Fossorial | Construido por Fossorial | footer |
|
||||||
|
| Open Source | Código abierto | footer |
|
||||||
|
| Documentation | Documentación | footer |
|
||||||
|
| {version} | {version} | footer |
|
||||||
|
|
||||||
|
## Main “Sites”
|
||||||
|
##### “Hero” section
|
||||||
|
|
||||||
|
| EN | ES | Notes |
|
||||||
|
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||||
|
| Newt (Recommended) | Newt (Recomendado) | |
|
||||||
|
| For the best user experience, use Newt. It uses WireGuard under the hood and allows you to address your private resources by their LAN address on your private network from within the Pangolin dashboard. | Para obtener la mejor experiencia de usuario, utiliza Newt. Utiliza WireGuard internamente y te permite abordar tus recursos privados mediante tu dirección LAN en tu red privada desde el panel de Pangolin. | |
|
||||||
|
| Runs in Docker | Se ejecuta en Docker | |
|
||||||
|
| Runs in shell on macOS, Linux, and Windows | Se ejecuta en shell en macOS, Linux y Windows | |
|
||||||
|
| Install Newt | Instalar Newt | |
|
||||||
|
| Basic WireGuard<br> | WireGuard básico<br> | |
|
||||||
|
| Compatible with all WireGuard clients<br> | Compatible con todos los clientes WireGuard<br> | |
|
||||||
|
| Manual configuration required | Se requiere configuración manual | |
|
||||||
|
|
||||||
|
##### Content
|
||||||
|
|
||||||
|
| EN | ES | Notes |
|
||||||
|
| --------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------- |
|
||||||
|
| Manage Sites | Administrar sitios | |
|
||||||
|
| Allow connectivity to your network through secure tunnels | Permitir la conectividad a tu red a través de túneles seguros| |
|
||||||
|
| Search sites | Buscar sitios | placeholder |
|
||||||
|
| Add Site | Agregar sitio | |
|
||||||
|
| Name | Nombre | table header |
|
||||||
|
| Online | Conectado | table header |
|
||||||
|
| Site | Sitio | table header |
|
||||||
|
| Data In | Datos en | table header |
|
||||||
|
| Data Out | Datos de salida | table header |
|
||||||
|
| Connection Type | Tipo de conexión | table header |
|
||||||
|
| Online | Conectado | site state |
|
||||||
|
| Offline | Desconectado | site state |
|
||||||
|
| Edit → | Editar → | |
|
||||||
|
| View settings | Ver configuración | Popup after clicking “…” on site |
|
||||||
|
| Delete | Borrar | Popup after clicking “…” on site |
|
||||||
|
|
||||||
|
##### Add Site Popup
|
||||||
|
|
||||||
|
| EN | ES | Notes |
|
||||||
|
| ------------------------------------------------------ | ----------------------------------------------------------- | ----------- |
|
||||||
|
| Create Site | Crear sitio | |
|
||||||
|
| Create a new site to start connection for this site | Crear un nuevo sitio para iniciar la conexión para este sitio | |
|
||||||
|
| Name | Nombre | |
|
||||||
|
| Site name | Nombre del sitio | placeholder |
|
||||||
|
| This is the name that will be displayed for this site. | Este es el nombre que se mostrará para este sitio. | desc |
|
||||||
|
| Method | Método | |
|
||||||
|
| Local | Local | |
|
||||||
|
| Newt | Newt | |
|
||||||
|
| WireGuard | WireGuard | |
|
||||||
|
| This is how you will expose connections. | Así es como expondrás las conexiones. | |
|
||||||
|
| You will only be able to see the configuration once. | Solo podrás ver la configuración una vez. | |
|
||||||
|
| Learn how to install Newt on your system | Aprende a instalar Newt en tu sistema | |
|
||||||
|
| I have copied the config | He copiado la configuración | |
|
||||||
|
| Create Site | Crear sitio | |
|
||||||
|
| Close | Cerrar | |
|
||||||
|
|
||||||
|
## Main “Resources”
|
||||||
|
|
||||||
|
##### “Hero” section
|
||||||
|
|
||||||
|
| EN | ES | Notes |
|
||||||
|
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||||
|
| Resources | Recursos | |
|
||||||
|
| Ressourcen sind Proxy-Server für Anwendungen, die in Ihrem privaten Netzwerk laufen. Erstellen Sie eine Ressource für jede HTTP- oder HTTPS-Anwendung in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Website verbunden sein, um eine private und sichere Verbindung über den verschlüsselten WireGuard-Tunnel zu ermöglichen. |Los recursos son servidores proxy para aplicaciones que se ejecutan en su red privada. Cree un recurso para cada aplicación HTTP o HTTPS en su red privada. Cada recurso debe estar conectado a un sitio web para proporcionar una conexión privada y segura a través del túnel cifrado WireGuard. | |
|
||||||
|
| Secure connectivity with WireGuard encryption | Conectividad segura con encriptación WireGuard | |
|
||||||
|
| Configure multiple authentication methods | Configura múltiples métodos de autenticación | |
|
||||||
|
| User and role-based access control | Control de acceso basado en usuarios y roles | |
|
||||||
|
|
||||||
|
##### Content
|
||||||
|
|
||||||
|
| EN | ES | Notes |
|
||||||
|
| -------------------------------------------------- | ---------------------------------------------------------- | -------------------- |
|
||||||
|
| Manage Resources | Administrar recursos | |
|
||||||
|
| Create secure proxies to your private applications | Crea servidores proxy seguros para tus aplicaciones privadas | |
|
||||||
|
| Search resources | Buscar recursos | placeholder |
|
||||||
|
| Name | Nombre | |
|
||||||
|
| Site | Sitio | |
|
||||||
|
| Full URL | URL completa | |
|
||||||
|
| Authentication | Autenticación | |
|
||||||
|
| Not Protected | No protegido | authentication state |
|
||||||
|
| Protected | Protegido | authentication state |
|
||||||
|
| Edit → | Editar → | |
|
||||||
|
| Add Resource | Agregar recurso | |
|
||||||
|
|
||||||
|
##### Add Resource Popup
|
||||||
|
|
||||||
|
| EN | ES | Notes |
|
||||||
|
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------- |
|
||||||
|
| Create Resource | Crear recurso | |
|
||||||
|
| Create a new resource to proxy request to your app | Crea un nuevo recurso para enviar solicitudes a tu aplicación | |
|
||||||
|
| Name | Nombre | |
|
||||||
|
| My Resource | Mi recurso | name placeholder |
|
||||||
|
| This is the name that will be displayed for this resource. | Este es el nombre que se mostrará para este recurso. | |
|
||||||
|
| Subdomain | Subdominio | |
|
||||||
|
| Enter subdomain | Ingresar subdominio | |
|
||||||
|
| This is the fully qualified domain name that will be used to access the resource. | Este es el nombre de dominio completo que se utilizará para acceder al recurso. | |
|
||||||
|
| Site | Sitio | |
|
||||||
|
| Search site… | Buscar sitio… | Site selector popup |
|
||||||
|
| This is the site that will be used in the dashboard. | Este es el sitio que se utilizará en el panel de control. | |
|
||||||
|
| Create Resource | Crear recurso | |
|
||||||
|
| Close | Cerrar | |
|
||||||
|
|
||||||
|
## Main “User & Roles”
|
||||||
|
##### Content
|
||||||
|
|
||||||
|
| EN | ES | Notes |
|
||||||
|
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------- |
|
||||||
|
| Manage User & Roles | Administrar usuarios y roles | |
|
||||||
|
| Invite users and add them to roles to manage access to your organization | Invita a usuarios y agrégalos a roles para administrar el acceso a tu organización | |
|
||||||
|
| Users | Usuarios | sidebar item |
|
||||||
|
| Roles | Roles | sidebar item |
|
||||||
|
| **User tab** | **Pestaña de usuario** | |
|
||||||
|
| Search users | Buscar usuarios | placeholder |
|
||||||
|
| Invite User | Invitar usuario | addbutton |
|
||||||
|
| Email | Email | table header |
|
||||||
|
| Status | Estado | table header |
|
||||||
|
| Role | Role | table header |
|
||||||
|
| Confirmed | Confirmado | account status |
|
||||||
|
| Not confirmed (?) | No confirmado (?) | unknown for me account status |
|
||||||
|
| Owner | Dueño | role |
|
||||||
|
| Admin | Administrador | role |
|
||||||
|
| Member | Miembro | role |
|
||||||
|
| **Roles Tab** | **Pestaña Roles** | |
|
||||||
|
| Search roles | Buscar roles | placeholder |
|
||||||
|
| Add Role | Agregar rol | addbutton |
|
||||||
|
| Name | Nombre | table header |
|
||||||
|
| Description | Descripción | table header |
|
||||||
|
| Admin | Administrador | role |
|
||||||
|
| Member | Miembro | role |
|
||||||
|
| Admin role with the most permissions | Rol de administrador con más permisos | admin role desc |
|
||||||
|
| Members can only view resources | Los miembros sólo pueden ver los recursos | member role desc |
|
||||||
|
|
||||||
|
##### Invite User popup
|
||||||
|
|
||||||
|
| EN | ES | Notes |
|
||||||
|
| ----------------- | ------------------------------------------------------- | ----------- |
|
||||||
|
| Invite User | Invitar usuario | |
|
||||||
|
| Email | Email | |
|
||||||
|
| Enter an email | Introduzca un email | placeholder |
|
||||||
|
| Role | Rol | |
|
||||||
|
| Select role | Seleccionar rol | placeholder |
|
||||||
|
| Gültig für | Válido para | |
|
||||||
|
| 1 day | 1 día | |
|
||||||
|
| 2 days | 2 días | |
|
||||||
|
| 3 days | 3 días | |
|
||||||
|
| 4 days | 4 días | |
|
||||||
|
| 5 days | 5 días | |
|
||||||
|
| 6 days | 6 días | |
|
||||||
|
| 7 days | 7 días | |
|
||||||
|
| Create Invitation | Crear invitación | |
|
||||||
|
| Close | Cerrar | |
|
||||||
|
|
||||||
|
|
||||||
|
## Main “Shareable Links”
|
||||||
|
##### “Hero” section
|
||||||
|
|
||||||
|
| EN | ES | Notes |
|
||||||
|
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||||
|
| Shareable Links | Enlaces para compartir | |
|
||||||
|
| Create shareable links to your resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one. | Crear enlaces que se puedan compartir a tus recursos. Los enlaces proporcionan acceso temporal o ilimitado a tu recurso. Puedes configurar la duración de caducidad del enlace cuando lo creas. | |
|
||||||
|
| Easy to create and share | Fácil de crear y compartir | |
|
||||||
|
| Configurable expiration duration | Duración de expiración configurable | |
|
||||||
|
| Secure and revocable | Seguro y revocable | |
|
||||||
|
##### Content
|
||||||
|
|
||||||
|
| EN | ES | Notes |
|
||||||
|
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------- |
|
||||||
|
| Manage Shareable Links | Administrar enlaces compartibles | |
|
||||||
|
| Create shareable links to grant temporary or permanent access to your resources | Crear enlaces compartibles para otorgar acceso temporal o permanente a tus recursos | |
|
||||||
|
| Search links | Buscar enlaces | placeholder |
|
||||||
|
| Create Share Link | Crear enlace para compartir | addbutton |
|
||||||
|
| Resource | Recurso | table header |
|
||||||
|
| Title | Título | table header |
|
||||||
|
| Created | Creado | table header |
|
||||||
|
| Expires | Caduca | table header |
|
||||||
|
| No links. Create one to get started. | No hay enlaces. Crea uno para comenzar. | table placeholder |
|
||||||
|
|
||||||
|
##### Create Shareable Link popup
|
||||||
|
|
||||||
|
| EN | ES | Notes |
|
||||||
|
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------- |
|
||||||
|
| Create Shareable Link | Crear un enlace para compartir | |
|
||||||
|
| Anyone with this link can access the resource | Cualquier persona con este enlace puede acceder al recurso. | |
|
||||||
|
| Resource | Recurso | |
|
||||||
|
| Select resource | Seleccionar recurso | |
|
||||||
|
| Search resources… | Buscar recursos… | resource selector popup |
|
||||||
|
| Title (optional) | Título (opcional) | |
|
||||||
|
| Enter title | Introducir título | placeholder |
|
||||||
|
| Expire in | Caduca en | |
|
||||||
|
| Minutes | Minutos | |
|
||||||
|
| Hours | Horas | |
|
||||||
|
| Days | Días | |
|
||||||
|
| Months | Meses | |
|
||||||
|
| Years | Años | |
|
||||||
|
| Never expire | Nunca caduca | |
|
||||||
|
| Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource. | El tiempo de expiración es el tiempo durante el cual el enlace se podrá utilizar y brindará acceso al recurso. Después de este tiempo, el enlace dejará de funcionar y los usuarios que lo hayan utilizado perderán el acceso al recurso. | |
|
||||||
|
| Create Link | Crear enlace | |
|
||||||
|
| Close | Cerrar | |
|
||||||
|
|
||||||
|
|
||||||
|
## Main “General”
|
||||||
|
|
||||||
|
| EN | ES | Notes |
|
||||||
|
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------ |
|
||||||
|
| General | General | |
|
||||||
|
| Configure your organization’s general settings | Configura los ajustes generales de tu organización | |
|
||||||
|
| General | General | sidebar item |
|
||||||
|
| Organization Settings | Configuración de la organización | |
|
||||||
|
| Manage your organization details and configuration | Administra los detalles y la configuración de tu organización| |
|
||||||
|
| Name | Nombre | |
|
||||||
|
| This is the display name of the org | Este es el nombre para mostrar de la organización. | |
|
||||||
|
| Save Settings | Guardar configuración | |
|
||||||
|
| Danger Zone | Zona de peligro | |
|
||||||
|
| Once you delete this org, there is no going back. Please be certain. | Una vez que elimines esta organización, no habrá vuelta atrás. Asegúrate de hacerlo. | |
|
||||||
|
| Delete Organization Data | Eliminar datos de la organización | |
|
|
@ -2,7 +2,8 @@
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
eslint: {
|
eslint: {
|
||||||
ignoreDuringBuilds: true,
|
ignoreDuringBuilds: true,
|
||||||
}
|
},
|
||||||
|
output: "standalone"
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
14861
package-lock.json
generated
Normal file
|
@ -50,7 +50,6 @@
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"drizzle-orm": "0.38.3",
|
"drizzle-orm": "0.38.3",
|
||||||
"emblor": "1.4.7",
|
|
||||||
"eslint": "9.17.0",
|
"eslint": "9.17.0",
|
||||||
"eslint-config-next": "15.1.3",
|
"eslint-config-next": "15.1.3",
|
||||||
"express": "4.21.2",
|
"express": "4.21.2",
|
||||||
|
@ -71,6 +70,7 @@
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
|
"react-easy-sort": "^1.6.0",
|
||||||
"react-hook-form": "7.54.2",
|
"react-hook-form": "7.54.2",
|
||||||
"rebuild": "0.1.2",
|
"rebuild": "0.1.2",
|
||||||
"semver": "7.6.3",
|
"semver": "7.6.3",
|
||||||
|
|
Before Width: | Height: | Size: 577 KiB |
BIN
public/screenshots/collage.png
Normal file
After Width: | Height: | Size: 1.1 MiB |
Before Width: | Height: | Size: 447 KiB |
BIN
public/screenshots/resources.png
Normal file
After Width: | Height: | Size: 706 KiB |
Before Width: | Height: | Size: 484 KiB |
Before Width: | Height: | Size: 438 KiB After Width: | Height: | Size: 729 KiB |
Before Width: | Height: | Size: 415 KiB |
|
@ -64,7 +64,8 @@ export enum ActionsEnum {
|
||||||
updateResourceRule = "updateResourceRule",
|
updateResourceRule = "updateResourceRule",
|
||||||
createClient = "createClient",
|
createClient = "createClient",
|
||||||
deleteClient = "deleteClient",
|
deleteClient = "deleteClient",
|
||||||
listClients = "listClients"
|
listClients = "listClients",
|
||||||
|
listOrgDomains = "listOrgDomains",
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|
|
@ -129,18 +129,19 @@ export async function invalidateAllSessions(userId: string): Promise<void> {
|
||||||
|
|
||||||
export function serializeSessionCookie(
|
export function serializeSessionCookie(
|
||||||
token: string,
|
token: string,
|
||||||
isSecure: boolean
|
isSecure: boolean,
|
||||||
|
expiresAt: Date
|
||||||
): string {
|
): string {
|
||||||
if (isSecure) {
|
if (isSecure) {
|
||||||
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
||||||
} else {
|
} else {
|
||||||
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`;
|
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/;`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBlankSessionTokenCookie(isSecure: boolean): string {
|
export function createBlankSessionTokenCookie(isSecure: boolean): string {
|
||||||
if (isSecure) {
|
if (isSecure) {
|
||||||
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
||||||
} else {
|
} else {
|
||||||
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/;`;
|
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/;`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -167,12 +167,19 @@ export function serializeResourceSessionCookie(
|
||||||
cookieName: string,
|
cookieName: string,
|
||||||
domain: string,
|
domain: string,
|
||||||
token: string,
|
token: string,
|
||||||
isHttp: boolean = false
|
isHttp: boolean = false,
|
||||||
|
expiresAt?: Date
|
||||||
): string {
|
): string {
|
||||||
if (!isHttp) {
|
if (!isHttp) {
|
||||||
return `${cookieName}_s=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${"." + domain}`;
|
if (expiresAt === undefined) {
|
||||||
|
return `${cookieName}_s=${token}; HttpOnly; SameSite=Lax; Path=/; Secure; Domain=${"." + domain}`;
|
||||||
|
}
|
||||||
|
return `${cookieName}_s=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${"." + domain}`;
|
||||||
} else {
|
} else {
|
||||||
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${"." + domain}`;
|
if (expiresAt === undefined) {
|
||||||
|
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=${"." + domain}`;
|
||||||
|
}
|
||||||
|
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Domain=${"." + domain}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -182,9 +189,9 @@ export function createBlankResourceSessionTokenCookie(
|
||||||
isHttp: boolean = false
|
isHttp: boolean = false
|
||||||
): string {
|
): string {
|
||||||
if (!isHttp) {
|
if (!isHttp) {
|
||||||
return `${cookieName}_s=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${"." + domain}`;
|
return `${cookieName}_s=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${"." + domain}`;
|
||||||
} else {
|
} else {
|
||||||
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${"." + domain}`;
|
return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Domain=${"." + domain}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,26 @@
|
||||||
import { InferSelectModel } from "drizzle-orm";
|
import { InferSelectModel } from "drizzle-orm";
|
||||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
|
export const domains = sqliteTable("domains", {
|
||||||
|
domainId: text("domainId").primaryKey(),
|
||||||
|
baseDomain: text("baseDomain").notNull(),
|
||||||
|
configManaged: integer("configManaged", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false)
|
||||||
|
});
|
||||||
|
|
||||||
export const orgs = sqliteTable("orgs", {
|
export const orgs = sqliteTable("orgs", {
|
||||||
orgId: text("orgId").primaryKey(),
|
orgId: text("orgId").primaryKey(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull()
|
||||||
domain: text("domain").notNull()
|
});
|
||||||
|
|
||||||
|
export const orgDomains = sqliteTable("orgDomains", {
|
||||||
|
orgId: text("orgId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
|
domainId: text("domainId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => domains.domainId, { onDelete: "cascade" })
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sites = sqliteTable("sites", {
|
export const sites = sqliteTable("sites", {
|
||||||
|
@ -50,6 +66,9 @@ export const resources = sqliteTable("resources", {
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
subdomain: text("subdomain"),
|
subdomain: text("subdomain"),
|
||||||
fullDomain: text("fullDomain"),
|
fullDomain: text("fullDomain"),
|
||||||
|
domainId: text("domainId").references(() => domains.domainId, {
|
||||||
|
onDelete: "set null"
|
||||||
|
}),
|
||||||
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||||
blockAccess: integer("blockAccess", { mode: "boolean" })
|
blockAccess: integer("blockAccess", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
|
@ -490,3 +509,4 @@ export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
||||||
export type Client = InferSelectModel<typeof clients>;
|
export type Client = InferSelectModel<typeof clients>;
|
||||||
export type RoleClient = InferSelectModel<typeof roleClients>;
|
export type RoleClient = InferSelectModel<typeof roleClients>;
|
||||||
export type UserClient = InferSelectModel<typeof userClients>;
|
export type UserClient = InferSelectModel<typeof userClients>;
|
||||||
|
export type Domain = InferSelectModel<typeof domains>;
|
||||||
|
|
|
@ -3,6 +3,7 @@ export * from "@server/emails/sendEmail";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||||
|
|
||||||
function createEmailClient() {
|
function createEmailClient() {
|
||||||
const emailConfig = config.getRawConfig().email;
|
const emailConfig = config.getRawConfig().email;
|
||||||
|
@ -13,7 +14,7 @@ function createEmailClient() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodemailer.createTransport({
|
const settings = {
|
||||||
host: emailConfig.smtp_host,
|
host: emailConfig.smtp_host,
|
||||||
port: emailConfig.smtp_port,
|
port: emailConfig.smtp_port,
|
||||||
secure: emailConfig.smtp_secure || false,
|
secure: emailConfig.smtp_secure || false,
|
||||||
|
@ -21,7 +22,15 @@ function createEmailClient() {
|
||||||
user: emailConfig.smtp_user,
|
user: emailConfig.smtp_user,
|
||||||
pass: emailConfig.smtp_pass
|
pass: emailConfig.smtp_pass
|
||||||
}
|
}
|
||||||
});
|
} as SMTPTransport.Options;
|
||||||
|
|
||||||
|
if (emailConfig.smtp_tls_reject_unauthorized !== undefined) {
|
||||||
|
settings.tls = {
|
||||||
|
rejectUnauthorized: emailConfig.smtp_tls_reject_unauthorized
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodemailer.createTransport(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const emailClient = createEmailClient();
|
export const emailClient = createEmailClient();
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import yaml from "js-yaml";
|
import yaml from "js-yaml";
|
||||||
import path from "path";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import {
|
import {
|
||||||
__DIRNAME,
|
__DIRNAME,
|
||||||
APP_PATH,
|
|
||||||
APP_VERSION,
|
APP_VERSION,
|
||||||
configFilePath1,
|
configFilePath1,
|
||||||
configFilePath2
|
configFilePath2
|
||||||
|
@ -15,12 +13,6 @@ import stoi from "./stoi";
|
||||||
import { start } from "repl";
|
import { start } from "repl";
|
||||||
|
|
||||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||||
const hostnameSchema = z
|
|
||||||
.string()
|
|
||||||
.regex(
|
|
||||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$/
|
|
||||||
)
|
|
||||||
.or(z.literal("localhost"));
|
|
||||||
|
|
||||||
const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
|
const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
|
||||||
return process.env[envVar] ?? valFromYaml;
|
return process.env[envVar] ?? valFromYaml;
|
||||||
|
@ -32,34 +24,42 @@ const configSchema = z.object({
|
||||||
.string()
|
.string()
|
||||||
.url()
|
.url()
|
||||||
.optional()
|
.optional()
|
||||||
.transform(getEnvOrYaml("APP_DASHBOARDURL"))
|
|
||||||
.pipe(z.string().url())
|
.pipe(z.string().url())
|
||||||
.transform((url) => url.toLowerCase()),
|
.transform((url) => url.toLowerCase()),
|
||||||
base_domain: hostnameSchema
|
|
||||||
.optional()
|
|
||||||
.transform(getEnvOrYaml("APP_BASEDOMAIN"))
|
|
||||||
.pipe(hostnameSchema)
|
|
||||||
.transform((url) => url.toLowerCase()),
|
|
||||||
log_level: z.enum(["debug", "info", "warn", "error"]),
|
log_level: z.enum(["debug", "info", "warn", "error"]),
|
||||||
save_logs: z.boolean(),
|
save_logs: z.boolean(),
|
||||||
log_failed_attempts: z.boolean().optional()
|
log_failed_attempts: z.boolean().optional()
|
||||||
}),
|
}),
|
||||||
|
domains: z
|
||||||
|
.record(
|
||||||
|
z.string(),
|
||||||
|
z.object({
|
||||||
|
base_domain: z
|
||||||
|
.string()
|
||||||
|
.nonempty("base_domain must not be empty")
|
||||||
|
.transform((url) => url.toLowerCase()),
|
||||||
|
cert_resolver: z.string().optional(),
|
||||||
|
prefer_wildcard_cert: z.boolean().optional()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(domains) => {
|
||||||
|
const keys = Object.keys(domains);
|
||||||
|
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "At least one domain must be defined"
|
||||||
|
}
|
||||||
|
),
|
||||||
server: z.object({
|
server: z.object({
|
||||||
external_port: portSchema
|
external_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||||
.optional()
|
internal_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||||
.transform(getEnvOrYaml("SERVER_EXTERNALPORT"))
|
next_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||||
.transform(stoi)
|
|
||||||
.pipe(portSchema),
|
|
||||||
internal_port: portSchema
|
|
||||||
.optional()
|
|
||||||
.transform(getEnvOrYaml("SERVER_INTERNALPORT"))
|
|
||||||
.transform(stoi)
|
|
||||||
.pipe(portSchema),
|
|
||||||
next_port: portSchema
|
|
||||||
.optional()
|
|
||||||
.transform(getEnvOrYaml("SERVER_NEXTPORT"))
|
|
||||||
.transform(stoi)
|
|
||||||
.pipe(portSchema),
|
|
||||||
internal_hostname: z.string().transform((url) => url.toLowerCase()),
|
internal_hostname: z.string().transform((url) => url.toLowerCase()),
|
||||||
session_cookie_name: z.string(),
|
session_cookie_name: z.string(),
|
||||||
resource_access_token_param: z.string(),
|
resource_access_token_param: z.string(),
|
||||||
|
@ -89,20 +89,13 @@ const configSchema = z.object({
|
||||||
traefik: z.object({
|
traefik: z.object({
|
||||||
http_entrypoint: z.string(),
|
http_entrypoint: z.string(),
|
||||||
https_entrypoint: z.string().optional(),
|
https_entrypoint: z.string().optional(),
|
||||||
cert_resolver: z.string().optional(),
|
|
||||||
prefer_wildcard_cert: z.boolean().optional(),
|
|
||||||
additional_middlewares: z.array(z.string()).optional()
|
additional_middlewares: z.array(z.string()).optional()
|
||||||
}),
|
}),
|
||||||
gerbil: z.object({
|
gerbil: z.object({
|
||||||
start_port: portSchema
|
start_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||||
.optional()
|
|
||||||
.transform(getEnvOrYaml("GERBIL_STARTPORT"))
|
|
||||||
.transform(stoi)
|
|
||||||
.pipe(portSchema),
|
|
||||||
base_endpoint: z
|
base_endpoint: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform(getEnvOrYaml("GERBIL_BASEENDPOINT"))
|
|
||||||
.pipe(z.string())
|
.pipe(z.string())
|
||||||
.transform((url) => url.toLowerCase()),
|
.transform((url) => url.toLowerCase()),
|
||||||
use_subdomain: z.boolean(),
|
use_subdomain: z.boolean(),
|
||||||
|
@ -135,6 +128,7 @@ const configSchema = z.object({
|
||||||
smtp_user: z.string().optional(),
|
smtp_user: z.string().optional(),
|
||||||
smtp_pass: z.string().optional(),
|
smtp_pass: z.string().optional(),
|
||||||
smtp_secure: z.boolean().optional(),
|
smtp_secure: z.boolean().optional(),
|
||||||
|
smtp_tls_reject_unauthorized: z.boolean().optional(),
|
||||||
no_reply: z.string().email().optional()
|
no_reply: z.string().email().optional()
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
@ -159,7 +153,8 @@ const configSchema = z.object({
|
||||||
disable_signup_without_invite: z.boolean().optional(),
|
disable_signup_without_invite: z.boolean().optional(),
|
||||||
disable_user_create_org: z.boolean().optional(),
|
disable_user_create_org: z.boolean().optional(),
|
||||||
allow_raw_resources: z.boolean().optional(),
|
allow_raw_resources: z.boolean().optional(),
|
||||||
allow_base_domain_resources: z.boolean().optional()
|
allow_base_domain_resources: z.boolean().optional(),
|
||||||
|
allow_local_sites: z.boolean().optional()
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
});
|
});
|
||||||
|
@ -169,14 +164,8 @@ export class Config {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.loadConfig();
|
this.loadConfig();
|
||||||
|
|
||||||
if (process.env.GENERATE_TRAEFIK_CONFIG === "true") {
|
|
||||||
this.createTraefikConfig();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public loadEnvironment() {}
|
|
||||||
|
|
||||||
public loadConfig() {
|
public loadConfig() {
|
||||||
const loadConfig = (configPath: string) => {
|
const loadConfig = (configPath: string) => {
|
||||||
try {
|
try {
|
||||||
|
@ -199,45 +188,15 @@ export class Config {
|
||||||
} else if (fs.existsSync(configFilePath2)) {
|
} else if (fs.existsSync(configFilePath2)) {
|
||||||
environment = loadConfig(configFilePath2);
|
environment = loadConfig(configFilePath2);
|
||||||
}
|
}
|
||||||
if (!environment) {
|
|
||||||
const exampleConfigPath = path.join(
|
if (process.env.APP_BASE_DOMAIN) {
|
||||||
__DIRNAME,
|
console.log("You're using deprecated environment variables. Transition to the configuration file. https://docs.fossorial.io/");
|
||||||
"config.example.yml"
|
|
||||||
);
|
|
||||||
if (fs.existsSync(exampleConfigPath)) {
|
|
||||||
try {
|
|
||||||
const exampleConfigContent = fs.readFileSync(
|
|
||||||
exampleConfigPath,
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
configFilePath1,
|
|
||||||
exampleConfigContent,
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
environment = loadConfig(configFilePath1);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(
|
|
||||||
"See the docs for information about what to include in the configuration file: https://docs.fossorial.io/Pangolin/Configuration/config"
|
|
||||||
);
|
|
||||||
if (error instanceof Error) {
|
|
||||||
throw new Error(
|
|
||||||
`Error creating configuration file from example: ${
|
|
||||||
error.message
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"No configuration file found and no example configuration available"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!environment) {
|
if (!environment) {
|
||||||
throw new Error("No configuration file found");
|
throw new Error(
|
||||||
|
"No configuration file found. Please create one. https://docs.fossorial.io/"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedConfig = configSchema.safeParse(environment);
|
const parsedConfig = configSchema.safeParse(environment);
|
||||||
|
@ -290,80 +249,14 @@ export class Config {
|
||||||
return this.rawConfig;
|
return this.rawConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getBaseDomain(): string {
|
|
||||||
return this.rawConfig.app.base_domain;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getNoReplyEmail(): string | undefined {
|
public getNoReplyEmail(): string | undefined {
|
||||||
return (
|
return (
|
||||||
this.rawConfig.email?.no_reply || this.rawConfig.email?.smtp_user
|
this.rawConfig.email?.no_reply || this.rawConfig.email?.smtp_user
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private createTraefikConfig() {
|
public getDomain(domainId: string) {
|
||||||
try {
|
return this.rawConfig.domains[domainId];
|
||||||
// check if traefik_config.yml and dynamic_config.yml exists in APP_PATH/traefik
|
|
||||||
const defaultTraefikConfigPath = path.join(
|
|
||||||
__DIRNAME,
|
|
||||||
"traefik_config.example.yml"
|
|
||||||
);
|
|
||||||
const defaultDynamicConfigPath = path.join(
|
|
||||||
__DIRNAME,
|
|
||||||
"dynamic_config.example.yml"
|
|
||||||
);
|
|
||||||
|
|
||||||
const traefikPath = path.join(APP_PATH, "traefik");
|
|
||||||
if (!fs.existsSync(traefikPath)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// load default configs
|
|
||||||
let traefikConfig = fs.readFileSync(
|
|
||||||
defaultTraefikConfigPath,
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
let dynamicConfig = fs.readFileSync(
|
|
||||||
defaultDynamicConfigPath,
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
|
|
||||||
traefikConfig = traefikConfig
|
|
||||||
.split("{{.LetsEncryptEmail}}")
|
|
||||||
.join(this.rawConfig.users.server_admin.email);
|
|
||||||
traefikConfig = traefikConfig
|
|
||||||
.split("{{.INTERNAL_PORT}}")
|
|
||||||
.join(this.rawConfig.server.internal_port.toString());
|
|
||||||
|
|
||||||
dynamicConfig = dynamicConfig
|
|
||||||
.split("{{.DashboardDomain}}")
|
|
||||||
.join(new URL(this.rawConfig.app.dashboard_url).hostname);
|
|
||||||
dynamicConfig = dynamicConfig
|
|
||||||
.split("{{.NEXT_PORT}}")
|
|
||||||
.join(this.rawConfig.server.next_port.toString());
|
|
||||||
dynamicConfig = dynamicConfig
|
|
||||||
.split("{{.EXTERNAL_PORT}}")
|
|
||||||
.join(this.rawConfig.server.external_port.toString());
|
|
||||||
|
|
||||||
// write thiese to the traefik directory
|
|
||||||
const traefikConfigPath = path.join(
|
|
||||||
traefikPath,
|
|
||||||
"traefik_config.yml"
|
|
||||||
);
|
|
||||||
const dynamicConfigPath = path.join(
|
|
||||||
traefikPath,
|
|
||||||
"dynamic_config.yml"
|
|
||||||
);
|
|
||||||
|
|
||||||
fs.writeFileSync(traefikConfigPath, traefikConfig, "utf8");
|
|
||||||
fs.writeFileSync(dynamicConfigPath, dynamicConfig, "utf8");
|
|
||||||
|
|
||||||
console.log("Traefik configuration files created");
|
|
||||||
} catch (e) {
|
|
||||||
console.log(
|
|
||||||
"Failed to generate the Traefik configuration files. Please create them manually."
|
|
||||||
);
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@ import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
// This is a placeholder value replaced by the build process
|
// This is a placeholder value replaced by the build process
|
||||||
export const APP_VERSION = "1.0.0-beta.14";
|
export const APP_VERSION = "1.0.0";
|
||||||
|
|
||||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||||
export const __DIRNAME = path.dirname(__FILENAME);
|
export const __DIRNAME = path.dirname(__FILENAME);
|
||||||
|
|
|
@ -1,61 +1,5 @@
|
||||||
import { cidrToRange, findNextAvailableCidr } from "./ip";
|
import { cidrToRange, findNextAvailableCidr } from "./ip";
|
||||||
|
import { assertEquals } from "@test/assert";
|
||||||
/**
|
|
||||||
* Compares two objects for deep equality
|
|
||||||
* @param actual The actual value to test
|
|
||||||
* @param expected The expected value to compare against
|
|
||||||
* @param message The message to display if assertion fails
|
|
||||||
* @throws Error if objects are not equal
|
|
||||||
*/
|
|
||||||
export function assertEqualsObj<T>(actual: T, expected: T, message: string): void {
|
|
||||||
const actualStr = JSON.stringify(actual);
|
|
||||||
const expectedStr = JSON.stringify(expected);
|
|
||||||
if (actualStr !== expectedStr) {
|
|
||||||
throw new Error(`${message}\nExpected: ${expectedStr}\nActual: ${actualStr}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compares two primitive values for equality
|
|
||||||
* @param actual The actual value to test
|
|
||||||
* @param expected The expected value to compare against
|
|
||||||
* @param message The message to display if assertion fails
|
|
||||||
* @throws Error if values are not equal
|
|
||||||
*/
|
|
||||||
export function assertEquals<T>(actual: T, expected: T, message: string): void {
|
|
||||||
if (actual !== expected) {
|
|
||||||
throw new Error(`${message}\nExpected: ${expected}\nActual: ${actual}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests if a function throws an expected error
|
|
||||||
* @param fn The function to test
|
|
||||||
* @param expectedError The expected error message or part of it
|
|
||||||
* @param message The message to display if assertion fails
|
|
||||||
* @throws Error if function doesn't throw or throws unexpected error
|
|
||||||
*/
|
|
||||||
export function assertThrows(
|
|
||||||
fn: () => void,
|
|
||||||
expectedError: string,
|
|
||||||
message: string
|
|
||||||
): void {
|
|
||||||
try {
|
|
||||||
fn();
|
|
||||||
throw new Error(`${message}: Expected to throw "${expectedError}"`);
|
|
||||||
} catch (error) {
|
|
||||||
if (!(error instanceof Error)) {
|
|
||||||
throw new Error(`${message}\nUnexpected error type: ${typeof error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!error.message.includes(expectedError)) {
|
|
||||||
throw new Error(
|
|
||||||
`${message}\nExpected error: ${expectedError}\nActual error: ${error.message}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Test cases
|
// Test cases
|
||||||
function testFindNextAvailableCidr() {
|
function testFindNextAvailableCidr() {
|
||||||
|
|
71
server/lib/validators.test.ts
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
import { isValidUrlGlobPattern } from "./validators";
|
||||||
|
import { assertEquals } from "@test/assert";
|
||||||
|
|
||||||
|
function runTests() {
|
||||||
|
console.log('Running URL pattern validation tests...');
|
||||||
|
|
||||||
|
// Test valid patterns
|
||||||
|
assertEquals(isValidUrlGlobPattern('simple'), true, 'Simple path segment should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('simple/path'), true, 'Simple path with slash should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('/leading/slash'), true, 'Path with leading slash should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('path/'), true, 'Path with trailing slash should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('path/*'), true, 'Path with wildcard segment should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('*'), true, 'Single wildcard should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('*/subpath'), true, 'Wildcard with subpath should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('path/*/more'), true, 'Path with wildcard in the middle should be valid');
|
||||||
|
|
||||||
|
// Test with special characters
|
||||||
|
assertEquals(isValidUrlGlobPattern('path-with-dash'), true, 'Path with dash should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('path_with_underscore'), true, 'Path with underscore should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('path.with.dots'), true, 'Path with dots should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('path~with~tilde'), true, 'Path with tilde should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('path!with!exclamation'), true, 'Path with exclamation should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('path$with$dollar'), true, 'Path with dollar should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('path&with&ersand'), true, 'Path with ampersand should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern("path'with'quote"), true, "Path with quote should be valid");
|
||||||
|
assertEquals(isValidUrlGlobPattern('path(with)parentheses'), true, 'Path with parentheses should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('path+with+plus'), true, 'Path with plus should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('path,with,comma'), true, 'Path with comma should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('path;with;semicolon'), true, 'Path with semicolon should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('path=with=equals'), true, 'Path with equals should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('path:with:colon'), true, 'Path with colon should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('path@with@at'), true, 'Path with at should be valid');
|
||||||
|
|
||||||
|
// Test with percent encoding
|
||||||
|
assertEquals(isValidUrlGlobPattern('path%20with%20spaces'), true, 'Path with percent-encoded spaces should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('path%2Fwith%2Fencoded%2Fslashes'), true, 'Path with percent-encoded slashes should be valid');
|
||||||
|
|
||||||
|
// Test with wildcards in segments (the fixed functionality)
|
||||||
|
assertEquals(isValidUrlGlobPattern('padbootstrap*'), true, 'Path with wildcard at the end of segment should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('pad*bootstrap'), true, 'Path with wildcard in the middle of segment should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('*bootstrap'), true, 'Path with wildcard at the start of segment should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('multiple*wildcards*in*segment'), true, 'Path with multiple wildcards in segment should be valid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('wild*/cards/in*/different/seg*ments'), true, 'Path with wildcards in different segments should be valid');
|
||||||
|
|
||||||
|
// Test invalid patterns
|
||||||
|
assertEquals(isValidUrlGlobPattern(''), false, 'Empty string should be invalid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('//double/slash'), false, 'Path with double slash should be invalid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('path//end'), false, 'Path with double slash in the middle should be invalid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('invalid<char>'), false, 'Path with invalid characters should be invalid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('invalid|char'), false, 'Path with invalid pipe character should be invalid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('invalid"char'), false, 'Path with invalid quote character should be invalid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('invalid`char'), false, 'Path with invalid backtick character should be invalid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('invalid^char'), false, 'Path with invalid caret character should be invalid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('invalid\\char'), false, 'Path with invalid backslash character should be invalid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('invalid[char]'), false, 'Path with invalid square brackets should be invalid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('invalid{char}'), false, 'Path with invalid curly braces should be invalid');
|
||||||
|
|
||||||
|
// Test invalid percent encoding
|
||||||
|
assertEquals(isValidUrlGlobPattern('invalid%2'), false, 'Path with incomplete percent encoding should be invalid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('invalid%GZ'), false, 'Path with invalid hex in percent encoding should be invalid');
|
||||||
|
assertEquals(isValidUrlGlobPattern('invalid%'), false, 'Path with isolated percent sign should be invalid');
|
||||||
|
|
||||||
|
console.log('All tests passed!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run all tests
|
||||||
|
try {
|
||||||
|
runTests();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Test failed:', error);
|
||||||
|
}
|
|
@ -29,11 +29,6 @@ export function isValidUrlGlobPattern(pattern: string): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If segment contains *, it must be exactly *
|
|
||||||
if (segment.includes("*") && segment !== "*") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check each character in the segment
|
// Check each character in the segment
|
||||||
for (let j = 0; j < segment.length; j++) {
|
for (let j = 0; j < segment.length; j++) {
|
||||||
const char = segment[j];
|
const char = segment[j];
|
||||||
|
@ -56,7 +51,7 @@ export function isValidUrlGlobPattern(pattern: string): boolean {
|
||||||
// - unreserved (A-Z a-z 0-9 - . _ ~)
|
// - unreserved (A-Z a-z 0-9 - . _ ~)
|
||||||
// - sub-delims (! $ & ' ( ) * + , ; =)
|
// - sub-delims (! $ & ' ( ) * + , ; =)
|
||||||
// - @ : for compatibility with some systems
|
// - @ : for compatibility with some systems
|
||||||
if (!/^[A-Za-z0-9\-._~!$&'()*+,;=@:]$/.test(char)) {
|
if (!/^[A-Za-z0-9\-._~!$&'()*+,;#=@:]$/.test(char)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,8 @@ import {
|
||||||
resources,
|
resources,
|
||||||
userResources,
|
userResources,
|
||||||
roleResources,
|
roleResources,
|
||||||
resourceAccessToken
|
resourceAccessToken,
|
||||||
|
sites
|
||||||
} from "@server/db/schema";
|
} from "@server/db/schema";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
@ -59,7 +60,8 @@ function queryAccessTokens(
|
||||||
title: resourceAccessToken.title,
|
title: resourceAccessToken.title,
|
||||||
description: resourceAccessToken.description,
|
description: resourceAccessToken.description,
|
||||||
createdAt: resourceAccessToken.createdAt,
|
createdAt: resourceAccessToken.createdAt,
|
||||||
resourceName: resources.name
|
resourceName: resources.name,
|
||||||
|
siteName: sites.name
|
||||||
};
|
};
|
||||||
|
|
||||||
if (orgId) {
|
if (orgId) {
|
||||||
|
@ -70,6 +72,10 @@ function queryAccessTokens(
|
||||||
resources,
|
resources,
|
||||||
eq(resourceAccessToken.resourceId, resources.resourceId)
|
eq(resourceAccessToken.resourceId, resources.resourceId)
|
||||||
)
|
)
|
||||||
|
.leftJoin(
|
||||||
|
sites,
|
||||||
|
eq(resources.resourceId, sites.siteId)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(
|
inArray(
|
||||||
|
@ -91,6 +97,10 @@ function queryAccessTokens(
|
||||||
resources,
|
resources,
|
||||||
eq(resourceAccessToken.resourceId, resources.resourceId)
|
eq(resourceAccessToken.resourceId, resources.resourceId)
|
||||||
)
|
)
|
||||||
|
.leftJoin(
|
||||||
|
sites,
|
||||||
|
eq(resources.resourceId, sites.siteId)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(
|
inArray(
|
||||||
|
|
|
@ -78,7 +78,7 @@ export async function login(
|
||||||
}
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.UNAUTHORIZED,
|
||||||
"Username or password is incorrect"
|
"Username or password is incorrect"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -98,7 +98,7 @@ export async function login(
|
||||||
}
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.UNAUTHORIZED,
|
||||||
"Username or password is incorrect"
|
"Username or password is incorrect"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -129,7 +129,7 @@ export async function login(
|
||||||
}
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.UNAUTHORIZED,
|
||||||
"The two-factor code you entered is incorrect"
|
"The two-factor code you entered is incorrect"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -137,9 +137,13 @@ export async function login(
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = generateSessionToken();
|
const token = generateSessionToken();
|
||||||
await createSession(token, existingUser.userId);
|
const sess = await createSession(token, existingUser.userId);
|
||||||
const isSecure = req.protocol === "https";
|
const isSecure = req.protocol === "https";
|
||||||
const cookie = serializeSessionCookie(token, isSecure);
|
const cookie = serializeSessionCookie(
|
||||||
|
token,
|
||||||
|
isSecure,
|
||||||
|
new Date(sess.expiresAt)
|
||||||
|
);
|
||||||
|
|
||||||
res.appendHeader("Set-Cookie", cookie);
|
res.appendHeader("Set-Cookie", cookie);
|
||||||
|
|
||||||
|
|
|
@ -170,9 +170,13 @@ export async function signup(
|
||||||
// });
|
// });
|
||||||
|
|
||||||
const token = generateSessionToken();
|
const token = generateSessionToken();
|
||||||
await createSession(token, userId);
|
const sess = await createSession(token, userId);
|
||||||
const isSecure = req.protocol === "https";
|
const isSecure = req.protocol === "https";
|
||||||
const cookie = serializeSessionCookie(token, isSecure);
|
const cookie = serializeSessionCookie(
|
||||||
|
token,
|
||||||
|
isSecure,
|
||||||
|
new Date(sess.expiresAt)
|
||||||
|
);
|
||||||
res.appendHeader("Set-Cookie", cookie);
|
res.appendHeader("Set-Cookie", cookie);
|
||||||
|
|
||||||
if (config.getRawConfig().flags?.require_email_verification) {
|
if (config.getRawConfig().flags?.require_email_verification) {
|
||||||
|
|
|
@ -102,6 +102,8 @@ export async function exchangeSession(
|
||||||
|
|
||||||
const token = generateSessionToken();
|
const token = generateSessionToken();
|
||||||
|
|
||||||
|
let expiresAt: number | null = null;
|
||||||
|
|
||||||
if (requestSession.userSessionId) {
|
if (requestSession.userSessionId) {
|
||||||
const [res] = await db
|
const [res] = await db
|
||||||
.select()
|
.select()
|
||||||
|
@ -118,6 +120,7 @@ export async function exchangeSession(
|
||||||
expiresAt: res.expiresAt,
|
expiresAt: res.expiresAt,
|
||||||
sessionLength: SESSION_COOKIE_EXPIRES
|
sessionLength: SESSION_COOKIE_EXPIRES
|
||||||
});
|
});
|
||||||
|
expiresAt = res.expiresAt;
|
||||||
}
|
}
|
||||||
} else if (requestSession.accessTokenId) {
|
} else if (requestSession.accessTokenId) {
|
||||||
const [res] = await db
|
const [res] = await db
|
||||||
|
@ -140,8 +143,12 @@ export async function exchangeSession(
|
||||||
expiresAt: res.expiresAt,
|
expiresAt: res.expiresAt,
|
||||||
sessionLength: res.sessionLength
|
sessionLength: res.sessionLength
|
||||||
});
|
});
|
||||||
|
expiresAt = res.expiresAt;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
const expires = new Date(
|
||||||
|
Date.now() + SESSION_COOKIE_EXPIRES
|
||||||
|
).getTime();
|
||||||
await createResourceSession({
|
await createResourceSession({
|
||||||
token,
|
token,
|
||||||
resourceId: resource.resourceId,
|
resourceId: resource.resourceId,
|
||||||
|
@ -152,11 +159,10 @@ export async function exchangeSession(
|
||||||
whitelistId: requestSession.whitelistId,
|
whitelistId: requestSession.whitelistId,
|
||||||
accessTokenId: requestSession.accessTokenId,
|
accessTokenId: requestSession.accessTokenId,
|
||||||
doNotExtend: false,
|
doNotExtend: false,
|
||||||
expiresAt: new Date(
|
expiresAt: expires,
|
||||||
Date.now() + SESSION_COOKIE_EXPIRES
|
|
||||||
).getTime(),
|
|
||||||
sessionLength: RESOURCE_SESSION_COOKIE_EXPIRES
|
sessionLength: RESOURCE_SESSION_COOKIE_EXPIRES
|
||||||
});
|
});
|
||||||
|
expiresAt = expires;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
|
const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
|
||||||
|
@ -164,7 +170,8 @@ export async function exchangeSession(
|
||||||
cookieName,
|
cookieName,
|
||||||
resource.fullDomain!,
|
resource.fullDomain!,
|
||||||
token,
|
token,
|
||||||
!resource.ssl
|
!resource.ssl,
|
||||||
|
expiresAt ? new Date(expiresAt) : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.debug(JSON.stringify("Exchange cookie: " + cookie));
|
logger.debug(JSON.stringify("Exchange cookie: " + cookie));
|
||||||
|
|
67
server/routers/badger/verifySession.test.ts
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import { isPathAllowed } from './verifySession';
|
||||||
|
import { assertEquals } from '@test/assert';
|
||||||
|
|
||||||
|
function runTests() {
|
||||||
|
console.log('Running path matching tests...');
|
||||||
|
|
||||||
|
// Test exact matching
|
||||||
|
assertEquals(isPathAllowed('foo', 'foo'), true, 'Exact match should be allowed');
|
||||||
|
assertEquals(isPathAllowed('foo', 'bar'), false, 'Different segments should not match');
|
||||||
|
assertEquals(isPathAllowed('foo/bar', 'foo/bar'), true, 'Exact multi-segment match should be allowed');
|
||||||
|
assertEquals(isPathAllowed('foo/bar', 'foo/baz'), false, 'Partial multi-segment match should not be allowed');
|
||||||
|
|
||||||
|
// Test with leading and trailing slashes
|
||||||
|
assertEquals(isPathAllowed('/foo', 'foo'), true, 'Pattern with leading slash should match');
|
||||||
|
assertEquals(isPathAllowed('foo/', 'foo'), true, 'Pattern with trailing slash should match');
|
||||||
|
assertEquals(isPathAllowed('/foo/', 'foo'), true, 'Pattern with both leading and trailing slashes should match');
|
||||||
|
assertEquals(isPathAllowed('foo', '/foo/'), true, 'Path with leading and trailing slashes should match');
|
||||||
|
|
||||||
|
// Test simple wildcard matching
|
||||||
|
assertEquals(isPathAllowed('*', 'foo'), true, 'Single wildcard should match any single segment');
|
||||||
|
assertEquals(isPathAllowed('*', 'foo/bar'), true, 'Single wildcard should match multiple segments');
|
||||||
|
assertEquals(isPathAllowed('*/bar', 'foo/bar'), true, 'Wildcard prefix should match');
|
||||||
|
assertEquals(isPathAllowed('foo/*', 'foo/bar'), true, 'Wildcard suffix should match');
|
||||||
|
assertEquals(isPathAllowed('foo/*/baz', 'foo/bar/baz'), true, 'Wildcard in middle should match');
|
||||||
|
|
||||||
|
// Test multiple wildcards
|
||||||
|
assertEquals(isPathAllowed('*/*', 'foo/bar'), true, 'Multiple wildcards should match corresponding segments');
|
||||||
|
assertEquals(isPathAllowed('*/*/*', 'foo/bar/baz'), true, 'Three wildcards should match three segments');
|
||||||
|
assertEquals(isPathAllowed('foo/*/*', 'foo/bar/baz'), true, 'Specific prefix with wildcards should match');
|
||||||
|
assertEquals(isPathAllowed('*/*/baz', 'foo/bar/baz'), true, 'Wildcards with specific suffix should match');
|
||||||
|
|
||||||
|
// Test wildcard consumption behavior
|
||||||
|
assertEquals(isPathAllowed('*', ''), true, 'Wildcard should optionally consume segments');
|
||||||
|
assertEquals(isPathAllowed('foo/*', 'foo'), true, 'Trailing wildcard should be optional');
|
||||||
|
assertEquals(isPathAllowed('*/*', 'foo'), true, 'Multiple wildcards can match fewer segments');
|
||||||
|
assertEquals(isPathAllowed('*/*/*', 'foo/bar'), true, 'Extra wildcards can be skipped');
|
||||||
|
|
||||||
|
// Test complex nested paths
|
||||||
|
assertEquals(isPathAllowed('api/*/users', 'api/v1/users'), true, 'API versioning pattern should match');
|
||||||
|
assertEquals(isPathAllowed('api/*/users/*', 'api/v1/users/123'), true, 'API resource pattern should match');
|
||||||
|
assertEquals(isPathAllowed('api/*/users/*/profile', 'api/v1/users/123/profile'), true, 'Nested API pattern should match');
|
||||||
|
|
||||||
|
// Test for the requested padbootstrap* pattern
|
||||||
|
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap'), true, 'padbootstrap* should match padbootstrap');
|
||||||
|
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrapv1'), true, 'padbootstrap* should match padbootstrapv1');
|
||||||
|
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap/files'), false, 'padbootstrap* should not match padbootstrap/files');
|
||||||
|
assertEquals(isPathAllowed('padbootstrap*/*', 'padbootstrap/files'), true, 'padbootstrap*/* should match padbootstrap/files');
|
||||||
|
assertEquals(isPathAllowed('padbootstrap*/files', 'padbootstrapv1/files'), true, 'padbootstrap*/files should not match padbootstrapv1/files (wildcard is segment-based, not partial)');
|
||||||
|
|
||||||
|
// Test wildcard edge cases
|
||||||
|
assertEquals(isPathAllowed('*/*/*/*/*/*', 'a/b'), true, 'Many wildcards can match few segments');
|
||||||
|
assertEquals(isPathAllowed('a/*/b/*/c', 'a/anything/b/something/c'), true, 'Multiple wildcards in pattern should match corresponding segments');
|
||||||
|
|
||||||
|
// Test patterns with partial segment matches
|
||||||
|
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap-123'), true, 'Wildcards in isPathAllowed should be segment-based, not character-based');
|
||||||
|
assertEquals(isPathAllowed('test*', 'testuser'), true, 'Asterisk as part of segment name is treated as a literal, not a wildcard');
|
||||||
|
assertEquals(isPathAllowed('my*app', 'myapp'), true, 'Asterisk in middle of segment name is treated as a literal, not a wildcard');
|
||||||
|
|
||||||
|
console.log('All tests passed!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run all tests
|
||||||
|
try {
|
||||||
|
runTests();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Test failed:', error);
|
||||||
|
}
|
|
@ -90,7 +90,15 @@ export async function verifyResourceSession(
|
||||||
|
|
||||||
const clientIp = requestIp?.split(":")[0];
|
const clientIp = requestIp?.split(":")[0];
|
||||||
|
|
||||||
const resourceCacheKey = `resource:${host}`;
|
let cleanHost = host;
|
||||||
|
// if the host ends with :443 or :80 remove it
|
||||||
|
if (cleanHost.endsWith(":443")) {
|
||||||
|
cleanHost = cleanHost.slice(0, -4);
|
||||||
|
} else if (cleanHost.endsWith(":80")) {
|
||||||
|
cleanHost = cleanHost.slice(0, -3);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceCacheKey = `resource:${cleanHost}`;
|
||||||
let resourceData:
|
let resourceData:
|
||||||
| {
|
| {
|
||||||
resource: Resource | null;
|
resource: Resource | null;
|
||||||
|
@ -111,11 +119,11 @@ export async function verifyResourceSession(
|
||||||
resourcePassword,
|
resourcePassword,
|
||||||
eq(resourcePassword.resourceId, resources.resourceId)
|
eq(resourcePassword.resourceId, resources.resourceId)
|
||||||
)
|
)
|
||||||
.where(eq(resources.fullDomain, host))
|
.where(eq(resources.fullDomain, cleanHost))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
logger.debug("Resource not found", host);
|
logger.debug("Resource not found", cleanHost);
|
||||||
return notAllowed(res);
|
return notAllowed(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -131,7 +139,7 @@ export async function verifyResourceSession(
|
||||||
const { resource, pincode, password } = resourceData;
|
const { resource, pincode, password } = resourceData;
|
||||||
|
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
logger.debug("Resource not found", host);
|
logger.debug("Resource not found", cleanHost);
|
||||||
return notAllowed(res);
|
return notAllowed(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -142,16 +150,6 @@ export async function verifyResourceSession(
|
||||||
return notAllowed(res);
|
return notAllowed(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
!resource.sso &&
|
|
||||||
!pincode &&
|
|
||||||
!password &&
|
|
||||||
!resource.emailWhitelistEnabled
|
|
||||||
) {
|
|
||||||
logger.debug("Resource allowed because no auth");
|
|
||||||
return allowed(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
// check the rules
|
// check the rules
|
||||||
if (resource.applyRules) {
|
if (resource.applyRules) {
|
||||||
const action = await checkRules(
|
const action = await checkRules(
|
||||||
|
@ -171,6 +169,16 @@ export async function verifyResourceSession(
|
||||||
// otherwise its undefined and we pass
|
// otherwise its undefined and we pass
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!resource.sso &&
|
||||||
|
!pincode &&
|
||||||
|
!password &&
|
||||||
|
!resource.emailWhitelistEnabled
|
||||||
|
) {
|
||||||
|
logger.debug("Resource allowed because no auth");
|
||||||
|
return allowed(res);
|
||||||
|
}
|
||||||
|
|
||||||
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(
|
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(
|
||||||
resource.resourceId
|
resource.resourceId
|
||||||
)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
||||||
|
@ -376,7 +384,7 @@ async function createAccessTokenSession(
|
||||||
tokenItem: ResourceAccessToken
|
tokenItem: ResourceAccessToken
|
||||||
) {
|
) {
|
||||||
const token = generateSessionToken();
|
const token = generateSessionToken();
|
||||||
await createResourceSession({
|
const sess = await createResourceSession({
|
||||||
resourceId: resource.resourceId,
|
resourceId: resource.resourceId,
|
||||||
token,
|
token,
|
||||||
accessTokenId: tokenItem.accessTokenId,
|
accessTokenId: tokenItem.accessTokenId,
|
||||||
|
@ -389,7 +397,8 @@ async function createAccessTokenSession(
|
||||||
cookieName,
|
cookieName,
|
||||||
resource.fullDomain!,
|
resource.fullDomain!,
|
||||||
token,
|
token,
|
||||||
!resource.ssl
|
!resource.ssl,
|
||||||
|
new Date(sess.expiresAt)
|
||||||
);
|
);
|
||||||
res.appendHeader("Set-Cookie", cookie);
|
res.appendHeader("Set-Cookie", cookie);
|
||||||
logger.debug("Access token is valid, creating new session");
|
logger.debug("Access token is valid, creating new session");
|
||||||
|
@ -525,7 +534,7 @@ async function checkRules(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPathAllowed(pattern: string, path: string): boolean {
|
export function isPathAllowed(pattern: string, path: string): boolean {
|
||||||
logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`);
|
logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`);
|
||||||
|
|
||||||
// Normalize and split paths into segments
|
// Normalize and split paths into segments
|
||||||
|
@ -566,7 +575,7 @@ function isPathAllowed(pattern: string, path: string): boolean {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For wildcards, try consuming different numbers of path segments
|
// For full segment wildcards, try consuming different numbers of path segments
|
||||||
if (currentPatternPart === "*") {
|
if (currentPatternPart === "*") {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`${indent}Found wildcard at pattern index ${patternIndex}`
|
`${indent}Found wildcard at pattern index ${patternIndex}`
|
||||||
|
@ -598,6 +607,32 @@ function isPathAllowed(pattern: string, path: string): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix")
|
||||||
|
if (currentPatternPart.includes("*")) {
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Found in-segment wildcard in "${currentPatternPart}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert the pattern segment to a regex pattern
|
||||||
|
const regexPattern = currentPatternPart
|
||||||
|
.replace(/\*/g, ".*") // Replace * with .* for regex wildcard
|
||||||
|
.replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
|
||||||
|
|
||||||
|
const regex = new RegExp(`^${regexPattern}$`);
|
||||||
|
|
||||||
|
if (regex.test(currentPathPart)) {
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Segment with wildcard matches: "${currentPatternPart}" matches "${currentPathPart}"`
|
||||||
|
);
|
||||||
|
return matchSegments(patternIndex + 1, pathIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Segment with wildcard mismatch: "${currentPatternPart}" doesn't match "${currentPathPart}"`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// For regular segments, they must match exactly
|
// For regular segments, they must match exactly
|
||||||
if (currentPatternPart !== currentPathPart) {
|
if (currentPatternPart !== currentPathPart) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
@ -616,4 +651,4 @@ function isPathAllowed(pattern: string, path: string): boolean {
|
||||||
const result = matchSegments(0, 0);
|
const result = matchSegments(0, 0);
|
||||||
logger.debug(`Final result: ${result}`);
|
logger.debug(`Final result: ${result}`);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
1
server/routers/domain/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from "./listDomains";
|
109
server/routers/domain/listDomains.ts
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { domains, orgDomains, users } from "@server/db/schema";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const listDomainsParamsSchema = z
|
||||||
|
.object({
|
||||||
|
orgId: z.string()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const listDomainsSchema = z
|
||||||
|
.object({
|
||||||
|
limit: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("1000")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().nonnegative()),
|
||||||
|
offset: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("0")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().nonnegative())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
async function queryDomains(orgId: string, limit: number, offset: number) {
|
||||||
|
const res = await db
|
||||||
|
.select({
|
||||||
|
domainId: domains.domainId,
|
||||||
|
baseDomain: domains.baseDomain
|
||||||
|
})
|
||||||
|
.from(orgDomains)
|
||||||
|
.where(eq(orgDomains.orgId, orgId))
|
||||||
|
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListDomainsResponse = {
|
||||||
|
domains: NonNullable<Awaited<ReturnType<typeof queryDomains>>>;
|
||||||
|
pagination: { total: number; limit: number; offset: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listDomains(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedQuery = listDomainsSchema.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { limit, offset } = parsedQuery.data;
|
||||||
|
|
||||||
|
const parsedParams = listDomainsParamsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
|
const domains = await queryDomains(orgId.toString(), limit, offset);
|
||||||
|
|
||||||
|
const [{ count }] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(users);
|
||||||
|
|
||||||
|
return response<ListDomainsResponse>(res, {
|
||||||
|
data: {
|
||||||
|
domains,
|
||||||
|
pagination: {
|
||||||
|
total: count,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Users retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ import config from "@server/lib/config";
|
||||||
import * as site from "./site";
|
import * as site from "./site";
|
||||||
import * as org from "./org";
|
import * as org from "./org";
|
||||||
import * as resource from "./resource";
|
import * as resource from "./resource";
|
||||||
|
import * as domain from "./domain";
|
||||||
import * as target from "./target";
|
import * as target from "./target";
|
||||||
import * as user from "./user";
|
import * as user from "./user";
|
||||||
import * as auth from "./auth";
|
import * as auth from "./auth";
|
||||||
|
@ -165,6 +166,13 @@ authenticated.get(
|
||||||
resource.listResources
|
resource.listResources
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/domains",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listOrgDomains),
|
||||||
|
domain.listDomains
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId/create-invite",
|
"/org/:orgId/create-invite",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
|
|
@ -8,7 +8,7 @@ import {
|
||||||
Target,
|
Target,
|
||||||
targets
|
targets
|
||||||
} from "@server/db/schema";
|
} from "@server/db/schema";
|
||||||
import { eq, and, sql } from "drizzle-orm";
|
import { eq, and, sql, inArray } from "drizzle-orm";
|
||||||
import { addPeer, deletePeer } from "../gerbil/peers";
|
import { addPeer, deletePeer } from "../gerbil/peers";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
@ -77,68 +77,84 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||||
allowedIps: [site.subnet]
|
allowedIps: [site.subnet]
|
||||||
});
|
});
|
||||||
|
|
||||||
const allResources = await db
|
// Improved version
|
||||||
.select({
|
const allResources = await db.transaction(async (tx) => {
|
||||||
// Resource fields
|
// First get all resources for the site
|
||||||
resourceId: resources.resourceId,
|
const resourcesList = await tx
|
||||||
subdomain: resources.subdomain,
|
.select({
|
||||||
fullDomain: resources.fullDomain,
|
resourceId: resources.resourceId,
|
||||||
ssl: resources.ssl,
|
subdomain: resources.subdomain,
|
||||||
blockAccess: resources.blockAccess,
|
fullDomain: resources.fullDomain,
|
||||||
sso: resources.sso,
|
ssl: resources.ssl,
|
||||||
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
blockAccess: resources.blockAccess,
|
||||||
http: resources.http,
|
sso: resources.sso,
|
||||||
proxyPort: resources.proxyPort,
|
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
||||||
protocol: resources.protocol,
|
http: resources.http,
|
||||||
// Targets as a subquery
|
proxyPort: resources.proxyPort,
|
||||||
targets: sql<string>`json_group_array(json_object(
|
protocol: resources.protocol
|
||||||
'targetId', ${targets.targetId},
|
})
|
||||||
'ip', ${targets.ip},
|
.from(resources)
|
||||||
'method', ${targets.method},
|
.where(eq(resources.siteId, siteId));
|
||||||
'port', ${targets.port},
|
|
||||||
'internalPort', ${targets.internalPort},
|
// Get all enabled targets for these resources in a single query
|
||||||
'enabled', ${targets.enabled}
|
const resourceIds = resourcesList.map((r) => r.resourceId);
|
||||||
))`.as("targets")
|
const allTargets =
|
||||||
})
|
resourceIds.length > 0
|
||||||
.from(resources)
|
? await tx
|
||||||
.leftJoin(
|
.select({
|
||||||
targets,
|
resourceId: targets.resourceId,
|
||||||
and(
|
targetId: targets.targetId,
|
||||||
eq(targets.resourceId, resources.resourceId),
|
ip: targets.ip,
|
||||||
eq(targets.enabled, true)
|
method: targets.method,
|
||||||
|
port: targets.port,
|
||||||
|
internalPort: targets.internalPort,
|
||||||
|
enabled: targets.enabled
|
||||||
|
})
|
||||||
|
.from(targets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(targets.resourceId, resourceIds),
|
||||||
|
eq(targets.enabled, true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Combine the data in JS instead of using SQL for the JSON
|
||||||
|
return resourcesList.map((resource) => ({
|
||||||
|
...resource,
|
||||||
|
targets: allTargets.filter(
|
||||||
|
(target) => target.resourceId === resource.resourceId
|
||||||
)
|
)
|
||||||
)
|
}));
|
||||||
.where(eq(resources.siteId, siteId))
|
});
|
||||||
.groupBy(resources.resourceId);
|
|
||||||
|
|
||||||
let tcpTargets: string[] = [];
|
const { tcpTargets, udpTargets } = allResources.reduce(
|
||||||
let udpTargets: string[] = [];
|
(acc, resource) => {
|
||||||
|
// Skip resources with no targets
|
||||||
|
if (!resource.targets?.length) return acc;
|
||||||
|
|
||||||
for (const resource of allResources) {
|
// Format valid targets into strings
|
||||||
const targets = JSON.parse(resource.targets);
|
const formattedTargets = resource.targets
|
||||||
if (!targets || targets.length === 0) {
|
.filter(
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (resource.protocol === "tcp") {
|
|
||||||
tcpTargets = tcpTargets.concat(
|
|
||||||
targets.map(
|
|
||||||
(target: Target) =>
|
(target: Target) =>
|
||||||
`${
|
target?.internalPort && target?.ip && target?.port
|
||||||
target.internalPort ? target.internalPort + ":" : ""
|
|
||||||
}${target.ip}:${target.port}`
|
|
||||||
)
|
)
|
||||||
);
|
.map(
|
||||||
} else {
|
|
||||||
udpTargets = tcpTargets.concat(
|
|
||||||
targets.map(
|
|
||||||
(target: Target) =>
|
(target: Target) =>
|
||||||
`${
|
`${target.internalPort}:${target.ip}:${target.port}`
|
||||||
target.internalPort ? target.internalPort + ":" : ""
|
);
|
||||||
}${target.ip}:${target.port}`
|
|
||||||
)
|
// Add to the appropriate protocol array
|
||||||
);
|
if (resource.protocol === "tcp") {
|
||||||
}
|
acc.tcpTargets.push(...formattedTargets);
|
||||||
}
|
} else {
|
||||||
|
acc.udpTargets.push(...formattedTargets);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ tcpTargets: [] as string[], udpTargets: [] as string[] }
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
|
|
|
@ -2,7 +2,15 @@ import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { Org, orgs, roleActions, roles, userOrgs } from "@server/db/schema";
|
import {
|
||||||
|
domains,
|
||||||
|
Org,
|
||||||
|
orgDomains,
|
||||||
|
orgs,
|
||||||
|
roleActions,
|
||||||
|
roles,
|
||||||
|
userOrgs
|
||||||
|
} from "@server/db/schema";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
|
@ -16,7 +24,6 @@ const createOrgSchema = z
|
||||||
.object({
|
.object({
|
||||||
orgId: z.string(),
|
orgId: z.string(),
|
||||||
name: z.string().min(1).max(255)
|
name: z.string().min(1).max(255)
|
||||||
// domain: z.string().min(1).max(255).optional(),
|
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
@ -82,14 +89,16 @@ export async function createOrg(
|
||||||
let org: Org | null = null;
|
let org: Org | null = null;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const domain = config.getBaseDomain();
|
const allDomains = await trx
|
||||||
|
.select()
|
||||||
|
.from(domains)
|
||||||
|
.where(eq(domains.configManaged, true));
|
||||||
|
|
||||||
const newOrg = await trx
|
const newOrg = await trx
|
||||||
.insert(orgs)
|
.insert(orgs)
|
||||||
.values({
|
.values({
|
||||||
orgId,
|
orgId,
|
||||||
name,
|
name
|
||||||
domain
|
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
@ -109,6 +118,13 @@ export async function createOrg(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await trx.insert(orgDomains).values(
|
||||||
|
allDomains.map((domain) => ({
|
||||||
|
orgId: newOrg[0].orgId,
|
||||||
|
domainId: domain.domainId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
await trx.insert(userOrgs).values({
|
await trx.insert(userOrgs).values({
|
||||||
userId: req.user!.userId,
|
userId: req.user!.userId,
|
||||||
orgId: newOrg[0].orgId,
|
orgId: newOrg[0].orgId,
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { SqliteError } from "better-sqlite3";
|
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import {
|
import {
|
||||||
|
domains,
|
||||||
|
orgDomains,
|
||||||
orgs,
|
orgs,
|
||||||
Resource,
|
Resource,
|
||||||
resources,
|
resources,
|
||||||
|
@ -27,69 +28,29 @@ const createResourceParamsSchema = z
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const createResourceSchema = z
|
const createHttpResourceSchema = z
|
||||||
.object({
|
.object({
|
||||||
subdomain: z.string().optional(),
|
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
|
subdomain: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((val) => val?.toLowerCase()),
|
||||||
|
isBaseDomain: z.boolean().optional(),
|
||||||
siteId: z.number(),
|
siteId: z.number(),
|
||||||
http: z.boolean(),
|
http: z.boolean(),
|
||||||
protocol: z.string(),
|
protocol: z.string(),
|
||||||
proxyPort: z.number().optional(),
|
domainId: z.string()
|
||||||
isBaseDomain: z.boolean().optional()
|
|
||||||
})
|
})
|
||||||
|
.strict()
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (!data.http) {
|
if (data.subdomain) {
|
||||||
return z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(1)
|
|
||||||
.max(65535)
|
|
||||||
.safeParse(data.proxyPort).success;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "Invalid port number",
|
|
||||||
path: ["proxyPort"]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
if (data.http && !data.isBaseDomain) {
|
|
||||||
return subdomainSchema.safeParse(data.subdomain).success;
|
return subdomainSchema.safeParse(data.subdomain).success;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
{
|
{ message: "Invalid subdomain" }
|
||||||
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(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
|
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
|
||||||
|
@ -104,6 +65,29 @@ const createResourceSchema = z
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const createRawResourceSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
siteId: z.number(),
|
||||||
|
http: z.boolean(),
|
||||||
|
protocol: z.string(),
|
||||||
|
proxyPort: z.number().int().min(1).max(65535)
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (!config.getRawConfig().flags?.allow_raw_resources) {
|
||||||
|
if (data.proxyPort !== undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Proxy port cannot be set"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export type CreateResourceResponse = Resource;
|
export type CreateResourceResponse = Resource;
|
||||||
|
|
||||||
export async function createResource(
|
export async function createResource(
|
||||||
|
@ -112,18 +96,6 @@ export async function createResource(
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const parsedBody = createResourceSchema.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let { name, subdomain, protocol, proxyPort, http, isBaseDomain } = parsedBody.data;
|
|
||||||
|
|
||||||
// Validate request params
|
// Validate request params
|
||||||
const parsedParams = createResourceParamsSchema.safeParse(req.params);
|
const parsedParams = createResourceParamsSchema.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
|
@ -159,99 +131,25 @@ export async function createResource(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let fullDomain = "";
|
if (typeof req.body.http !== "boolean") {
|
||||||
if (isBaseDomain) {
|
return next(
|
||||||
fullDomain = org[0].domain;
|
createHttpError(HttpCode.BAD_REQUEST, "http field is required")
|
||||||
} 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
|
const { http } = req.body;
|
||||||
if (!http) {
|
|
||||||
const existingResource = await db
|
|
||||||
.select()
|
|
||||||
.from(resources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resources.protocol, protocol),
|
|
||||||
eq(resources.proxyPort, proxyPort!)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingResource.length > 0) {
|
if (http) {
|
||||||
return next(
|
return await createHttpResource(
|
||||||
createHttpError(
|
{ req, res, next },
|
||||||
HttpCode.CONFLICT,
|
{ siteId, orgId }
|
||||||
"Resource with that protocol and port already exists"
|
);
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// make sure the full domain is unique
|
return await createRawResource(
|
||||||
const existingResource = await db
|
{ req, res, next },
|
||||||
.select()
|
{ siteId, orgId }
|
||||||
.from(resources)
|
);
|
||||||
.where(eq(resources.fullDomain, fullDomain));
|
|
||||||
|
|
||||||
if (existingResource.length > 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.CONFLICT,
|
|
||||||
"Resource with that domain already exists"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
const newResource = await trx
|
|
||||||
.insert(resources)
|
|
||||||
.values({
|
|
||||||
siteId,
|
|
||||||
fullDomain: http ? fullDomain : null,
|
|
||||||
orgId,
|
|
||||||
name,
|
|
||||||
subdomain,
|
|
||||||
http,
|
|
||||||
protocol,
|
|
||||||
proxyPort,
|
|
||||||
ssl: true,
|
|
||||||
isBaseDomain
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
const adminRole = await db
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (adminRole.length === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await trx.insert(roleResources).values({
|
|
||||||
roleId: adminRole[0].roleId,
|
|
||||||
resourceId: newResource[0].resourceId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (req.userOrgRoleId != adminRole[0].roleId) {
|
|
||||||
// make sure the user can access the resource
|
|
||||||
await trx.insert(userResources).values({
|
|
||||||
userId: req.user?.userId!,
|
|
||||||
resourceId: newResource[0].resourceId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
response<CreateResourceResponse>(res, {
|
|
||||||
data: newResource[0],
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Resource created successfully",
|
|
||||||
status: HttpCode.CREATED
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
return next(
|
return next(
|
||||||
|
@ -259,3 +157,245 @@ export async function createResource(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createHttpResource(
|
||||||
|
route: {
|
||||||
|
req: Request;
|
||||||
|
res: Response;
|
||||||
|
next: NextFunction;
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
siteId: number;
|
||||||
|
orgId: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { req, res, next } = route;
|
||||||
|
const { siteId, orgId } = meta;
|
||||||
|
|
||||||
|
const parsedBody = createHttpResourceSchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, subdomain, isBaseDomain, http, protocol, domainId } =
|
||||||
|
parsedBody.data;
|
||||||
|
|
||||||
|
const [orgDomain] = await db
|
||||||
|
.select()
|
||||||
|
.from(orgDomains)
|
||||||
|
.where(
|
||||||
|
and(eq(orgDomains.orgId, orgId), eq(orgDomains.domainId, domainId))
|
||||||
|
)
|
||||||
|
.leftJoin(domains, eq(orgDomains.domainId, domains.domainId));
|
||||||
|
|
||||||
|
if (!orgDomain || !orgDomain.domains) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Domain with ID ${parsedBody.data.domainId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = orgDomain.domains;
|
||||||
|
|
||||||
|
let fullDomain = "";
|
||||||
|
if (isBaseDomain) {
|
||||||
|
fullDomain = domain.baseDomain;
|
||||||
|
} else {
|
||||||
|
fullDomain = `${subdomain}.${domain.baseDomain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Full domain: ${fullDomain}`);
|
||||||
|
|
||||||
|
// make sure the full domain is unique
|
||||||
|
const existingResource = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.fullDomain, fullDomain));
|
||||||
|
|
||||||
|
if (existingResource.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Resource with that domain already exists"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resource: Resource | undefined;
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
const newResource = await trx
|
||||||
|
.insert(resources)
|
||||||
|
.values({
|
||||||
|
siteId,
|
||||||
|
fullDomain,
|
||||||
|
domainId,
|
||||||
|
orgId,
|
||||||
|
name,
|
||||||
|
subdomain,
|
||||||
|
http,
|
||||||
|
protocol,
|
||||||
|
ssl: true,
|
||||||
|
isBaseDomain
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const adminRole = await db
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (adminRole.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.insert(roleResources).values({
|
||||||
|
roleId: adminRole[0].roleId,
|
||||||
|
resourceId: newResource[0].resourceId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.userOrgRoleId != adminRole[0].roleId) {
|
||||||
|
// make sure the user can access the resource
|
||||||
|
await trx.insert(userResources).values({
|
||||||
|
userId: req.user?.userId!,
|
||||||
|
resourceId: newResource[0].resourceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resource = newResource[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to create resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<CreateResourceResponse>(res, {
|
||||||
|
data: resource,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Http resource created successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRawResource(
|
||||||
|
route: {
|
||||||
|
req: Request;
|
||||||
|
res: Response;
|
||||||
|
next: NextFunction;
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
siteId: number;
|
||||||
|
orgId: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { req, res, next } = route;
|
||||||
|
const { siteId, orgId } = meta;
|
||||||
|
|
||||||
|
const parsedBody = createRawResourceSchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, http, protocol, proxyPort } = parsedBody.data;
|
||||||
|
|
||||||
|
// if http is false check to see if there is already a resource with the same port and protocol
|
||||||
|
const existingResource = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.protocol, protocol),
|
||||||
|
eq(resources.proxyPort, proxyPort!)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingResource.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Resource with that protocol and port already exists"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resource: Resource | undefined;
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
const newResource = await trx
|
||||||
|
.insert(resources)
|
||||||
|
.values({
|
||||||
|
siteId,
|
||||||
|
orgId,
|
||||||
|
name,
|
||||||
|
http,
|
||||||
|
protocol,
|
||||||
|
proxyPort
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const adminRole = await db
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (adminRole.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.insert(roleResources).values({
|
||||||
|
roleId: adminRole[0].roleId,
|
||||||
|
resourceId: newResource[0].resourceId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.userOrgRoleId != adminRole[0].roleId) {
|
||||||
|
// make sure the user can access the resource
|
||||||
|
await trx.insert(userResources).values({
|
||||||
|
userId: req.user?.userId!,
|
||||||
|
resourceId: newResource[0].resourceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resource = newResource[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to create resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<CreateResourceResponse>(res, {
|
||||||
|
data: resource,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Non-http resource created successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { Resource, resources } from "@server/db/schema";
|
import { Resource, resources, sites } from "@server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
@ -18,7 +18,9 @@ const getResourceSchema = z
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
export type GetResourceResponse = Resource;
|
export type GetResourceResponse = Resource & {
|
||||||
|
siteName: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function getResource(
|
export async function getResource(
|
||||||
req: Request,
|
req: Request,
|
||||||
|
@ -38,13 +40,17 @@ export async function getResource(
|
||||||
|
|
||||||
const { resourceId } = parsedParams.data;
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
const resource = await db
|
const [resp] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.where(eq(resources.resourceId, resourceId))
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.leftJoin(sites, eq(sites.siteId, resources.siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (resource.length === 0) {
|
const resource = resp.resources;
|
||||||
|
const site = resp.sites;
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.NOT_FOUND,
|
HttpCode.NOT_FOUND,
|
||||||
|
@ -54,7 +60,10 @@ export async function getResource(
|
||||||
}
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: resource[0],
|
data: {
|
||||||
|
...resource,
|
||||||
|
siteName: site?.name
|
||||||
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Resource retrieved successfully",
|
message: "Resource retrieved successfully",
|
||||||
|
|
|
@ -1,8 +1,15 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { orgs, resources, sites } from "@server/db/schema";
|
import {
|
||||||
import { eq, or, and } from "drizzle-orm";
|
domains,
|
||||||
|
Org,
|
||||||
|
orgDomains,
|
||||||
|
orgs,
|
||||||
|
Resource,
|
||||||
|
resources
|
||||||
|
} from "@server/db/schema";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
|
@ -20,17 +27,53 @@ const updateResourceParamsSchema = z
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const updateResourceBodySchema = z
|
const updateHttpResourceBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
subdomain: subdomainSchema.optional(),
|
subdomain: subdomainSchema
|
||||||
|
.optional()
|
||||||
|
.transform((val) => val?.toLowerCase()),
|
||||||
ssl: z.boolean().optional(),
|
ssl: z.boolean().optional(),
|
||||||
sso: z.boolean().optional(),
|
sso: z.boolean().optional(),
|
||||||
blockAccess: 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(),
|
isBaseDomain: z.boolean().optional(),
|
||||||
applyRules: z.boolean().optional(),
|
applyRules: z.boolean().optional(),
|
||||||
|
domainId: z.string().optional()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
|
message: "At least one field must be provided for update"
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.subdomain) {
|
||||||
|
return subdomainSchema.safeParse(data.subdomain).success;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{ message: "Invalid subdomain" }
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
|
||||||
|
if (data.isBaseDomain) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Base domain resources are not allowed"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type UpdateResourceResponse = Resource;
|
||||||
|
|
||||||
|
const updateRawResourceBodySchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
proxyPort: z.number().int().min(1).max(65535).optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
|
@ -46,30 +89,6 @@ const updateResourceBodySchema = z
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
{ message: "Cannot update proxyPort" }
|
{ message: "Cannot update proxyPort" }
|
||||||
)
|
|
||||||
// .refine(
|
|
||||||
// (data) => {
|
|
||||||
// if (data.proxyPort === 443 || data.proxyPort === 80) {
|
|
||||||
// return false;
|
|
||||||
// }
|
|
||||||
// return true;
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// message: "Port 80 and 443 are reserved for http and https resources"
|
|
||||||
// }
|
|
||||||
// )
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
|
|
||||||
if (data.isBaseDomain) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "Base domain resources are not allowed"
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export async function updateResource(
|
export async function updateResource(
|
||||||
|
@ -88,18 +107,7 @@ export async function updateResource(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedBody = updateResourceBodySchema.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { resourceId } = parsedParams.data;
|
const { resourceId } = parsedParams.data;
|
||||||
const updateData = parsedBody.data;
|
|
||||||
|
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
.select()
|
.select()
|
||||||
|
@ -119,117 +127,33 @@ export async function updateResource(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updateData.subdomain) {
|
if (resource.http) {
|
||||||
if (!resource.http) {
|
// HANDLE UPDATING HTTP RESOURCES
|
||||||
return next(
|
return await updateHttpResource(
|
||||||
createHttpError(
|
{
|
||||||
HttpCode.BAD_REQUEST,
|
req,
|
||||||
"Cannot update subdomain for non-http resource"
|
res,
|
||||||
)
|
next
|
||||||
);
|
},
|
||||||
}
|
{
|
||||||
|
resource,
|
||||||
const valid = subdomainSchema.safeParse(
|
org
|
||||||
updateData.subdomain
|
}
|
||||||
).success;
|
);
|
||||||
if (!valid) {
|
} else {
|
||||||
return next(
|
// HANDLE UPDATING RAW TCP/UDP RESOURCES
|
||||||
createHttpError(
|
return await updateRawResource(
|
||||||
HttpCode.BAD_REQUEST,
|
{
|
||||||
"Invalid subdomain provided"
|
req,
|
||||||
)
|
res,
|
||||||
);
|
next
|
||||||
}
|
},
|
||||||
}
|
{
|
||||||
|
resource,
|
||||||
if (updateData.proxyPort) {
|
org
|
||||||
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,
|
|
||||||
"Resource does not have a domain"
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let fullDomain: string | undefined;
|
|
||||||
if (updateData.isBaseDomain) {
|
|
||||||
fullDomain = org.domain;
|
|
||||||
} else if (updateData.subdomain) {
|
|
||||||
fullDomain = `${updateData.subdomain}.${org.domain}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatePayload = {
|
|
||||||
...updateData,
|
|
||||||
...(fullDomain && { fullDomain })
|
|
||||||
};
|
|
||||||
|
|
||||||
if (
|
|
||||||
fullDomain &&
|
|
||||||
(updatePayload.subdomain !== undefined ||
|
|
||||||
updatePayload.isBaseDomain !== undefined)
|
|
||||||
) {
|
|
||||||
const [existingDomain] = await db
|
|
||||||
.select()
|
|
||||||
.from(resources)
|
|
||||||
.where(eq(resources.fullDomain, fullDomain));
|
|
||||||
|
|
||||||
if (existingDomain && existingDomain.resourceId !== resourceId) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.CONFLICT,
|
|
||||||
"Resource with that domain already exists"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedResource = await db
|
|
||||||
.update(resources)
|
|
||||||
.set(updatePayload)
|
|
||||||
.where(eq(resources.resourceId, resourceId))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (updatedResource.length === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Resource with ID ${resourceId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: updatedResource[0],
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Resource updated successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
return next(
|
return next(
|
||||||
|
@ -237,3 +161,191 @@ export async function updateResource(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateHttpResource(
|
||||||
|
route: {
|
||||||
|
req: Request;
|
||||||
|
res: Response;
|
||||||
|
next: NextFunction;
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
resource: Resource;
|
||||||
|
org: Org;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { next, req, res } = route;
|
||||||
|
const { resource, org } = meta;
|
||||||
|
|
||||||
|
const parsedBody = updateHttpResourceBodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = parsedBody.data;
|
||||||
|
|
||||||
|
if (updateData.domainId) {
|
||||||
|
const [existingDomain] = await db
|
||||||
|
.select()
|
||||||
|
.from(orgDomains)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(orgDomains.orgId, org.orgId),
|
||||||
|
eq(orgDomains.domainId, updateData.domainId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(domains, eq(orgDomains.domainId, domains.domainId));
|
||||||
|
|
||||||
|
if (!existingDomain) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, `Domain not found`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainId = updateData.domainId || resource.domainId!;
|
||||||
|
const subdomain = updateData.subdomain || resource.subdomain;
|
||||||
|
|
||||||
|
const [domain] = await db
|
||||||
|
.select()
|
||||||
|
.from(domains)
|
||||||
|
.where(eq(domains.domainId, domainId));
|
||||||
|
|
||||||
|
const isBaseDomain =
|
||||||
|
updateData.isBaseDomain !== undefined
|
||||||
|
? updateData.isBaseDomain
|
||||||
|
: resource.isBaseDomain;
|
||||||
|
|
||||||
|
let fullDomain: string | null = null;
|
||||||
|
if (isBaseDomain) {
|
||||||
|
fullDomain = domain.baseDomain;
|
||||||
|
} else if (subdomain && domain) {
|
||||||
|
fullDomain = `${subdomain}.${domain.baseDomain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullDomain) {
|
||||||
|
const [existingDomain] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.fullDomain, fullDomain));
|
||||||
|
|
||||||
|
if (
|
||||||
|
existingDomain &&
|
||||||
|
existingDomain.resourceId !== resource.resourceId
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Resource with that domain already exists"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePayload = {
|
||||||
|
...updateData,
|
||||||
|
fullDomain
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedResource = await db
|
||||||
|
.update(resources)
|
||||||
|
.set(updatePayload)
|
||||||
|
.where(eq(resources.resourceId, resource.resourceId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (updatedResource.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resource.resourceId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: updatedResource[0],
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "HTTP resource updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateRawResource(
|
||||||
|
route: {
|
||||||
|
req: Request;
|
||||||
|
res: Response;
|
||||||
|
next: NextFunction;
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
resource: Resource;
|
||||||
|
org: Org;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { next, req, res } = route;
|
||||||
|
const { resource } = meta;
|
||||||
|
|
||||||
|
const parsedBody = updateRawResourceBodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = parsedBody.data;
|
||||||
|
|
||||||
|
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 !== resource.resourceId
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Resource with that protocol and port already exists"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedResource = await db
|
||||||
|
.update(resources)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(resources.resourceId, resource.resourceId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (updatedResource.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resource.resourceId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: updatedResource[0],
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Non-http Resource updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
@ -12,52 +12,79 @@ export async function traefikConfigProvider(
|
||||||
res: Response
|
res: Response
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const allResources = await db
|
// Get all resources with related data
|
||||||
.select({
|
const allResources = await db.transaction(async (tx) => {
|
||||||
// Resource fields
|
// First query to get resources with site and org info
|
||||||
resourceId: resources.resourceId,
|
const resourcesWithRelations = await tx
|
||||||
subdomain: resources.subdomain,
|
.select({
|
||||||
fullDomain: resources.fullDomain,
|
// Resource fields
|
||||||
ssl: resources.ssl,
|
resourceId: resources.resourceId,
|
||||||
blockAccess: resources.blockAccess,
|
subdomain: resources.subdomain,
|
||||||
sso: resources.sso,
|
fullDomain: resources.fullDomain,
|
||||||
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
ssl: resources.ssl,
|
||||||
http: resources.http,
|
blockAccess: resources.blockAccess,
|
||||||
proxyPort: resources.proxyPort,
|
sso: resources.sso,
|
||||||
protocol: resources.protocol,
|
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
||||||
isBaseDomain: resources.isBaseDomain,
|
http: resources.http,
|
||||||
// Site fields
|
proxyPort: resources.proxyPort,
|
||||||
site: {
|
protocol: resources.protocol,
|
||||||
siteId: sites.siteId,
|
isBaseDomain: resources.isBaseDomain,
|
||||||
type: sites.type,
|
domainId: resources.domainId,
|
||||||
subnet: sites.subnet
|
// Site fields
|
||||||
},
|
site: {
|
||||||
// Org fields
|
siteId: sites.siteId,
|
||||||
org: {
|
type: sites.type,
|
||||||
orgId: orgs.orgId,
|
subnet: sites.subnet
|
||||||
domain: orgs.domain
|
},
|
||||||
},
|
// Org fields
|
||||||
// Targets as a subquery
|
org: {
|
||||||
targets: sql<string>`json_group_array(json_object(
|
orgId: orgs.orgId
|
||||||
'targetId', ${targets.targetId},
|
}
|
||||||
'ip', ${targets.ip},
|
})
|
||||||
'method', ${targets.method},
|
.from(resources)
|
||||||
'port', ${targets.port},
|
.innerJoin(sites, eq(sites.siteId, resources.siteId))
|
||||||
'internalPort', ${targets.internalPort},
|
.innerJoin(orgs, eq(resources.orgId, orgs.orgId));
|
||||||
'enabled', ${targets.enabled}
|
|
||||||
))`.as("targets")
|
// Get all resource IDs from the first query
|
||||||
})
|
const resourceIds = resourcesWithRelations.map((r) => r.resourceId);
|
||||||
.from(resources)
|
|
||||||
.innerJoin(sites, eq(sites.siteId, resources.siteId))
|
// Second query to get all enabled targets for these resources
|
||||||
.innerJoin(orgs, eq(resources.orgId, orgs.orgId))
|
const allTargets =
|
||||||
.leftJoin(
|
resourceIds.length > 0
|
||||||
targets,
|
? await tx
|
||||||
and(
|
.select({
|
||||||
eq(targets.resourceId, resources.resourceId),
|
resourceId: targets.resourceId,
|
||||||
eq(targets.enabled, true)
|
targetId: targets.targetId,
|
||||||
)
|
ip: targets.ip,
|
||||||
)
|
method: targets.method,
|
||||||
.groupBy(resources.resourceId);
|
port: targets.port,
|
||||||
|
internalPort: targets.internalPort,
|
||||||
|
enabled: targets.enabled
|
||||||
|
})
|
||||||
|
.from(targets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(targets.resourceId, resourceIds),
|
||||||
|
eq(targets.enabled, true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Create a map for fast target lookup by resourceId
|
||||||
|
const targetsMap = allTargets.reduce((map, target) => {
|
||||||
|
if (!map.has(target.resourceId)) {
|
||||||
|
map.set(target.resourceId, []);
|
||||||
|
}
|
||||||
|
map.get(target.resourceId).push(target);
|
||||||
|
return map;
|
||||||
|
}, new Map());
|
||||||
|
|
||||||
|
// Combine the data
|
||||||
|
return resourcesWithRelations.map((resource) => ({
|
||||||
|
...resource,
|
||||||
|
targets: targetsMap.get(resource.resourceId) || []
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
if (!allResources.length) {
|
if (!allResources.length) {
|
||||||
return res.status(HttpCode.OK).json({});
|
return res.status(HttpCode.OK).json({});
|
||||||
|
@ -101,19 +128,26 @@ export async function traefikConfigProvider(
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const resource of allResources) {
|
for (const resource of allResources) {
|
||||||
const targets = JSON.parse(resource.targets);
|
const targets = resource.targets as Target[];
|
||||||
const site = resource.site;
|
const site = resource.site;
|
||||||
const org = resource.org;
|
const org = resource.org;
|
||||||
|
|
||||||
if (!org.domain) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const routerName = `${resource.resourceId}-router`;
|
const routerName = `${resource.resourceId}-router`;
|
||||||
const serviceName = `${resource.resourceId}-service`;
|
const serviceName = `${resource.resourceId}-service`;
|
||||||
const fullDomain = `${resource.fullDomain}`;
|
const fullDomain = `${resource.fullDomain}`;
|
||||||
|
|
||||||
if (resource.http) {
|
if (resource.http) {
|
||||||
|
if (!resource.domainId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resource.fullDomain) {
|
||||||
|
logger.error(
|
||||||
|
`Resource ${resource.resourceId} has no fullDomain`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// HTTP configuration remains the same
|
// HTTP configuration remains the same
|
||||||
if (!resource.subdomain && !resource.isBaseDomain) {
|
if (!resource.subdomain && !resource.isBaseDomain) {
|
||||||
continue;
|
continue;
|
||||||
|
@ -136,9 +170,18 @@ export async function traefikConfigProvider(
|
||||||
wildCard = `*.${domainParts.slice(1).join(".")}`;
|
wildCard = `*.${domainParts.slice(1).join(".")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const configDomain = config.getDomain(resource.domainId);
|
||||||
|
|
||||||
|
if (!configDomain) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to get domain from config for resource ${resource.resourceId}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const tls = {
|
const tls = {
|
||||||
certResolver: config.getRawConfig().traefik.cert_resolver,
|
certResolver: configDomain.cert_resolver,
|
||||||
...(config.getRawConfig().traefik.prefer_wildcard_cert
|
...(configDomain.prefer_wildcard_cert
|
||||||
? {
|
? {
|
||||||
domains: [
|
domains: [
|
||||||
{
|
{
|
||||||
|
|
79
server/setup/clearStaleData.ts
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import {
|
||||||
|
emailVerificationCodes,
|
||||||
|
newtSessions,
|
||||||
|
passwordResetTokens,
|
||||||
|
resourceAccessToken,
|
||||||
|
resourceOtp,
|
||||||
|
resourceSessions,
|
||||||
|
sessions,
|
||||||
|
userInvites
|
||||||
|
} from "@server/db/schema";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { lt } from "drizzle-orm";
|
||||||
|
|
||||||
|
export async function clearStaleData() {
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(sessions)
|
||||||
|
.where(lt(sessions.expiresAt, new Date().getTime()));
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn("Error clearing expired sessions:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(newtSessions)
|
||||||
|
.where(lt(newtSessions.expiresAt, new Date().getTime()));
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn("Error clearing expired newtSessions:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(emailVerificationCodes)
|
||||||
|
.where(lt(emailVerificationCodes.expiresAt, new Date().getTime()));
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn("Error clearing expired emailVerificationCodes:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(passwordResetTokens)
|
||||||
|
.where(lt(passwordResetTokens.expiresAt, new Date().getTime()));
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn("Error clearing expired passwordResetTokens:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(userInvites)
|
||||||
|
.where(lt(userInvites.expiresAt, new Date().getTime()));
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn("Error clearing expired userInvites:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(resourceAccessToken)
|
||||||
|
.where(lt(resourceAccessToken.expiresAt, new Date().getTime()));
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn("Error clearing expired resourceAccessToken:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(resourceSessions)
|
||||||
|
.where(lt(resourceSessions.expiresAt, new Date().getTime()));
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn("Error clearing expired resourceSessions:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(resourceOtp)
|
||||||
|
.where(lt(resourceOtp.expiresAt, new Date().getTime()));
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn("Error clearing expired resourceOtp:", e);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,34 +1,103 @@
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { exitNodes, orgs, resources } from "../db/schema";
|
import { domains, exitNodes, orgDomains, orgs, resources } from "../db/schema";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { eq, ne } from "drizzle-orm";
|
import { eq, ne } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export async function copyInConfig() {
|
export async function copyInConfig() {
|
||||||
const domain = config.getBaseDomain();
|
|
||||||
const endpoint = config.getRawConfig().gerbil.base_endpoint;
|
const endpoint = config.getRawConfig().gerbil.base_endpoint;
|
||||||
const listenPort = config.getRawConfig().gerbil.start_port;
|
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||||
|
|
||||||
// update the domain on all of the orgs where the domain is not equal to the new domain
|
|
||||||
// TODO: eventually each org could have a unique domain that we do not want to overwrite, so this will be unnecessary
|
|
||||||
await db.update(orgs).set({ domain }).where(ne(orgs.domain, domain));
|
|
||||||
|
|
||||||
// TODO: eventually each exit node could have a different endpoint
|
|
||||||
await db.update(exitNodes).set({ endpoint }).where(ne(exitNodes.endpoint, endpoint));
|
|
||||||
// TODO: eventually each exit node could have a different port
|
|
||||||
await db.update(exitNodes).set({ listenPort }).where(ne(exitNodes.listenPort, listenPort));
|
|
||||||
|
|
||||||
// update all resources fullDomain to use the new domain
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const allResources = await trx.select().from(resources);
|
const rawDomains = config.getRawConfig().domains;
|
||||||
|
|
||||||
|
const configDomains = Object.entries(rawDomains).map(
|
||||||
|
([key, value]) => ({
|
||||||
|
domainId: key,
|
||||||
|
baseDomain: value.base_domain.toLowerCase()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingDomains = await trx
|
||||||
|
.select()
|
||||||
|
.from(domains)
|
||||||
|
.where(eq(domains.configManaged, true));
|
||||||
|
const existingDomainKeys = new Set(
|
||||||
|
existingDomains.map((d) => d.domainId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const configDomainKeys = new Set(configDomains.map((d) => d.domainId));
|
||||||
|
for (const existingDomain of existingDomains) {
|
||||||
|
if (!configDomainKeys.has(existingDomain.domainId)) {
|
||||||
|
await trx
|
||||||
|
.delete(domains)
|
||||||
|
.where(eq(domains.domainId, existingDomain.domainId))
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { domainId, baseDomain } of configDomains) {
|
||||||
|
if (existingDomainKeys.has(domainId)) {
|
||||||
|
await trx
|
||||||
|
.update(domains)
|
||||||
|
.set({ baseDomain })
|
||||||
|
.where(eq(domains.domainId, domainId))
|
||||||
|
.execute();
|
||||||
|
} else {
|
||||||
|
await trx
|
||||||
|
.insert(domains)
|
||||||
|
.values({ domainId, baseDomain, configManaged: true })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allOrgs = await trx.select().from(orgs);
|
||||||
|
|
||||||
|
const existingOrgDomains = await trx.select().from(orgDomains);
|
||||||
|
const existingOrgDomainSet = new Set(
|
||||||
|
existingOrgDomains.map((od) => `${od.orgId}-${od.domainId}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const newOrgDomains = [];
|
||||||
|
for (const org of allOrgs) {
|
||||||
|
for (const domain of configDomains) {
|
||||||
|
const key = `${org.orgId}-${domain.domainId}`;
|
||||||
|
if (!existingOrgDomainSet.has(key)) {
|
||||||
|
newOrgDomains.push({
|
||||||
|
orgId: org.orgId,
|
||||||
|
domainId: domain.domainId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newOrgDomains.length > 0) {
|
||||||
|
await trx.insert(orgDomains).values(newOrgDomains).execute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
const allResources = await trx
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.leftJoin(domains, eq(domains.domainId, resources.domainId));
|
||||||
|
|
||||||
|
for (const { resources: resource, domains: domain } of allResources) {
|
||||||
|
if (!resource || !domain) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!domain.configManaged) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
for (const resource of allResources) {
|
|
||||||
let fullDomain = "";
|
let fullDomain = "";
|
||||||
if (resource.isBaseDomain) {
|
if (resource.isBaseDomain) {
|
||||||
fullDomain = domain;
|
fullDomain = domain.baseDomain;
|
||||||
} else {
|
} else {
|
||||||
fullDomain = `${resource.subdomain}.${domain}`;
|
fullDomain = `${resource.subdomain}.${domain.baseDomain}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.update(resources)
|
.update(resources)
|
||||||
.set({ fullDomain })
|
.set({ fullDomain })
|
||||||
|
@ -36,5 +105,14 @@ export async function copyInConfig() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Updated orgs with new domain (${domain})`);
|
// TODO: eventually each exit node could have a different endpoint
|
||||||
|
await db
|
||||||
|
.update(exitNodes)
|
||||||
|
.set({ endpoint })
|
||||||
|
.where(ne(exitNodes.endpoint, endpoint));
|
||||||
|
// TODO: eventually each exit node could have a different port
|
||||||
|
await db
|
||||||
|
.update(exitNodes)
|
||||||
|
.set({ listenPort })
|
||||||
|
.where(ne(exitNodes.listenPort, listenPort));
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,12 +2,14 @@ import { ensureActions } from "./ensureActions";
|
||||||
import { copyInConfig } from "./copyInConfig";
|
import { copyInConfig } from "./copyInConfig";
|
||||||
import { setupServerAdmin } from "./setupServerAdmin";
|
import { setupServerAdmin } from "./setupServerAdmin";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { clearStaleData } from "./clearStaleData";
|
||||||
|
|
||||||
export async function runSetupFunctions() {
|
export async function runSetupFunctions() {
|
||||||
try {
|
try {
|
||||||
await copyInConfig(); // copy in the config to the db as needed
|
await copyInConfig(); // copy in the config to the db as needed
|
||||||
await setupServerAdmin();
|
await setupServerAdmin();
|
||||||
await ensureActions(); // make sure all of the actions are in the db and the roles
|
await ensureActions(); // make sure all of the actions are in the db and the roles
|
||||||
|
await clearStaleData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error running setup functions:", error);
|
logger.error("Error running setup functions:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|
|
@ -15,6 +15,8 @@ import m6 from "./scripts/1.0.0-beta9";
|
||||||
import m7 from "./scripts/1.0.0-beta10";
|
import m7 from "./scripts/1.0.0-beta10";
|
||||||
import m8 from "./scripts/1.0.0-beta12";
|
import m8 from "./scripts/1.0.0-beta12";
|
||||||
import m13 from "./scripts/1.0.0-beta13";
|
import m13 from "./scripts/1.0.0-beta13";
|
||||||
|
import m15 from "./scripts/1.0.0-beta15";
|
||||||
|
import m16 from "./scripts/1.0.0";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
|
@ -29,16 +31,15 @@ const migrations = [
|
||||||
{ 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.10", run: m7 },
|
||||||
{ version: "1.0.0-beta.12", run: m8 },
|
{ version: "1.0.0-beta.12", run: m8 },
|
||||||
{ version: "1.0.0-beta.13", run: m13 }
|
{ version: "1.0.0-beta.13", run: m13 },
|
||||||
|
{ version: "1.0.0-beta.15", run: m15 },
|
||||||
|
{ version: "1.0.0", run: m16 }
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
await run();
|
await run();
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
// backup the database
|
|
||||||
backupDb();
|
|
||||||
|
|
||||||
// run the migrations
|
// run the migrations
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
}
|
}
|
||||||
|
@ -123,6 +124,11 @@ async function executeScripts() {
|
||||||
console.log(`Running migration ${migration.version}`);
|
console.log(`Running migration ${migration.version}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!process.env.DISABLE_BACKUP_ON_MIGRATION) {
|
||||||
|
// Backup the database before running the migration
|
||||||
|
backupDb();
|
||||||
|
}
|
||||||
|
|
||||||
await migration.run();
|
await migration.run();
|
||||||
|
|
||||||
// Update version in database
|
// Update version in database
|
||||||
|
|
129
server/setup/scripts/1.0.0-beta15.ts
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
import db from "@server/db";
|
||||||
|
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
|
import fs from "fs";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { domains, orgDomains, resources } from "@server/db/schema";
|
||||||
|
|
||||||
|
const version = "1.0.0-beta.15";
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log(`Running setup script ${version}...`);
|
||||||
|
|
||||||
|
let domain = "";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const baseDomain = rawConfig.app.base_domain;
|
||||||
|
const certResolver = rawConfig.traefik.cert_resolver;
|
||||||
|
const preferWildcardCert = rawConfig.traefik.prefer_wildcard_cert;
|
||||||
|
|
||||||
|
delete rawConfig.traefik.prefer_wildcard_cert;
|
||||||
|
delete rawConfig.traefik.cert_resolver;
|
||||||
|
delete rawConfig.app.base_domain;
|
||||||
|
|
||||||
|
rawConfig.domains = {
|
||||||
|
domain1: {
|
||||||
|
base_domain: baseDomain
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (certResolver) {
|
||||||
|
rawConfig.domains.domain1.cert_resolver = certResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferWildcardCert) {
|
||||||
|
rawConfig.domains.domain1.prefer_wildcard_cert = preferWildcardCert;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the updated YAML back to the file
|
||||||
|
const updatedYaml = yaml.dump(rawConfig);
|
||||||
|
fs.writeFileSync(filePath, updatedYaml, "utf8");
|
||||||
|
|
||||||
|
domain = baseDomain;
|
||||||
|
|
||||||
|
console.log(`Moved base_domain to new domains section`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(
|
||||||
|
`Unable to migrate config file and move base_domain to domains. Error: ${e}`
|
||||||
|
);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.transaction((trx) => {
|
||||||
|
trx.run(sql`CREATE TABLE 'domains' (
|
||||||
|
'domainId' text PRIMARY KEY NOT NULL,
|
||||||
|
'baseDomain' text NOT NULL,
|
||||||
|
'configManaged' integer DEFAULT false NOT NULL
|
||||||
|
);`);
|
||||||
|
|
||||||
|
trx.run(sql`CREATE TABLE 'orgDomains' (
|
||||||
|
'orgId' text NOT NULL,
|
||||||
|
'domainId' text NOT NULL,
|
||||||
|
FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE cascade
|
||||||
|
);`);
|
||||||
|
|
||||||
|
trx.run(
|
||||||
|
sql`ALTER TABLE 'resources' ADD 'domainId' text REFERENCES domains(domainId);`
|
||||||
|
);
|
||||||
|
trx.run(sql`ALTER TABLE 'orgs' DROP COLUMN 'domain';`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Migrated database schema`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Unable to migrate database schema");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await trx
|
||||||
|
.insert(domains)
|
||||||
|
.values({
|
||||||
|
domainId: "domain1",
|
||||||
|
baseDomain: domain,
|
||||||
|
configManaged: true
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
await trx.update(resources).set({ domainId: "domain1" });
|
||||||
|
const existingOrgDomains = await trx.select().from(orgDomains);
|
||||||
|
for (const orgDomain of existingOrgDomains) {
|
||||||
|
await trx
|
||||||
|
.insert(orgDomains)
|
||||||
|
.values({ orgId: orgDomain.orgId, domainId: "domain1" })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Updated resources table with new domainId`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(
|
||||||
|
`Unable to update resources table with new domainId. Error: ${e}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${version} migration complete`);
|
||||||
|
}
|
57
server/setup/scripts/1.0.0.ts
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import { APP_PATH } from "@server/lib/consts";
|
||||||
|
import fs from "fs";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
import path from "path";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromZodError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const version = "1.0.0";
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log(`Running setup script ${version}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const traefikPath = path.join(
|
||||||
|
APP_PATH,
|
||||||
|
"traefik",
|
||||||
|
"traefik_config.yml"
|
||||||
|
);
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
experimental: z.object({
|
||||||
|
plugins: z.object({
|
||||||
|
badger: z.object({
|
||||||
|
moduleName: z.string(),
|
||||||
|
version: z.string()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const traefikFileContents = fs.readFileSync(traefikPath, "utf8");
|
||||||
|
const traefikConfig = yaml.load(traefikFileContents) as any;
|
||||||
|
|
||||||
|
const parsedConfig = schema.safeParse(traefikConfig);
|
||||||
|
|
||||||
|
if (!parsedConfig.success) {
|
||||||
|
throw new Error(fromZodError(parsedConfig.error).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
traefikConfig.experimental.plugins.badger.version = "v1.0.0";
|
||||||
|
|
||||||
|
const updatedTraefikYaml = yaml.dump(traefikConfig);
|
||||||
|
|
||||||
|
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"Updated the version of Badger in your Traefik configuration to 1.0.0"
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(
|
||||||
|
"We were unable to update the version of Badger in your Traefik configuration. Please update it manually."
|
||||||
|
);
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${version} migration complete`);
|
||||||
|
}
|
|
@ -7,7 +7,7 @@ import {
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
@ -24,11 +24,11 @@ import {
|
||||||
CredenzaDescription,
|
CredenzaDescription,
|
||||||
CredenzaFooter,
|
CredenzaFooter,
|
||||||
CredenzaHeader,
|
CredenzaHeader,
|
||||||
CredenzaTitle,
|
CredenzaTitle
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
|
import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
|
||||||
import { formatAxiosError } from "@app/lib/api";;
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
|
||||||
|
@ -40,13 +40,13 @@ type CreateRoleFormProps = {
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string({ message: "Name is required" }).max(32),
|
name: z.string({ message: "Name is required" }).max(32),
|
||||||
description: z.string().max(255).optional(),
|
description: z.string().max(255).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function CreateRoleForm({
|
export default function CreateRoleForm({
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
afterCreate,
|
afterCreate
|
||||||
}: CreateRoleFormProps) {
|
}: CreateRoleFormProps) {
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
|
|
||||||
|
@ -58,8 +58,8 @@ export default function CreateRoleForm({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: ""
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
@ -70,7 +70,7 @@ export default function CreateRoleForm({
|
||||||
`/org/${org?.org.orgId}/role`,
|
`/org/${org?.org.orgId}/role`,
|
||||||
{
|
{
|
||||||
name: values.name,
|
name: values.name,
|
||||||
description: values.description,
|
description: values.description
|
||||||
} as CreateRoleBody
|
} as CreateRoleBody
|
||||||
)
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
|
@ -80,7 +80,7 @@ export default function CreateRoleForm({
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(
|
||||||
e,
|
e,
|
||||||
"An error occurred while creating the role."
|
"An error occurred while creating the role."
|
||||||
),
|
)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -88,7 +88,7 @@ export default function CreateRoleForm({
|
||||||
toast({
|
toast({
|
||||||
variant: "default",
|
variant: "default",
|
||||||
title: "Role created",
|
title: "Role created",
|
||||||
description: "The role has been successfully created.",
|
description: "The role has been successfully created."
|
||||||
});
|
});
|
||||||
|
|
||||||
if (open) {
|
if (open) {
|
||||||
|
@ -135,10 +135,7 @@ export default function CreateRoleForm({
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Role Name</FormLabel>
|
<FormLabel>Role Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} />
|
||||||
placeholder="Enter name for the role"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
@ -151,10 +148,7 @@ export default function CreateRoleForm({
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Description</FormLabel>
|
<FormLabel>Description</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} />
|
||||||
placeholder="Describe the role"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
@ -164,6 +158,9 @@ export default function CreateRoleForm({
|
||||||
</Form>
|
</Form>
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
<CredenzaFooter>
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</CredenzaClose>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
form="create-role-form"
|
form="create-role-form"
|
||||||
|
@ -172,9 +169,6 @@ export default function CreateRoleForm({
|
||||||
>
|
>
|
||||||
Create Role
|
Create Role
|
||||||
</Button>
|
</Button>
|
||||||
<CredenzaClose asChild>
|
|
||||||
<Button variant="outline">Close</Button>
|
|
||||||
</CredenzaClose>
|
|
||||||
</CredenzaFooter>
|
</CredenzaFooter>
|
||||||
</CredenzaContent>
|
</CredenzaContent>
|
||||||
</Credenza>
|
</Credenza>
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
@ -23,7 +23,7 @@ import {
|
||||||
CredenzaDescription,
|
CredenzaDescription,
|
||||||
CredenzaFooter,
|
CredenzaFooter,
|
||||||
CredenzaHeader,
|
CredenzaHeader,
|
||||||
CredenzaTitle,
|
CredenzaTitle
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { ListRolesResponse } from "@server/routers/role";
|
import { ListRolesResponse } from "@server/routers/role";
|
||||||
|
@ -32,10 +32,10 @@ import {
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
import { RoleRow } from "./RolesTable";
|
import { RoleRow } from "./RolesTable";
|
||||||
import { formatAxiosError } from "@app/lib/api";;
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
|
||||||
|
@ -47,14 +47,14 @@ type CreateRoleFormProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
newRoleId: z.string({ message: "New role is required" }),
|
newRoleId: z.string({ message: "New role is required" })
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function DeleteRoleForm({
|
export default function DeleteRoleForm({
|
||||||
open,
|
open,
|
||||||
roleToDelete,
|
roleToDelete,
|
||||||
setOpen,
|
setOpen,
|
||||||
afterDelete,
|
afterDelete
|
||||||
}: CreateRoleFormProps) {
|
}: CreateRoleFormProps) {
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
|
|
||||||
|
@ -66,9 +66,9 @@ export default function DeleteRoleForm({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchRoles() {
|
async function fetchRoles() {
|
||||||
const res = await api
|
const res = await api
|
||||||
.get<AxiosResponse<ListRolesResponse>>(
|
.get<
|
||||||
`/org/${org?.org.orgId}/roles`
|
AxiosResponse<ListRolesResponse>
|
||||||
)
|
>(`/org/${org?.org.orgId}/roles`)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast({
|
toast({
|
||||||
|
@ -77,7 +77,7 @@ export default function DeleteRoleForm({
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(
|
||||||
e,
|
e,
|
||||||
"An error occurred while fetching the roles"
|
"An error occurred while fetching the roles"
|
||||||
),
|
)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -96,8 +96,8 @@ export default function DeleteRoleForm({
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
newRoleId: "",
|
newRoleId: ""
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
@ -106,8 +106,8 @@ export default function DeleteRoleForm({
|
||||||
const res = await api
|
const res = await api
|
||||||
.delete(`/role/${roleToDelete.roleId}`, {
|
.delete(`/role/${roleToDelete.roleId}`, {
|
||||||
data: {
|
data: {
|
||||||
roleId: values.newRoleId,
|
roleId: values.newRoleId
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
|
@ -116,7 +116,7 @@ export default function DeleteRoleForm({
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(
|
||||||
e,
|
e,
|
||||||
"An error occurred while removing the role."
|
"An error occurred while removing the role."
|
||||||
),
|
)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -124,7 +124,7 @@ export default function DeleteRoleForm({
|
||||||
toast({
|
toast({
|
||||||
variant: "default",
|
variant: "default",
|
||||||
title: "Role removed",
|
title: "Role removed",
|
||||||
description: "The role has been successfully removed.",
|
description: "The role has been successfully removed."
|
||||||
});
|
});
|
||||||
|
|
||||||
if (open) {
|
if (open) {
|
||||||
|
@ -214,6 +214,9 @@ export default function DeleteRoleForm({
|
||||||
</div>
|
</div>
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
<CredenzaFooter>
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</CredenzaClose>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
form="remove-role-form"
|
form="remove-role-form"
|
||||||
|
@ -222,9 +225,6 @@ export default function DeleteRoleForm({
|
||||||
>
|
>
|
||||||
Remove Role
|
Remove Role
|
||||||
</Button>
|
</Button>
|
||||||
<CredenzaClose asChild>
|
|
||||||
<Button variant="outline">Close</Button>
|
|
||||||
</CredenzaClose>
|
|
||||||
</CredenzaFooter>
|
</CredenzaFooter>
|
||||||
</CredenzaContent>
|
</CredenzaContent>
|
||||||
</Credenza>
|
</Credenza>
|
||||||
|
|
|
@ -37,7 +37,7 @@ import {
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { ListRolesResponse } from "@server/routers/role";
|
import { ListRolesResponse } from "@server/routers/role";
|
||||||
import { formatAxiosError } from "@app/lib/api";;
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { Checkbox } from "@app/components/ui/checkbox";
|
import { Checkbox } from "@app/components/ui/checkbox";
|
||||||
|
@ -194,10 +194,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} />
|
||||||
placeholder="Enter an email"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
@ -341,6 +338,9 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
|
||||||
</div>
|
</div>
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
<CredenzaFooter>
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</CredenzaClose>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
form="invite-user-form"
|
form="invite-user-form"
|
||||||
|
@ -349,9 +349,6 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
|
||||||
>
|
>
|
||||||
Create Invitation
|
Create Invitation
|
||||||
</Button>
|
</Button>
|
||||||
<CredenzaClose asChild>
|
|
||||||
<Button variant="outline">Close</Button>
|
|
||||||
</CredenzaClose>
|
|
||||||
</CredenzaFooter>
|
</CredenzaFooter>
|
||||||
</CredenzaContent>
|
</CredenzaContent>
|
||||||
</Credenza>
|
</Credenza>
|
||||||
|
|
|
@ -185,7 +185,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||||
<Link
|
<Link
|
||||||
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
||||||
>
|
>
|
||||||
<Button variant={"outline"} className="ml-2">
|
<Button variant={"outlinePrimary"} className="ml-2">
|
||||||
Manage
|
Manage
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -64,7 +64,7 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-0.5 select-none mb-6">
|
<div className="space-y-0.5 mb-6">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">
|
<h2 className="text-2xl font-bold tracking-tight">
|
||||||
User {user?.email}
|
User {user?.email}
|
||||||
</h2>
|
</h2>
|
||||||
|
@ -73,7 +73,6 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
|
||||||
|
|
||||||
<SidebarSettings
|
<SidebarSettings
|
||||||
sidebarNavItems={sidebarNavItems}
|
sidebarNavItems={sidebarNavItems}
|
||||||
limitWidth={true}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</SidebarSettings>
|
</SidebarSettings>
|
||||||
|
|
|
@ -210,11 +210,11 @@ export default function GeneralPage() {
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
This is the display name of the
|
This is the display name of the
|
||||||
org
|
organization.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -238,7 +238,6 @@ export default function GeneralPage() {
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
<AlertTriangle className="h-5 w-5" />
|
|
||||||
Danger Zone
|
Danger Zone
|
||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
|
|
|
@ -1,6 +1,12 @@
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { TopbarNav } from "@app/components/TopbarNav";
|
import { TopbarNav } from "@app/components/TopbarNav";
|
||||||
import { Cog, Combine, Laptop, Link, Settings, Users, Waypoints, Workflow } from "lucide-react";
|
import {
|
||||||
|
Combine,
|
||||||
|
LinkIcon,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
Waypoints
|
||||||
|
} from "lucide-react";
|
||||||
import { Header } from "@app/components/Header";
|
import { Header } from "@app/components/Header";
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
@ -43,7 +49,7 @@ const topNavItems = [
|
||||||
{
|
{
|
||||||
title: "Shareable Links",
|
title: "Shareable Links",
|
||||||
href: "/{orgId}/settings/share-links",
|
href: "/{orgId}/settings/share-links",
|
||||||
icon: <Link className="h-4 w-4" />
|
icon: <LinkIcon className="h-4 w-4" />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "General",
|
title: "General",
|
||||||
|
@ -100,19 +106,23 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full border-b bg-card select-none sm:px-0 px-3 fixed top-0 z-10">
|
<div className="w-full bg-card sm:px-0 px-3 fixed top-0 z-10">
|
||||||
<div className="container mx-auto flex flex-col content-between">
|
<div className="border-b">
|
||||||
<div className="my-4">
|
<div className="container mx-auto flex flex-col content-between">
|
||||||
<UserProvider user={user}>
|
<div className="my-4">
|
||||||
<Header orgId={params.orgId} orgs={orgs} />
|
<UserProvider user={user}>
|
||||||
</UserProvider>
|
<Header orgId={params.orgId} orgs={orgs} />
|
||||||
|
</UserProvider>
|
||||||
|
</div>
|
||||||
|
<TopbarNav items={topNavItems} orgId={params.orgId} />
|
||||||
</div>
|
</div>
|
||||||
<TopbarNav items={topNavItems} orgId={params.orgId} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="container mx-auto sm:px-0 px-3 pt-[165px]">
|
<div className="container mx-auto sm:px-0 px-3 pt-[155px]">
|
||||||
{children}
|
<div className="container mx-auto sm:px-0 px-3">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -233,7 +233,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||||
<Link
|
<Link
|
||||||
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
|
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
|
||||||
>
|
>
|
||||||
<Button variant={"outline"} className="ml-2">
|
<Button variant={"outlinePrimary"} className="ml-2">
|
||||||
Edit
|
Edit
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -2,27 +2,68 @@
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
interface DomainOption {
|
||||||
|
baseDomain: string;
|
||||||
|
domainId: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface CustomDomainInputProps {
|
interface CustomDomainInputProps {
|
||||||
domainSuffix: string;
|
domainOptions: DomainOption[];
|
||||||
|
selectedDomainId?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string, selectedDomainId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CustomDomainInput({
|
export default function CustomDomainInput({
|
||||||
domainSuffix,
|
domainOptions,
|
||||||
placeholder = "Enter subdomain",
|
selectedDomainId,
|
||||||
|
placeholder = "Subdomain",
|
||||||
value: defaultValue,
|
value: defaultValue,
|
||||||
onChange
|
onChange
|
||||||
}: CustomDomainInputProps) {
|
}: CustomDomainInputProps) {
|
||||||
const [value, setValue] = React.useState(defaultValue);
|
const [value, setValue] = React.useState(defaultValue);
|
||||||
|
const [selectedDomain, setSelectedDomain] = React.useState<DomainOption>();
|
||||||
|
|
||||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
React.useEffect(() => {
|
||||||
|
if (domainOptions.length) {
|
||||||
|
if (selectedDomainId) {
|
||||||
|
const selectedDomainOption = domainOptions.find(
|
||||||
|
(option) => option.domainId === selectedDomainId
|
||||||
|
);
|
||||||
|
setSelectedDomain(selectedDomainOption || domainOptions[0]);
|
||||||
|
} else {
|
||||||
|
setSelectedDomain(domainOptions[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [domainOptions]);
|
||||||
|
|
||||||
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!selectedDomain) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const newValue = event.target.value;
|
const newValue = event.target.value;
|
||||||
setValue(newValue);
|
setValue(newValue);
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
onChange(newValue);
|
onChange(newValue, selectedDomain.domainId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDomainChange = (domainId: string) => {
|
||||||
|
const newSelectedDomain =
|
||||||
|
domainOptions.find((option) => option.domainId === domainId) ||
|
||||||
|
domainOptions[0];
|
||||||
|
setSelectedDomain(newSelectedDomain);
|
||||||
|
if (onChange) {
|
||||||
|
onChange(value, newSelectedDomain.domainId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -33,12 +74,28 @@ export default function CustomDomainInput({
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleInputChange}
|
||||||
className="rounded-r-none w-full"
|
className="w-1/2 mr-1 text-right"
|
||||||
/>
|
/>
|
||||||
<div className="max-w-1/2 flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-muted-foreground">
|
<Select
|
||||||
<span className="text-sm truncate">.{domainSuffix}</span>
|
onValueChange={handleDomainChange}
|
||||||
</div>
|
value={selectedDomain?.domainId}
|
||||||
|
defaultValue={selectedDomain?.domainId}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-1/2 pr-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{domainOptions.map((option) => (
|
||||||
|
<SelectItem
|
||||||
|
key={option.domainId}
|
||||||
|
value={option.domainId}
|
||||||
|
>
|
||||||
|
.{option.baseDomain}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
|
import { ArrowRight, InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
|
||||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
import { Separator } from "@app/components/ui/separator";
|
import { Separator } from "@app/components/ui/separator";
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
|
@ -13,21 +11,14 @@ import {
|
||||||
InfoSections,
|
InfoSections,
|
||||||
InfoSectionTitle
|
InfoSectionTitle
|
||||||
} from "@app/components/InfoSection";
|
} from "@app/components/InfoSection";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
type ResourceInfoBoxType = {};
|
type ResourceInfoBoxType = {};
|
||||||
|
|
||||||
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const { org } = useOrgContext();
|
|
||||||
const { resource, authInfo } = useResourceContext();
|
const { resource, authInfo } = useResourceContext();
|
||||||
|
|
||||||
let fullUrl = `${resource.ssl ? "https" : "http"}://`;
|
let fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||||
if (resource.isBaseDomain) {
|
|
||||||
fullUrl = fullUrl + org.org.domain;
|
|
||||||
} else {
|
|
||||||
fullUrl = fullUrl + `${resource.subdomain}.${org.org.domain}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert>
|
<Alert>
|
||||||
|
@ -36,7 +27,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
Resource Information
|
Resource Information
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription className="mt-4">
|
<AlertDescription className="mt-4">
|
||||||
<InfoSections>
|
<InfoSections cols={3}>
|
||||||
{resource.http ? (
|
{resource.http ? (
|
||||||
<>
|
<>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
|
@ -50,22 +41,16 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
authInfo.whitelist ? (
|
authInfo.whitelist ? (
|
||||||
<div className="flex items-start space-x-2 text-green-500">
|
<div className="flex items-start space-x-2 text-green-500">
|
||||||
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
||||||
<span>
|
<span>Protected</span>
|
||||||
This resource is protected with
|
|
||||||
at least one auth method.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center space-x-2 text-yellow-500">
|
<div className="flex items-center space-x-2 text-yellow-500">
|
||||||
<ShieldOff className="w-4 h-4" />
|
<ShieldOff className="w-4 h-4" />
|
||||||
<span>
|
<span>Not Protected</span>
|
||||||
Anyone can access this resource.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
<Separator orientation="vertical" />
|
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>URL</InfoSectionTitle>
|
<InfoSectionTitle>URL</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
|
@ -75,6 +60,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
/>
|
/>
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>Site</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{resource.siteName}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
@ -86,7 +77,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
</span>
|
</span>
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
<Separator orientation="vertical" />
|
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>Port</InfoSectionTitle>
|
<InfoSectionTitle>Port</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
|
|
|
@ -8,7 +8,7 @@ import {
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
@ -24,22 +24,22 @@ import {
|
||||||
CredenzaDescription,
|
CredenzaDescription,
|
||||||
CredenzaFooter,
|
CredenzaFooter,
|
||||||
CredenzaHeader,
|
CredenzaHeader,
|
||||||
CredenzaTitle,
|
CredenzaTitle
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import { formatAxiosError } from "@app/lib/api";;
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { Resource } from "@server/db/schema";
|
import { Resource } from "@server/db/schema";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
|
||||||
const setPasswordFormSchema = z.object({
|
const setPasswordFormSchema = z.object({
|
||||||
password: z.string().min(4).max(100),
|
password: z.string().min(4).max(100)
|
||||||
});
|
});
|
||||||
|
|
||||||
type SetPasswordFormValues = z.infer<typeof setPasswordFormSchema>;
|
type SetPasswordFormValues = z.infer<typeof setPasswordFormSchema>;
|
||||||
|
|
||||||
const defaultValues: Partial<SetPasswordFormValues> = {
|
const defaultValues: Partial<SetPasswordFormValues> = {
|
||||||
password: "",
|
password: ""
|
||||||
};
|
};
|
||||||
|
|
||||||
type SetPasswordFormProps = {
|
type SetPasswordFormProps = {
|
||||||
|
@ -53,7 +53,7 @@ export default function SetResourcePasswordForm({
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
resourceId,
|
resourceId,
|
||||||
onSetPassword,
|
onSetPassword
|
||||||
}: SetPasswordFormProps) {
|
}: SetPasswordFormProps) {
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
@ -61,7 +61,7 @@ export default function SetResourcePasswordForm({
|
||||||
|
|
||||||
const form = useForm<SetPasswordFormValues>({
|
const form = useForm<SetPasswordFormValues>({
|
||||||
resolver: zodResolver(setPasswordFormSchema),
|
resolver: zodResolver(setPasswordFormSchema),
|
||||||
defaultValues,
|
defaultValues
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -76,7 +76,7 @@ export default function SetResourcePasswordForm({
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/password`, {
|
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/password`, {
|
||||||
password: data.password,
|
password: data.password
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
|
@ -85,14 +85,14 @@ export default function SetResourcePasswordForm({
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(
|
||||||
e,
|
e,
|
||||||
"An error occurred while setting the resource password"
|
"An error occurred while setting the resource password"
|
||||||
),
|
)
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast({
|
toast({
|
||||||
title: "Resource password set",
|
title: "Resource password set",
|
||||||
description:
|
description:
|
||||||
"The resource password has been set successfully",
|
"The resource password has been set successfully"
|
||||||
});
|
});
|
||||||
|
|
||||||
if (onSetPassword) {
|
if (onSetPassword) {
|
||||||
|
@ -136,17 +136,16 @@ export default function SetResourcePasswordForm({
|
||||||
<Input
|
<Input
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Your secure password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Users will be able to access
|
Users will be able to access
|
||||||
this resource by entering this
|
this resource by entering this
|
||||||
password. It must be at least 4
|
password. It must be at least 4
|
||||||
characters long.
|
characters long.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -154,6 +153,9 @@ export default function SetResourcePasswordForm({
|
||||||
</Form>
|
</Form>
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
<CredenzaFooter>
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</CredenzaClose>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
form="set-password-form"
|
form="set-password-form"
|
||||||
|
@ -162,9 +164,6 @@ export default function SetResourcePasswordForm({
|
||||||
>
|
>
|
||||||
Enable Password Protection
|
Enable Password Protection
|
||||||
</Button>
|
</Button>
|
||||||
<CredenzaClose asChild>
|
|
||||||
<Button variant="outline">Close</Button>
|
|
||||||
</CredenzaClose>
|
|
||||||
</CredenzaFooter>
|
</CredenzaFooter>
|
||||||
</CredenzaContent>
|
</CredenzaContent>
|
||||||
</Credenza>
|
</Credenza>
|
||||||
|
|
|
@ -8,7 +8,7 @@ import {
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
@ -24,27 +24,27 @@ import {
|
||||||
CredenzaDescription,
|
CredenzaDescription,
|
||||||
CredenzaFooter,
|
CredenzaFooter,
|
||||||
CredenzaHeader,
|
CredenzaHeader,
|
||||||
CredenzaTitle,
|
CredenzaTitle
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import { formatAxiosError } from "@app/lib/api";;
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { Resource } from "@server/db/schema";
|
import { Resource } from "@server/db/schema";
|
||||||
import {
|
import {
|
||||||
InputOTP,
|
InputOTP,
|
||||||
InputOTPGroup,
|
InputOTPGroup,
|
||||||
InputOTPSlot,
|
InputOTPSlot
|
||||||
} from "@app/components/ui/input-otp";
|
} from "@app/components/ui/input-otp";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
|
||||||
const setPincodeFormSchema = z.object({
|
const setPincodeFormSchema = z.object({
|
||||||
pincode: z.string().length(6),
|
pincode: z.string().length(6)
|
||||||
});
|
});
|
||||||
|
|
||||||
type SetPincodeFormValues = z.infer<typeof setPincodeFormSchema>;
|
type SetPincodeFormValues = z.infer<typeof setPincodeFormSchema>;
|
||||||
|
|
||||||
const defaultValues: Partial<SetPincodeFormValues> = {
|
const defaultValues: Partial<SetPincodeFormValues> = {
|
||||||
pincode: "",
|
pincode: ""
|
||||||
};
|
};
|
||||||
|
|
||||||
type SetPincodeFormProps = {
|
type SetPincodeFormProps = {
|
||||||
|
@ -58,7 +58,7 @@ export default function SetResourcePincodeForm({
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
resourceId,
|
resourceId,
|
||||||
onSetPincode,
|
onSetPincode
|
||||||
}: SetPincodeFormProps) {
|
}: SetPincodeFormProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
@ -66,7 +66,7 @@ export default function SetResourcePincodeForm({
|
||||||
|
|
||||||
const form = useForm<SetPincodeFormValues>({
|
const form = useForm<SetPincodeFormValues>({
|
||||||
resolver: zodResolver(setPincodeFormSchema),
|
resolver: zodResolver(setPincodeFormSchema),
|
||||||
defaultValues,
|
defaultValues
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -81,7 +81,7 @@ export default function SetResourcePincodeForm({
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/pincode`, {
|
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/pincode`, {
|
||||||
pincode: data.pincode,
|
pincode: data.pincode
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
|
@ -89,15 +89,15 @@ export default function SetResourcePincodeForm({
|
||||||
title: "Error setting resource PIN code",
|
title: "Error setting resource PIN code",
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(
|
||||||
e,
|
e,
|
||||||
"An error occurred while setting the resource PIN code",
|
"An error occurred while setting the resource PIN code"
|
||||||
),
|
)
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast({
|
toast({
|
||||||
title: "Resource PIN code set",
|
title: "Resource PIN code set",
|
||||||
description:
|
description:
|
||||||
"The resource pincode has been set successfully",
|
"The resource pincode has been set successfully"
|
||||||
});
|
});
|
||||||
|
|
||||||
if (onSetPincode) {
|
if (onSetPincode) {
|
||||||
|
@ -167,13 +167,13 @@ export default function SetResourcePincodeForm({
|
||||||
</InputOTP>
|
</InputOTP>
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Users will be able to access
|
Users will be able to access
|
||||||
this resource by entering this
|
this resource by entering this
|
||||||
PIN code. It must be at least 6
|
PIN code. It must be at least 6
|
||||||
digits long.
|
digits long.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -181,6 +181,9 @@ export default function SetResourcePincodeForm({
|
||||||
</Form>
|
</Form>
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
<CredenzaFooter>
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</CredenzaClose>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
form="set-pincode-form"
|
form="set-pincode-form"
|
||||||
|
@ -189,9 +192,6 @@ export default function SetResourcePincodeForm({
|
||||||
>
|
>
|
||||||
Enable PIN Code Protection
|
Enable PIN Code Protection
|
||||||
</Button>
|
</Button>
|
||||||
<CredenzaClose asChild>
|
|
||||||
<Button variant="outline">Close</Button>
|
|
||||||
</CredenzaClose>
|
|
||||||
</CredenzaFooter>
|
</CredenzaFooter>
|
||||||
</CredenzaContent>
|
</CredenzaContent>
|
||||||
</Credenza>
|
</Credenza>
|
||||||
|
|
|
@ -8,14 +8,12 @@ import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import {
|
import {
|
||||||
GetResourceAuthInfoResponse,
|
|
||||||
GetResourceWhitelistResponse,
|
GetResourceWhitelistResponse,
|
||||||
ListResourceRolesResponse,
|
ListResourceRolesResponse,
|
||||||
ListResourceUsersResponse
|
ListResourceUsersResponse
|
||||||
} from "@server/routers/resource";
|
} from "@server/routers/resource";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { set, z } from "zod";
|
import { set, z } from "zod";
|
||||||
import { Tag } from "emblor";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import {
|
||||||
|
@ -27,12 +25,8 @@ import {
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { TagInput } from "emblor";
|
|
||||||
// import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
|
||||||
import { ListUsersResponse } from "@server/routers/user";
|
import { ListUsersResponse } from "@server/routers/user";
|
||||||
import { Switch } from "@app/components/ui/switch";
|
import { Binary, Key } from "lucide-react";
|
||||||
import { Label } from "@app/components/ui/label";
|
|
||||||
import { Binary, Key, ShieldCheck } from "lucide-react";
|
|
||||||
import SetResourcePasswordForm from "./SetResourcePasswordForm";
|
import SetResourcePasswordForm from "./SetResourcePasswordForm";
|
||||||
import SetResourcePincodeForm from "./SetResourcePincodeForm";
|
import SetResourcePincodeForm from "./SetResourcePincodeForm";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
@ -44,11 +38,12 @@ import {
|
||||||
SettingsSectionHeader,
|
SettingsSectionHeader,
|
||||||
SettingsSectionDescription,
|
SettingsSectionDescription,
|
||||||
SettingsSectionBody,
|
SettingsSectionBody,
|
||||||
SettingsSectionForm,
|
SettingsSectionFooter,
|
||||||
SettingsSectionFooter
|
SettingsSectionForm
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
const UsersRolesFormSchema = z.object({
|
const UsersRolesFormSchema = z.object({
|
||||||
|
@ -413,7 +408,7 @@ export default function ResourceAuthenticationPage() {
|
||||||
<SwitchInput
|
<SwitchInput
|
||||||
id="sso-toggle"
|
id="sso-toggle"
|
||||||
label="Use Platform SSO"
|
label="Use Platform SSO"
|
||||||
description="Existing users will only have to login once for all resources that have this enabled."
|
description="Existing users will only have to log in once for all resources that have this enabled."
|
||||||
defaultChecked={resource.sso}
|
defaultChecked={resource.sso}
|
||||||
onCheckedChange={(val) => setSsoEnabled(val)}
|
onCheckedChange={(val) => setSsoEnabled(val)}
|
||||||
/>
|
/>
|
||||||
|
@ -435,7 +430,6 @@ export default function ResourceAuthenticationPage() {
|
||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>Roles</FormLabel>
|
<FormLabel>Roles</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
{/* @ts-ignore */}
|
|
||||||
<TagInput
|
<TagInput
|
||||||
{...field}
|
{...field}
|
||||||
activeTagIndex={
|
activeTagIndex={
|
||||||
|
@ -444,7 +438,8 @@ export default function ResourceAuthenticationPage() {
|
||||||
setActiveTagIndex={
|
setActiveTagIndex={
|
||||||
setActiveRolesTagIndex
|
setActiveRolesTagIndex
|
||||||
}
|
}
|
||||||
placeholder="Enter a role"
|
placeholder="Select a role"
|
||||||
|
size="sm"
|
||||||
tags={
|
tags={
|
||||||
usersRolesForm.getValues()
|
usersRolesForm.getValues()
|
||||||
.roles
|
.roles
|
||||||
|
@ -473,23 +468,13 @@ export default function ResourceAuthenticationPage() {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
sortTags={true}
|
sortTags={true}
|
||||||
styleClasses={{
|
|
||||||
tag: {
|
|
||||||
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
|
|
||||||
},
|
|
||||||
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
|
|
||||||
inlineTagsContainer:
|
|
||||||
"bg-transparent p-2"
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
These roles will be able
|
|
||||||
to access this resource.
|
|
||||||
Admins can always access
|
Admins can always access
|
||||||
this resource.
|
this resource.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -500,7 +485,6 @@ export default function ResourceAuthenticationPage() {
|
||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>Users</FormLabel>
|
<FormLabel>Users</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
{/* @ts-ignore */}
|
|
||||||
<TagInput
|
<TagInput
|
||||||
{...field}
|
{...field}
|
||||||
activeTagIndex={
|
activeTagIndex={
|
||||||
|
@ -509,11 +493,12 @@ export default function ResourceAuthenticationPage() {
|
||||||
setActiveTagIndex={
|
setActiveTagIndex={
|
||||||
setActiveUsersTagIndex
|
setActiveUsersTagIndex
|
||||||
}
|
}
|
||||||
placeholder="Enter a user"
|
placeholder="Select a user"
|
||||||
tags={
|
tags={
|
||||||
usersRolesForm.getValues()
|
usersRolesForm.getValues()
|
||||||
.users
|
.users
|
||||||
}
|
}
|
||||||
|
size="sm"
|
||||||
setTags={(
|
setTags={(
|
||||||
newUsers
|
newUsers
|
||||||
) => {
|
) => {
|
||||||
|
@ -538,25 +523,8 @@ export default function ResourceAuthenticationPage() {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
sortTags={true}
|
sortTags={true}
|
||||||
styleClasses={{
|
|
||||||
tag: {
|
|
||||||
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
|
|
||||||
},
|
|
||||||
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
|
|
||||||
inlineTagsContainer:
|
|
||||||
"bg-transparent p-2"
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
|
||||||
Users added here will be
|
|
||||||
able to access this
|
|
||||||
resource. A user will
|
|
||||||
always have access to a
|
|
||||||
resource if they have a
|
|
||||||
role that has access to
|
|
||||||
it.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
@ -601,7 +569,7 @@ export default function ResourceAuthenticationPage() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outlinePrimary"
|
||||||
onClick={
|
onClick={
|
||||||
authInfo.password
|
authInfo.password
|
||||||
? removeResourcePassword
|
? removeResourcePassword
|
||||||
|
@ -627,7 +595,7 @@ export default function ResourceAuthenticationPage() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outlinePrimary"
|
||||||
onClick={
|
onClick={
|
||||||
authInfo.pincode
|
authInfo.pincode
|
||||||
? removeResourcePincode
|
? removeResourcePincode
|
||||||
|
@ -683,6 +651,7 @@ export default function ResourceAuthenticationPage() {
|
||||||
activeTagIndex={
|
activeTagIndex={
|
||||||
activeEmailTagIndex
|
activeEmailTagIndex
|
||||||
}
|
}
|
||||||
|
size={"sm"}
|
||||||
validateTag={(
|
validateTag={(
|
||||||
tag
|
tag
|
||||||
) => {
|
) => {
|
||||||
|
@ -727,18 +696,12 @@ export default function ResourceAuthenticationPage() {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
sortTags={true}
|
sortTags={true}
|
||||||
styleClasses={{
|
|
||||||
tag: {
|
|
||||||
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
|
|
||||||
},
|
|
||||||
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
|
|
||||||
inlineTagsContainer:
|
|
||||||
"bg-transparent p-2"
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Press enter to add an email after typing it in the input field.
|
Press enter to add an
|
||||||
|
email after typing it in
|
||||||
|
the input field.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -39,6 +39,7 @@ import {
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
TableCaption,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableContainer,
|
TableContainer,
|
||||||
TableHead,
|
TableHead,
|
||||||
|
@ -103,8 +104,8 @@ export default function ReverseProxyTargets(props: {
|
||||||
resolver: zodResolver(addTargetSchema),
|
resolver: zodResolver(addTargetSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
ip: "",
|
ip: "",
|
||||||
method: resource.http ? "http" : null
|
method: resource.http ? "http" : null,
|
||||||
// protocol: "TCP",
|
port: "" as any as number
|
||||||
} as z.infer<typeof addTargetSchema>
|
} as z.infer<typeof addTargetSchema>
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -199,7 +200,11 @@ export default function ReverseProxyTargets(props: {
|
||||||
};
|
};
|
||||||
|
|
||||||
setTargets([...targets, newTarget]);
|
setTargets([...targets, newTarget]);
|
||||||
addTargetForm.reset();
|
addTargetForm.reset({
|
||||||
|
ip: "",
|
||||||
|
method: resource.http ? "http" : null,
|
||||||
|
port: "" as any as number
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeTarget = (targetId: number) => {
|
const removeTarget = (targetId: number) => {
|
||||||
|
@ -241,10 +246,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
>(`/resource/${params.resourceId}/target`, data);
|
>(`/resource/${params.resourceId}/target`, data);
|
||||||
target.targetId = res.data.data.targetId;
|
target.targetId = res.data.data.targetId;
|
||||||
} else if (target.updated) {
|
} else if (target.updated) {
|
||||||
await api.post(
|
await api.post(`/target/${target.targetId}`, data);
|
||||||
`/target/${target.targetId}`,
|
|
||||||
data
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setTargets([
|
setTargets([
|
||||||
|
@ -261,9 +263,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
|
|
||||||
for (const targetId of targetsToRemove) {
|
for (const targetId of targetsToRemove) {
|
||||||
await api.delete(`/target/${targetId}`);
|
await api.delete(`/target/${targetId}`);
|
||||||
setTargets(
|
setTargets(targets.filter((t) => t.targetId !== targetId));
|
||||||
targets.filter((t) => t.targetId !== targetId)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
@ -421,6 +421,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="http">http</SelectItem>
|
<SelectItem value="http">http</SelectItem>
|
||||||
<SelectItem value="https">https</SelectItem>
|
<SelectItem value="https">https</SelectItem>
|
||||||
|
<SelectItem value="h2c">h2c</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
|
@ -436,7 +437,13 @@ export default function ReverseProxyTargets(props: {
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel()
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
state: {
|
||||||
|
pagination: {
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (pageLoading) {
|
if (pageLoading) {
|
||||||
|
@ -452,8 +459,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
SSL Configuration
|
SSL Configuration
|
||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
Setup SSL to secure your connections with
|
Set up SSL to secure your connections with certificates
|
||||||
LetsEncrypt certificates
|
|
||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
|
@ -475,7 +481,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
Target Configuration
|
Target Configuration
|
||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
Setup targets to route traffic to your services
|
Set up targets to route traffic to your services
|
||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
|
@ -484,7 +490,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
onSubmit={addTargetForm.handleSubmit(addTarget)}
|
onSubmit={addTargetForm.handleSubmit(addTarget)}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-start">
|
||||||
{resource.http && (
|
{resource.http && (
|
||||||
<FormField
|
<FormField
|
||||||
control={addTargetForm.control}
|
control={addTargetForm.control}
|
||||||
|
@ -517,6 +523,9 @@ export default function ReverseProxyTargets(props: {
|
||||||
<SelectItem value="https">
|
<SelectItem value="https">
|
||||||
https
|
https
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="h2c">
|
||||||
|
h2c
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
@ -536,18 +545,6 @@ export default function ReverseProxyTargets(props: {
|
||||||
<Input id="ip" {...field} />
|
<Input id="ip" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
{site?.type === "newt" ? (
|
|
||||||
<FormDescription>
|
|
||||||
This is the IP or hostname
|
|
||||||
of the target service on
|
|
||||||
your network.
|
|
||||||
</FormDescription>
|
|
||||||
) : site?.type === "wireguard" ? (
|
|
||||||
<FormDescription>
|
|
||||||
This is the IP of the
|
|
||||||
WireGuard peer.
|
|
||||||
</FormDescription>
|
|
||||||
) : null}
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -566,83 +563,68 @@ export default function ReverseProxyTargets(props: {
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
{site?.type === "newt" ? (
|
|
||||||
<FormDescription>
|
|
||||||
This is the port of the
|
|
||||||
target service on your
|
|
||||||
network.
|
|
||||||
</FormDescription>
|
|
||||||
) : site?.type === "wireguard" ? (
|
|
||||||
<FormDescription>
|
|
||||||
This is the port exposed on
|
|
||||||
an address on the WireGuard
|
|
||||||
network.
|
|
||||||
</FormDescription>
|
|
||||||
) : null}
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outlinePrimary"
|
||||||
|
className="mt-8"
|
||||||
|
>
|
||||||
|
Add Target
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" variant="outline">
|
|
||||||
Add Target
|
|
||||||
</Button>
|
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<TableContainer>
|
<Table>
|
||||||
<Table>
|
<TableHeader>
|
||||||
<TableHeader>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
<TableRow key={headerGroup.id}>
|
||||||
<TableRow key={headerGroup.id}>
|
{headerGroup.headers.map((header) => (
|
||||||
{headerGroup.headers.map((header) => (
|
<TableHead key={header.id}>
|
||||||
<TableHead key={header.id}>
|
{header.isPlaceholder
|
||||||
{header.isPlaceholder
|
? null
|
||||||
? null
|
: flexRender(
|
||||||
: flexRender(
|
header.column.columnDef
|
||||||
header.column
|
.header,
|
||||||
.columnDef.header,
|
header.getContext()
|
||||||
header.getContext()
|
)}
|
||||||
)}
|
</TableHead>
|
||||||
</TableHead>
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))
|
||||||
</TableHeader>
|
) : (
|
||||||
<TableBody>
|
<TableRow>
|
||||||
{table.getRowModel().rows?.length ? (
|
<TableCell
|
||||||
table.getRowModel().rows.map((row) => (
|
colSpan={columns.length}
|
||||||
<TableRow key={row.id}>
|
className="h-24 text-center"
|
||||||
{row
|
>
|
||||||
.getVisibleCells()
|
No targets. Add a target using the form.
|
||||||
.map((cell) => (
|
</TableCell>
|
||||||
<TableCell key={cell.id}>
|
</TableRow>
|
||||||
{flexRender(
|
)}
|
||||||
cell.column
|
</TableBody>
|
||||||
.columnDef.cell,
|
<TableCaption>
|
||||||
cell.getContext()
|
Adding more than one target above will enable load
|
||||||
)}
|
balancing.
|
||||||
</TableCell>
|
</TableCaption>
|
||||||
))}
|
</Table>
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={columns.length}
|
|
||||||
className="h-24 text-center"
|
|
||||||
>
|
|
||||||
No targets. Add a target using the
|
|
||||||
form.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Adding more than one target above will enable load
|
|
||||||
balancing.
|
|
||||||
</p>
|
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
<SettingsSectionFooter>
|
<SettingsSectionFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -33,7 +33,6 @@ import { useEffect, useState } from "react";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { GetResourceAuthInfoResponse } from "@server/routers/resource";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import {
|
import {
|
||||||
SettingsContainer,
|
SettingsContainer,
|
||||||
|
@ -53,6 +52,15 @@ import { subdomainSchema } from "@server/lib/schemas";
|
||||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
||||||
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
||||||
import { Label } from "@app/components/ui/label";
|
import { Label } from "@app/components/ui/label";
|
||||||
|
import { ListDomainsResponse } from "@server/routers/domain";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@app/components/ui/select";
|
||||||
|
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||||
|
|
||||||
const GeneralFormSchema = z
|
const GeneralFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -60,7 +68,8 @@ const GeneralFormSchema = z
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
proxyPort: z.number().optional(),
|
proxyPort: z.number().optional(),
|
||||||
http: z.boolean(),
|
http: z.boolean(),
|
||||||
isBaseDomain: z.boolean().optional()
|
isBaseDomain: z.boolean().optional(),
|
||||||
|
domainId: z.string().optional()
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
|
@ -100,6 +109,7 @@ type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||||
type TransferFormValues = z.infer<typeof TransferFormSchema>;
|
type TransferFormValues = z.infer<typeof TransferFormSchema>;
|
||||||
|
|
||||||
export default function GeneralForm() {
|
export default function GeneralForm() {
|
||||||
|
const [formKey, setFormKey] = useState(0);
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { resource, updateResource } = useResourceContext();
|
const { resource, updateResource } = useResourceContext();
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
|
@ -113,10 +123,13 @@ export default function GeneralForm() {
|
||||||
|
|
||||||
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
||||||
const [saveLoading, setSaveLoading] = useState(false);
|
const [saveLoading, setSaveLoading] = useState(false);
|
||||||
const [domainSuffix, setDomainSuffix] = useState(org.org.domain);
|
|
||||||
const [transferLoading, setTransferLoading] = useState(false);
|
const [transferLoading, setTransferLoading] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [baseDomains, setBaseDomains] = useState<
|
||||||
|
ListDomainsResponse["domains"]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const [loadingPage, setLoadingPage] = useState(true);
|
||||||
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
|
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
|
||||||
resource.isBaseDomain ? "basedomain" : "subdomain"
|
resource.isBaseDomain ? "basedomain" : "subdomain"
|
||||||
);
|
);
|
||||||
|
@ -128,7 +141,8 @@ export default function GeneralForm() {
|
||||||
subdomain: resource.subdomain ? resource.subdomain : undefined,
|
subdomain: resource.subdomain ? resource.subdomain : undefined,
|
||||||
proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
|
proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
|
||||||
http: resource.http,
|
http: resource.http,
|
||||||
isBaseDomain: resource.isBaseDomain ? true : false
|
isBaseDomain: resource.isBaseDomain ? true : false,
|
||||||
|
domainId: resource.domainId || undefined
|
||||||
},
|
},
|
||||||
mode: "onChange"
|
mode: "onChange"
|
||||||
});
|
});
|
||||||
|
@ -147,19 +161,54 @@ export default function GeneralForm() {
|
||||||
);
|
);
|
||||||
setSites(res.data.data.sites);
|
setSites(res.data.data.sites);
|
||||||
};
|
};
|
||||||
fetchSites();
|
|
||||||
|
const fetchDomains = async () => {
|
||||||
|
const res = await api
|
||||||
|
.get<
|
||||||
|
AxiosResponse<ListDomainsResponse>
|
||||||
|
>(`/org/${orgId}/domains/`)
|
||||||
|
.catch((e) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error fetching domains",
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
"An error occurred when fetching the domains"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res?.status === 200) {
|
||||||
|
const domains = res.data.data.domains;
|
||||||
|
setBaseDomains(domains);
|
||||||
|
setFormKey((key) => key + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
await fetchDomains();
|
||||||
|
await fetchSites();
|
||||||
|
|
||||||
|
setLoadingPage(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function onSubmit(data: GeneralFormValues) {
|
async function onSubmit(data: GeneralFormValues) {
|
||||||
setSaveLoading(true);
|
setSaveLoading(true);
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
.post(`resource/${resource?.resourceId}`, {
|
.post<AxiosResponse<UpdateResourceResponse>>(
|
||||||
name: data.name,
|
`resource/${resource?.resourceId}`,
|
||||||
subdomain: data.subdomain,
|
{
|
||||||
proxyPort: data.proxyPort,
|
name: data.name,
|
||||||
isBaseDomain: data.isBaseDomain
|
subdomain: data.http ? data.subdomain : undefined,
|
||||||
})
|
proxyPort: data.proxyPort,
|
||||||
|
isBaseDomain: data.http ? data.isBaseDomain : undefined,
|
||||||
|
domainId: data.http ? data.domainId : undefined
|
||||||
|
}
|
||||||
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
|
@ -177,12 +226,17 @@ export default function GeneralForm() {
|
||||||
description: "The resource has been updated successfully"
|
description: "The resource has been updated successfully"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const resource = res.data.data;
|
||||||
|
|
||||||
updateResource({
|
updateResource({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
subdomain: data.subdomain,
|
subdomain: data.subdomain,
|
||||||
proxyPort: data.proxyPort,
|
proxyPort: data.proxyPort,
|
||||||
isBaseDomain: data.isBaseDomain
|
isBaseDomain: data.isBaseDomain,
|
||||||
|
fullDomain: resource.fullDomain
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
}
|
}
|
||||||
setSaveLoading(false);
|
setSaveLoading(false);
|
||||||
}
|
}
|
||||||
|
@ -211,323 +265,415 @@ export default function GeneralForm() {
|
||||||
description: "The resource has been transferred successfully"
|
description: "The resource has been transferred successfully"
|
||||||
});
|
});
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
|
updateResource({
|
||||||
|
siteName:
|
||||||
|
sites.find((site) => site.siteId === data.siteId)?.name ||
|
||||||
|
""
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setTransferLoading(false);
|
setTransferLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsContainer>
|
!loadingPage && (
|
||||||
<SettingsSection>
|
<SettingsContainer>
|
||||||
<SettingsSectionHeader>
|
<SettingsSection>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionHeader>
|
||||||
General Settings
|
<SettingsSectionTitle>
|
||||||
</SettingsSectionTitle>
|
General Settings
|
||||||
<SettingsSectionDescription>
|
</SettingsSectionTitle>
|
||||||
Configure the general settings for this resource
|
<SettingsSectionDescription>
|
||||||
</SettingsSectionDescription>
|
Configure the general settings for this resource
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<Form {...form}>
|
<Form {...form} key={formKey}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-4"
|
className="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||||
id="general-settings-form"
|
id="general-settings-form"
|
||||||
>
|
>
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
This is the display name of 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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="proxyPort"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>Name</FormLabel>
|
||||||
Port Number
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} />
|
||||||
type="number"
|
|
||||||
placeholder="Enter port number"
|
|
||||||
value={
|
|
||||||
field.value ?? ""
|
|
||||||
}
|
|
||||||
onChange={(e) =>
|
|
||||||
field.onChange(
|
|
||||||
e.target.value
|
|
||||||
? parseInt(
|
|
||||||
e
|
|
||||||
.target
|
|
||||||
.value
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
|
||||||
This is the port that will
|
|
||||||
be used to access the
|
|
||||||
resource.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</SettingsSectionForm>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
|
|
||||||
<SettingsSectionFooter>
|
{resource.http && (
|
||||||
<Button
|
<>
|
||||||
type="submit"
|
{env.flags
|
||||||
loading={saveLoading}
|
.allowBaseDomainResources && (
|
||||||
disabled={saveLoading}
|
<FormField
|
||||||
form="general-settings-form"
|
control={form.control}
|
||||||
>
|
name="isBaseDomain"
|
||||||
Save Settings
|
render={({ field }) => (
|
||||||
</Button>
|
<FormItem>
|
||||||
</SettingsSectionFooter>
|
<FormLabel>
|
||||||
</SettingsSection>
|
Domain Type
|
||||||
|
</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={
|
||||||
|
domainType
|
||||||
|
}
|
||||||
|
onValueChange={(
|
||||||
|
val
|
||||||
|
) => {
|
||||||
|
setDomainType(
|
||||||
|
val ===
|
||||||
|
"basedomain"
|
||||||
|
? "basedomain"
|
||||||
|
: "subdomain"
|
||||||
|
);
|
||||||
|
form.setValue(
|
||||||
|
"isBaseDomain",
|
||||||
|
val ===
|
||||||
|
"basedomain"
|
||||||
|
? true
|
||||||
|
: false
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="subdomain">
|
||||||
|
Subdomain
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="basedomain">
|
||||||
|
Base
|
||||||
|
Domain
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<SettingsSection>
|
<div className="col-span-2">
|
||||||
<SettingsSectionHeader>
|
{domainType === "subdomain" ? (
|
||||||
<SettingsSectionTitle>
|
<div className="w-fill space-y-2">
|
||||||
Transfer Resource
|
<FormLabel>
|
||||||
</SettingsSectionTitle>
|
Subdomain
|
||||||
<SettingsSectionDescription>
|
</FormLabel>
|
||||||
Transfer this resource to a different site
|
<div className="flex">
|
||||||
</SettingsSectionDescription>
|
<div className="w-1/2">
|
||||||
</SettingsSectionHeader>
|
<FormField
|
||||||
|
control={
|
||||||
<SettingsSectionBody>
|
form.control
|
||||||
<SettingsSectionForm>
|
}
|
||||||
<Form {...transferForm}>
|
name="subdomain"
|
||||||
<form
|
render={({
|
||||||
onSubmit={transferForm.handleSubmit(onTransfer)}
|
field
|
||||||
className="space-y-4"
|
}) => (
|
||||||
id="transfer-form"
|
<FormItem>
|
||||||
>
|
<FormControl>
|
||||||
<FormField
|
<Input
|
||||||
control={transferForm.control}
|
{...field}
|
||||||
name="siteId"
|
className="border-r-0 rounded-r-none"
|
||||||
render={({ field }) => (
|
/>
|
||||||
<FormItem className="flex flex-col">
|
</FormControl>
|
||||||
<FormLabel>
|
<FormMessage />
|
||||||
Destination Site
|
</FormItem>
|
||||||
</FormLabel>
|
)}
|
||||||
<Popover
|
/>
|
||||||
open={open}
|
</div>
|
||||||
onOpenChange={setOpen}
|
<div className="w-1/2">
|
||||||
>
|
<FormField
|
||||||
<PopoverTrigger asChild>
|
control={
|
||||||
<FormControl>
|
form.control
|
||||||
<Button
|
}
|
||||||
variant="outline"
|
name="domainId"
|
||||||
role="combobox"
|
render={({
|
||||||
className={cn(
|
field
|
||||||
"w-full justify-between",
|
}) => (
|
||||||
!field.value &&
|
<FormItem>
|
||||||
"text-muted-foreground"
|
<Select
|
||||||
)}
|
onValueChange={
|
||||||
>
|
field.onChange
|
||||||
{field.value
|
}
|
||||||
? sites.find(
|
defaultValue={
|
||||||
(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
|
field.value
|
||||||
? "opacity-100"
|
}
|
||||||
: "opacity-0"
|
value={
|
||||||
)}
|
field.value
|
||||||
/>
|
}
|
||||||
</CommandItem>
|
>
|
||||||
)
|
<FormControl>
|
||||||
)}
|
<SelectTrigger className="rounded-l-none">
|
||||||
</CommandGroup>
|
<SelectValue />
|
||||||
</Command>
|
</SelectTrigger>
|
||||||
</PopoverContent>
|
</FormControl>
|
||||||
</Popover>
|
<SelectContent>
|
||||||
<FormDescription>
|
{baseDomains.map(
|
||||||
Select the new site to transfer
|
(
|
||||||
this resource to.
|
option
|
||||||
</FormDescription>
|
) => (
|
||||||
<FormMessage />
|
<SelectItem
|
||||||
</FormItem>
|
key={
|
||||||
|
option.domainId
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
option.domainId
|
||||||
|
}
|
||||||
|
>
|
||||||
|
.
|
||||||
|
{
|
||||||
|
option.baseDomain
|
||||||
|
}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="domainId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Base Domain
|
||||||
|
</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
defaultValue={
|
||||||
|
field.value ||
|
||||||
|
baseDomains[0]
|
||||||
|
?.domainId
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{baseDomains.map(
|
||||||
|
(
|
||||||
|
option
|
||||||
|
) => (
|
||||||
|
<SelectItem
|
||||||
|
key={
|
||||||
|
option.domainId
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
option.domainId
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
option.baseDomain
|
||||||
|
}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</SettingsSectionForm>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
|
|
||||||
<SettingsSectionFooter>
|
{!resource.http && (
|
||||||
<Button
|
<FormField
|
||||||
type="submit"
|
control={form.control}
|
||||||
loading={transferLoading}
|
name="proxyPort"
|
||||||
disabled={transferLoading}
|
render={({ field }) => (
|
||||||
form="transfer-form"
|
<FormItem>
|
||||||
variant="destructive"
|
<FormLabel>
|
||||||
>
|
Port Number
|
||||||
Transfer Resource
|
</FormLabel>
|
||||||
</Button>
|
<FormControl>
|
||||||
</SettingsSectionFooter>
|
<Input
|
||||||
</SettingsSection>
|
type="number"
|
||||||
</SettingsContainer>
|
value={
|
||||||
|
field.value ??
|
||||||
|
""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(
|
||||||
|
e.target
|
||||||
|
.value
|
||||||
|
? parseInt(
|
||||||
|
e
|
||||||
|
.target
|
||||||
|
.value
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={saveLoading}
|
||||||
|
disabled={saveLoading}
|
||||||
|
form="general-settings-form"
|
||||||
|
>
|
||||||
|
Save Settings
|
||||||
|
</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>
|
||||||
|
<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" />
|
||||||
|
<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>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={transferLoading}
|
||||||
|
disabled={transferLoading}
|
||||||
|
form="transfer-form"
|
||||||
|
>
|
||||||
|
Transfer Resource
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
</SettingsContainer>
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -130,9 +130,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||||
<OrgProvider org={org}>
|
<OrgProvider org={org}>
|
||||||
<ResourceProvider resource={resource} authInfo={authInfo}>
|
<ResourceProvider resource={resource} authInfo={authInfo}>
|
||||||
<SidebarSettings sidebarNavItems={sidebarNavItems}>
|
<SidebarSettings sidebarNavItems={sidebarNavItems}>
|
||||||
<div className="mb-8">
|
<ResourceInfoBox />
|
||||||
<ResourceInfoBox />
|
|
||||||
</div>
|
|
||||||
{children}
|
{children}
|
||||||
</SidebarSettings>
|
</SidebarSettings>
|
||||||
</ResourceProvider>
|
</ResourceProvider>
|
||||||
|
|
|
@ -33,6 +33,7 @@ import {
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
TableCaption,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableContainer,
|
TableContainer,
|
||||||
TableHead,
|
TableHead,
|
||||||
|
@ -92,9 +93,9 @@ enum RuleAction {
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RuleMatch {
|
enum RuleMatch {
|
||||||
|
PATH = "Path",
|
||||||
IP = "IP",
|
IP = "IP",
|
||||||
CIDR = "IP Range",
|
CIDR = "IP Range"
|
||||||
PATH = "Path"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ResourceRules(props: {
|
export default function ResourceRules(props: {
|
||||||
|
@ -469,9 +470,9 @@ export default function ResourceRules(props: {
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
|
||||||
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
|
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
|
||||||
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
|
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
|
||||||
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
|
@ -524,7 +525,13 @@ export default function ResourceRules(props: {
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel()
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
state: {
|
||||||
|
pagination: {
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (pageLoading) {
|
if (pageLoading) {
|
||||||
|
@ -617,7 +624,7 @@ export default function ResourceRules(props: {
|
||||||
onSubmit={addRuleForm.handleSubmit(addRule)}
|
onSubmit={addRuleForm.handleSubmit(addRule)}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-end">
|
||||||
<FormField
|
<FormField
|
||||||
control={addRuleForm.control}
|
control={addRuleForm.control}
|
||||||
name="action"
|
name="action"
|
||||||
|
@ -665,17 +672,17 @@ export default function ResourceRules(props: {
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
{resource.http && (
|
||||||
|
<SelectItem value="PATH">
|
||||||
|
{RuleMatch.PATH}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
<SelectItem value="IP">
|
<SelectItem value="IP">
|
||||||
{RuleMatch.IP}
|
{RuleMatch.IP}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="CIDR">
|
<SelectItem value="CIDR">
|
||||||
{RuleMatch.CIDR}
|
{RuleMatch.CIDR}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
{resource.http && (
|
|
||||||
<SelectItem value="PATH">
|
|
||||||
{RuleMatch.PATH}
|
|
||||||
</SelectItem>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
@ -705,68 +712,63 @@ export default function ResourceRules(props: {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outlinePrimary"
|
||||||
|
disabled={!rulesEnabled}
|
||||||
|
>
|
||||||
|
Add Rule
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outline"
|
|
||||||
disabled={!rulesEnabled}
|
|
||||||
>
|
|
||||||
Add Rule
|
|
||||||
</Button>
|
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
<TableContainer>
|
<Table>
|
||||||
<Table>
|
<TableHeader>
|
||||||
<TableHeader>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
<TableRow key={headerGroup.id}>
|
||||||
<TableRow key={headerGroup.id}>
|
{headerGroup.headers.map((header) => (
|
||||||
{headerGroup.headers.map((header) => (
|
<TableHead key={header.id}>
|
||||||
<TableHead key={header.id}>
|
{header.isPlaceholder
|
||||||
{header.isPlaceholder
|
? null
|
||||||
? null
|
: flexRender(
|
||||||
: flexRender(
|
header.column.columnDef
|
||||||
header.column
|
.header,
|
||||||
.columnDef.header,
|
header.getContext()
|
||||||
header.getContext()
|
)}
|
||||||
)}
|
</TableHead>
|
||||||
</TableHead>
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))
|
||||||
</TableHeader>
|
) : (
|
||||||
<TableBody>
|
<TableRow>
|
||||||
{table.getRowModel().rows?.length ? (
|
<TableCell
|
||||||
table.getRowModel().rows.map((row) => (
|
colSpan={columns.length}
|
||||||
<TableRow key={row.id}>
|
className="h-24 text-center"
|
||||||
{row
|
>
|
||||||
.getVisibleCells()
|
No rules. Add a rule using the form.
|
||||||
.map((cell) => (
|
</TableCell>
|
||||||
<TableCell key={cell.id}>
|
</TableRow>
|
||||||
{flexRender(
|
)}
|
||||||
cell.column
|
</TableBody>
|
||||||
.columnDef.cell,
|
<TableCaption>
|
||||||
cell.getContext()
|
Rules are evaluated by priority in ascending order.
|
||||||
)}
|
</TableCaption>
|
||||||
</TableCell>
|
</Table>
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={columns.length}
|
|
||||||
className="h-24 text-center"
|
|
||||||
>
|
|
||||||
No rules. Add a rule using the form.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Rules are evaluated by priority in ascending order.
|
|
||||||
</p>
|
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
<SettingsSectionFooter>
|
<SettingsSectionFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -107,7 +107,12 @@ export default function CreateShareLinkForm({
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const [resources, setResources] = useState<
|
const [resources, setResources] = useState<
|
||||||
{ resourceId: number; name: string; resourceUrl: string }[]
|
{
|
||||||
|
resourceId: number;
|
||||||
|
name: string;
|
||||||
|
resourceUrl: string;
|
||||||
|
siteName: string | null;
|
||||||
|
}[]
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
const timeUnits = [
|
const timeUnits = [
|
||||||
|
@ -152,13 +157,16 @@ export default function CreateShareLinkForm({
|
||||||
|
|
||||||
if (res?.status === 200) {
|
if (res?.status === 200) {
|
||||||
setResources(
|
setResources(
|
||||||
res.data.data.resources.filter((r) => {
|
res.data.data.resources
|
||||||
return r.http;
|
.filter((r) => {
|
||||||
}).map((r) => ({
|
return r.http;
|
||||||
resourceId: r.resourceId,
|
})
|
||||||
name: r.name,
|
.map((r) => ({
|
||||||
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
|
resourceId: r.resourceId,
|
||||||
}))
|
name: r.name,
|
||||||
|
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`,
|
||||||
|
siteName: r.siteName
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -229,19 +237,28 @@ export default function CreateShareLinkForm({
|
||||||
token.accessToken
|
token.accessToken
|
||||||
);
|
);
|
||||||
setDirectLink(directLink);
|
setDirectLink(directLink);
|
||||||
|
|
||||||
|
const resource = resources.find((r) => r.resourceId === values.resourceId);
|
||||||
|
|
||||||
onCreated?.({
|
onCreated?.({
|
||||||
accessTokenId: token.accessTokenId,
|
accessTokenId: token.accessTokenId,
|
||||||
resourceId: token.resourceId,
|
resourceId: token.resourceId,
|
||||||
resourceName: values.resourceName,
|
resourceName: values.resourceName,
|
||||||
title: token.title,
|
title: token.title,
|
||||||
createdAt: token.createdAt,
|
createdAt: token.createdAt,
|
||||||
expiresAt: token.expiresAt
|
expiresAt: token.expiresAt,
|
||||||
|
siteName: resource?.siteName || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSelectedResourceName(id: number) {
|
||||||
|
const resource = resources.find((r) => r.resourceId === id);
|
||||||
|
return `${resource?.name} ${resource?.siteName ? `(${resource.siteName})` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Credenza
|
<Credenza
|
||||||
|
@ -274,7 +291,7 @@ export default function CreateShareLinkForm({
|
||||||
name="resourceId"
|
name="resourceId"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-col">
|
<FormItem className="flex flex-col">
|
||||||
<FormLabel className="mb-2">
|
<FormLabel>
|
||||||
Resource
|
Resource
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<Popover>
|
<Popover>
|
||||||
|
@ -290,14 +307,9 @@ export default function CreateShareLinkForm({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{field.value
|
{field.value
|
||||||
? resources.find(
|
? getSelectedResourceName(
|
||||||
(
|
field.value
|
||||||
r
|
|
||||||
) =>
|
|
||||||
r.resourceId ===
|
|
||||||
field.value
|
|
||||||
)
|
)
|
||||||
?.name
|
|
||||||
: "Select resource"}
|
: "Select resource"}
|
||||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -305,7 +317,7 @@ export default function CreateShareLinkForm({
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0">
|
<PopoverContent className="p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="Search resources..." />
|
<CommandInput placeholder="Search resources" />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
No
|
No
|
||||||
|
@ -318,9 +330,7 @@ export default function CreateShareLinkForm({
|
||||||
r
|
r
|
||||||
) => (
|
) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={
|
value={`${r.name}:${r.resourceId}`}
|
||||||
`${r.name}:${r.resourceId}`
|
|
||||||
}
|
|
||||||
key={
|
key={
|
||||||
r.resourceId
|
r.resourceId
|
||||||
}
|
}
|
||||||
|
@ -348,9 +358,7 @@ export default function CreateShareLinkForm({
|
||||||
: "opacity-0"
|
: "opacity-0"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{
|
{`${r.name} ${r.siteName ? `(${r.siteName})` : ""}`}
|
||||||
r.name
|
|
||||||
}
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
@ -369,14 +377,11 @@ export default function CreateShareLinkForm({
|
||||||
name="title"
|
name="title"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<Label>
|
<FormLabel>
|
||||||
Title (optional)
|
Title (optional)
|
||||||
</Label>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} />
|
||||||
placeholder="Enter title"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
@ -384,67 +389,68 @@ export default function CreateShareLinkForm({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Label>Expire In</Label>
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-2 gap-4 mt-2">
|
<FormLabel>Expire In</FormLabel>
|
||||||
<FormField
|
<div className="grid grid-cols-2 gap-4">
|
||||||
control={form.control}
|
<FormField
|
||||||
name="timeUnit"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="timeUnit"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<Select
|
<FormItem>
|
||||||
onValueChange={
|
<Select
|
||||||
field.onChange
|
onValueChange={
|
||||||
}
|
field.onChange
|
||||||
defaultValue={field.value.toString()}
|
}
|
||||||
>
|
defaultValue={field.value.toString()}
|
||||||
<FormControl>
|
>
|
||||||
<SelectTrigger>
|
<FormControl>
|
||||||
<SelectValue placeholder="Select duration" />
|
<SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectValue placeholder="Select duration" />
|
||||||
</FormControl>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
</FormControl>
|
||||||
{timeUnits.map(
|
<SelectContent>
|
||||||
(
|
{timeUnits.map(
|
||||||
option
|
(
|
||||||
) => (
|
option
|
||||||
<SelectItem
|
) => (
|
||||||
key={
|
<SelectItem
|
||||||
option.unit
|
key={
|
||||||
}
|
option.unit
|
||||||
value={
|
}
|
||||||
option.unit
|
value={
|
||||||
}
|
option.unit
|
||||||
>
|
}
|
||||||
{
|
>
|
||||||
option.name
|
{
|
||||||
}
|
option.name
|
||||||
</SelectItem>
|
}
|
||||||
)
|
</SelectItem>
|
||||||
)}
|
)
|
||||||
</SelectContent>
|
)}
|
||||||
</Select>
|
</SelectContent>
|
||||||
<FormMessage />
|
</Select>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="timeValue"
|
name="timeValue"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
placeholder="Enter duration"
|
{...field}
|
||||||
{...field}
|
/>
|
||||||
/>
|
</FormControl>
|
||||||
</FormControl>
|
<FormMessage />
|
||||||
<FormMessage />
|
</FormItem>
|
||||||
</FormItem>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
@ -554,6 +560,9 @@ export default function CreateShareLinkForm({
|
||||||
</div>
|
</div>
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
<CredenzaFooter>
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</CredenzaClose>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={form.handleSubmit(onSubmit)}
|
onClick={form.handleSubmit(onSubmit)}
|
||||||
|
@ -562,9 +571,6 @@ export default function CreateShareLinkForm({
|
||||||
>
|
>
|
||||||
Create Link
|
Create Link
|
||||||
</Button>
|
</Button>
|
||||||
<CredenzaClose asChild>
|
|
||||||
<Button variant="outline">Close</Button>
|
|
||||||
</CredenzaClose>
|
|
||||||
</CredenzaFooter>
|
</CredenzaFooter>
|
||||||
</CredenzaContent>
|
</CredenzaContent>
|
||||||
</Credenza>
|
</Credenza>
|
||||||
|
|
|
@ -41,6 +41,7 @@ export type ShareLinkRow = {
|
||||||
title: string | null;
|
title: string | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
expiresAt: number | null;
|
expiresAt: number | null;
|
||||||
|
siteName: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ShareLinksTableProps = {
|
type ShareLinksTableProps = {
|
||||||
|
@ -145,7 +146,8 @@ export default function ShareLinksTable({
|
||||||
return (
|
return (
|
||||||
<Link href={`/${orgId}/settings/resources/${r.resourceId}`}>
|
<Link href={`/${orgId}/settings/resources/${r.resourceId}`}>
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
{r.resourceName}
|
{r.resourceName}{" "}
|
||||||
|
{r.siteName ? `(${r.siteName})` : ""}
|
||||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -273,6 +275,21 @@ export default function ShareLinksTable({
|
||||||
}
|
}
|
||||||
return "Never";
|
return "Never";
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "delete",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center justify-end space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outlinePrimary"
|
||||||
|
onClick={() =>
|
||||||
|
deleteSharelink(row.original.accessTokenId)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -41,6 +41,7 @@ import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
|
Loader2,
|
||||||
SquareArrowOutUpRight
|
SquareArrowOutUpRight
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
@ -48,6 +49,7 @@ import {
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger
|
CollapsibleTrigger
|
||||||
} from "@app/components/ui/collapsible";
|
} from "@app/components/ui/collapsible";
|
||||||
|
import LoaderPlaceholder from "@app/components/PlaceHolderLoader";
|
||||||
|
|
||||||
const createSiteFormSchema = z.object({
|
const createSiteFormSchema = z.object({
|
||||||
name: z
|
name: z
|
||||||
|
@ -97,6 +99,8 @@ export default function CreateSiteForm({
|
||||||
const [siteDefaults, setSiteDefaults] =
|
const [siteDefaults, setSiteDefaults] =
|
||||||
useState<PickSiteDefaultsResponse | null>(null);
|
useState<PickSiteDefaultsResponse | null>(null);
|
||||||
|
|
||||||
|
const [loadingPage, setLoadingPage] = useState(true);
|
||||||
|
|
||||||
const handleCheckboxChange = (checked: boolean) => {
|
const handleCheckboxChange = (checked: boolean) => {
|
||||||
// setChecked?.(checked);
|
// setChecked?.(checked);
|
||||||
setIsChecked(checked);
|
setIsChecked(checked);
|
||||||
|
@ -121,27 +125,36 @@ export default function CreateSiteForm({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
// reset all values
|
const load = async () => {
|
||||||
setLoading?.(false);
|
setLoadingPage(true);
|
||||||
setIsLoading(false);
|
// reset all values
|
||||||
form.reset();
|
setLoading?.(false);
|
||||||
setChecked?.(false);
|
setIsLoading(false);
|
||||||
setKeypair(null);
|
form.reset();
|
||||||
setSiteDefaults(null);
|
setChecked?.(false);
|
||||||
|
setKeypair(null);
|
||||||
|
setSiteDefaults(null);
|
||||||
|
|
||||||
const generatedKeypair = generateKeypair();
|
const generatedKeypair = generateKeypair();
|
||||||
setKeypair(generatedKeypair);
|
setKeypair(generatedKeypair);
|
||||||
|
|
||||||
api.get(`/org/${orgId}/pick-site-defaults`)
|
await api
|
||||||
.catch((e) => {
|
.get(`/org/${orgId}/pick-site-defaults`)
|
||||||
// update the default value of the form to be local method
|
.catch((e) => {
|
||||||
form.setValue("method", "local");
|
// update the default value of the form to be local method
|
||||||
})
|
form.setValue("method", "local");
|
||||||
.then((res) => {
|
})
|
||||||
if (res && res.status === 200) {
|
.then((res) => {
|
||||||
setSiteDefaults(res.data.data);
|
if (res && res.status === 200) {
|
||||||
}
|
setSiteDefaults(res.data.data);
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
setLoadingPage(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
async function onSubmit(data: CreateSiteFormValues) {
|
async function onSubmit(data: CreateSiteFormValues) {
|
||||||
|
@ -257,7 +270,9 @@ PersistentKeepalive = 5`
|
||||||
|
|
||||||
const newtConfigDockerRun = `docker run -it fosrl/newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`;
|
const newtConfigDockerRun = `docker run -it fosrl/newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`;
|
||||||
|
|
||||||
return (
|
return loadingPage ? (
|
||||||
|
<LoaderPlaceholder height="300px" />
|
||||||
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
|
@ -272,17 +287,12 @@ PersistentKeepalive = 5`
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel>Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input autoComplete="off" {...field} />
|
||||||
autoComplete="off"
|
|
||||||
placeholder="Site name"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
|
||||||
This is the name that will be displayed for
|
|
||||||
this site.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
This is the the display name for the site.
|
||||||
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -319,10 +329,10 @@ PersistentKeepalive = 5`
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
This is how you will expose connections.
|
This is how you will expose connections.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -335,7 +345,6 @@ PersistentKeepalive = 5`
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{" "}
|
|
||||||
Learn how to install Newt on your system
|
Learn how to install Newt on your system
|
||||||
</span>
|
</span>
|
||||||
<SquareArrowOutUpRight size={14} />
|
<SquareArrowOutUpRight size={14} />
|
||||||
|
@ -354,7 +363,7 @@ PersistentKeepalive = 5`
|
||||||
) : form.watch("method") === "wireguard" &&
|
) : form.watch("method") === "wireguard" &&
|
||||||
isLoading ? (
|
isLoading ? (
|
||||||
<p>Loading WireGuard configuration...</p>
|
<p>Loading WireGuard configuration...</p>
|
||||||
) : form.watch("method") === "newt" ? (
|
) : form.watch("method") === "newt" && siteDefaults ? (
|
||||||
<>
|
<>
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<Collapsible
|
<Collapsible
|
||||||
|
@ -362,12 +371,16 @@ PersistentKeepalive = 5`
|
||||||
onOpenChange={setIsOpen}
|
onOpenChange={setIsOpen}
|
||||||
className="space-y-2"
|
className="space-y-2"
|
||||||
>
|
>
|
||||||
<div className="mx-auto">
|
<div className="mx-auto mb-2">
|
||||||
<CopyTextBox
|
<CopyTextBox
|
||||||
text={newtConfig}
|
text={newtConfig}
|
||||||
wrapText={false}
|
wrapText={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
You will only be able to see the
|
||||||
|
configuration once.
|
||||||
|
</span>
|
||||||
<div className="flex items-center justify-between space-x-4">
|
<div className="flex items-center justify-between space-x-4">
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
@ -376,8 +389,8 @@ PersistentKeepalive = 5`
|
||||||
className="p-0 flex items-center justify-between w-full"
|
className="p-0 flex items-center justify-between w-full"
|
||||||
>
|
>
|
||||||
<h4 className="text-sm font-semibold">
|
<h4 className="text-sm font-semibold">
|
||||||
Expand for Docker Deployment
|
Expand for Docker
|
||||||
Details
|
Deployment Details
|
||||||
</h4>
|
</h4>
|
||||||
<div>
|
<div>
|
||||||
<ChevronsUpDown className="h-4 w-4" />
|
<ChevronsUpDown className="h-4 w-4" />
|
||||||
|
@ -409,10 +422,6 @@ PersistentKeepalive = 5`
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
You will only be able to see the
|
|
||||||
configuration once.
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -58,6 +58,9 @@ export default function CreateSiteFormModal({
|
||||||
</div>
|
</div>
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
<CredenzaFooter>
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</CredenzaClose>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
form="create-site-form"
|
form="create-site-form"
|
||||||
|
@ -69,9 +72,6 @@ export default function CreateSiteFormModal({
|
||||||
>
|
>
|
||||||
Create Site
|
Create Site
|
||||||
</Button>
|
</Button>
|
||||||
<CredenzaClose asChild>
|
|
||||||
<Button variant="outline">Close</Button>
|
|
||||||
</CredenzaClose>
|
|
||||||
</CredenzaFooter>
|
</CredenzaFooter>
|
||||||
</CredenzaContent>
|
</CredenzaContent>
|
||||||
</Credenza>
|
</Credenza>
|
||||||
|
|
|
@ -268,7 +268,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||||
<Link
|
<Link
|
||||||
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
||||||
>
|
>
|
||||||
<Button variant={"outline"} className="ml-2">
|
<Button variant={"outlinePrimary"} className="ml-2">
|
||||||
Edit
|
Edit
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { InfoIcon } from "lucide-react";
|
import { InfoIcon } from "lucide-react";
|
||||||
import { useSiteContext } from "@app/hooks/useSiteContext";
|
import { useSiteContext } from "@app/hooks/useSiteContext";
|
||||||
import { Separator } from "@app/components/ui/separator";
|
|
||||||
import {
|
import {
|
||||||
InfoSection,
|
InfoSection,
|
||||||
InfoSectionContent,
|
InfoSectionContent,
|
||||||
|
@ -33,7 +32,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||||
<InfoIcon className="h-4 w-4" />
|
<InfoIcon className="h-4 w-4" />
|
||||||
<AlertTitle className="font-semibold">Site Information</AlertTitle>
|
<AlertTitle className="font-semibold">Site Information</AlertTitle>
|
||||||
<AlertDescription className="mt-4">
|
<AlertDescription className="mt-4">
|
||||||
<InfoSections>
|
<InfoSections cols={2}>
|
||||||
{(site.type == "newt" || site.type == "wireguard") && (
|
{(site.type == "newt" || site.type == "wireguard") && (
|
||||||
<>
|
<>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
|
@ -52,8 +51,6 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
|
|
||||||
<Separator orientation="vertical" />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
|
|
|
@ -33,7 +33,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
const GeneralFormSchema = z.object({
|
const GeneralFormSchema = z.object({
|
||||||
name: z.string()
|
name: z.string().nonempty("Name is required")
|
||||||
});
|
});
|
||||||
|
|
||||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||||
|
@ -114,11 +114,11 @@ export default function GeneralPage() {
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
This is the display name of the
|
This is the display name of the
|
||||||
site
|
site.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -68,9 +68,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||||
|
|
||||||
<SiteProvider site={site}>
|
<SiteProvider site={site}>
|
||||||
<SidebarSettings sidebarNavItems={sidebarNavItems}>
|
<SidebarSettings sidebarNavItems={sidebarNavItems}>
|
||||||
<div className="mb-8">
|
<SiteInfoCard />
|
||||||
<SiteInfoCard />
|
|
||||||
</div>
|
|
||||||
{children}
|
{children}
|
||||||
</SidebarSettings>
|
</SidebarSettings>
|
||||||
</SiteProvider>
|
</SiteProvider>
|
||||||
|
|
|
@ -41,7 +41,7 @@ export default async function Page(props: {
|
||||||
Looks like you've been invited!
|
Looks like you've been invited!
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-center">
|
<p className="text-center">
|
||||||
To accept the invite, you must login or create an
|
To accept the invite, you must log in or create an
|
||||||
account.
|
account.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -38,7 +38,7 @@ import { Loader2 } from "lucide-react";
|
||||||
import { Alert, AlertDescription } from "../../../components/ui/alert";
|
import { Alert, AlertDescription } from "../../../components/ui/alert";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { formatAxiosError } from "@app/lib/api";;
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||||
|
@ -182,7 +182,7 @@ export default function ResetPasswordForm({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSuccessMessage("Password reset successfully! Back to login...");
|
setSuccessMessage("Password reset successfully! Back to log in...");
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
|
@ -223,16 +223,13 @@ export default function ResetPasswordForm({
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} />
|
||||||
placeholder="Enter your email"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
We'll send a password reset
|
We'll send a password reset
|
||||||
code to this email address.
|
code to this email address.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -255,7 +252,6 @@ export default function ResetPasswordForm({
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Email"
|
|
||||||
{...field}
|
{...field}
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
|
@ -276,12 +272,15 @@ export default function ResetPasswordForm({
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter reset code sent to your email"
|
|
||||||
type="password"
|
type="password"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
Check your email for the
|
||||||
|
reset code.
|
||||||
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -298,7 +297,6 @@ export default function ResetPasswordForm({
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
@ -317,7 +315,6 @@ export default function ResetPasswordForm({
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Confirm Password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
@ -349,7 +346,9 @@ export default function ResetPasswordForm({
|
||||||
<InputOTP
|
<InputOTP
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
{...field}
|
{...field}
|
||||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
pattern={
|
||||||
|
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<InputOTPGroup>
|
<InputOTPGroup>
|
||||||
<InputOTPSlot
|
<InputOTPSlot
|
||||||
|
|
|
@ -263,7 +263,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAllowed) {
|
if (isAllowed) {
|
||||||
window.location.href = props.redirect;
|
// window.location.href = props.redirect;
|
||||||
|
router.refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -448,7 +449,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter password"
|
|
||||||
type="password"
|
type="password"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
|
@ -517,7 +517,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter email"
|
|
||||||
type="email"
|
type="email"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
|
@ -576,7 +575,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter OTP"
|
|
||||||
type="password"
|
type="password"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -145,7 +145,7 @@ export default function SignupForm({
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Email" {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
@ -160,7 +160,6 @@ export default function SignupForm({
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
@ -177,7 +176,6 @@ export default function SignupForm({
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Confirm Password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
|
@ -57,7 +57,7 @@ export default async function Page(props: {
|
||||||
Looks like you've been invited!
|
Looks like you've been invited!
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-center">
|
<p className="text-center">
|
||||||
To accept the invite, you must login or create an
|
To accept the invite, you must log in or create an
|
||||||
account.
|
account.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -145,7 +145,6 @@ export default function VerifyEmailForm({
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Email"
|
|
||||||
{...field}
|
{...field}
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
|
@ -196,12 +195,11 @@ export default function VerifyEmailForm({
|
||||||
</InputOTP>
|
</InputOTP>
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
We sent a verification code to your
|
We sent a verification code to your
|
||||||
email address. Please enter the code
|
email address.
|
||||||
to verify your email address.
|
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -21,8 +21,8 @@
|
||||||
--accent-foreground: 24 9.8% 10%;
|
--accent-foreground: 24 9.8% 10%;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 60 9.1% 97.8%;
|
--destructive-foreground: 60 9.1% 97.8%;
|
||||||
--border: 20 5.9% 85%;
|
--border: 20 5.9% 80%;
|
||||||
--input: 20 5.9% 85%;
|
--input: 20 5.9% 75%;
|
||||||
--ring: 24.6 95% 53.1%;
|
--ring: 24.6 95% 53.1%;
|
||||||
--radius: 0.75rem;
|
--radius: 0.75rem;
|
||||||
--chart-1: 12 76% 61%;
|
--chart-1: 12 76% 61%;
|
||||||
|
@ -49,8 +49,8 @@
|
||||||
--accent-foreground: 60 9.1% 97.8%;
|
--accent-foreground: 60 9.1% 97.8%;
|
||||||
--destructive: 0 72.2% 50.6%;
|
--destructive: 0 72.2% 50.6%;
|
||||||
--destructive-foreground: 60 9.1% 97.8%;
|
--destructive-foreground: 60 9.1% 97.8%;
|
||||||
--border: 12 6.5% 25.0%;
|
--border: 12 6.5% 30.0%;
|
||||||
--input: 12 6.5% 25.0%;
|
--input: 12 6.5% 35.0%;
|
||||||
--ring: 20.5 90.2% 48.2%;
|
--ring: 20.5 90.2% 48.2%;
|
||||||
--chart-1: 220 70% 50%;
|
--chart-1: 220 70% 50%;
|
||||||
--chart-2: 160 60% 45%;
|
--chart-2: 160 60% 45%;
|
||||||
|
|