Merge branch 'main' into holepunch

This commit is contained in:
Owen 2025-03-10 21:13:05 -04:00
commit 1f11a1df02
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
129 changed files with 21424 additions and 2236 deletions

View file

@ -22,7 +22,6 @@ next-env.d.ts
*.log
.machinelogs*.json
*-audit.json
package-lock.json
install/
bruno/
LICENSE

View file

@ -64,7 +64,7 @@ jobs:
- name: Build installer
working-directory: install
run: |
make release
make go-build-release
- name: Upload artifacts from /install/bin
uses: actions/upload-artifact@v4

1
.gitignore vendored
View file

@ -23,7 +23,6 @@ next-env.d.ts
.machinelogs*.json
*-audit.json
migrations
package-lock.json
tsconfig.tsbuildinfo
config/config.yml
dist

View file

@ -2,9 +2,8 @@ FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
@ -14,21 +13,19 @@ RUN npm run build
FROM node:20-alpine AS runner
RUN apk add --no-cache curl
WORKDIR /app
COPY package.json ./
# Curl used for the health checks
RUN apk add --no-cache curl
RUN npm install --omit=dev
COPY package.json package-lock.json ./
RUN npm ci --only=production && npm cache clean --force
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/init ./dist/init
COPY config/config.example.yml ./dist/config.example.yml
COPY config/traefik/traefik_config.example.yml ./dist/traefik_config.example.yml
COPY config/traefik/dynamic_config.example.yml ./dist/dynamic_config.example.yml
COPY server/db/names.json ./dist/names.json
COPY public ./public

View file

@ -9,6 +9,13 @@
</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">
<h5>
<a href="https://docs.fossorial.io/Getting%20Started/quick-install">
@ -18,16 +25,13 @@
<a href="https://docs.fossorial.io">
Full Documentation
</a>
<span> | </span>
<a href="mailto:numbat@fossorial.io">
Contact Us
</a>
</h5>
</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.
<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
- 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.
- Use your preferred WireGuard client to connect, or use Newt, our custom user space client for the best experience.
- Use any WireGuard client to connect, or use **Newt, our custom user space client** for the best experience.
### Modular Design
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [Fail2Ban](https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban) or [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin).
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin) and [Geoblock](github.com/PascalMinder/geoblock).
- **Automatically install and configure Crowdsec via Pangolin's installer script.**
- Attach as many sites to the central server as you wish.
## Screenshots
<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>
<img src="public/screenshots/collage.png" alt="Collage"/>
## Deployment and Usage Example
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**:
@ -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.
- Automatically establish a connection from these sites to the central server.
4. **Configure Users & Roles**
4. **Expose Resources**:
- Define organizations and invite users.
- Implement user- or role-based permissions to control resource access.
- Add resources to the central server and configure access control rules.
- Access these resources securely from anywhere.
**Use Case Example - Bypassing Port Restrictions in Home Lab**:
Imagine private sites where the ISP restricts port forwarding. By connecting these sites to Pangolin via WireGuard, you can securely expose HTTP and HTTPS resources on the private network without any networking complexity.
@ -130,6 +114,11 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
**Use Case Example - IoT Networks**:
IoT networks are often fragmented and difficult to manage. By deploying Pangolin on a central server, you can connect all your IoT sites via Newt or another WireGuard client. This creates a simple, secure, and centralized way to access IoT resources without the need for intricate networking setups.
<img src="public/screenshots/resources.png" alt="Resources"/>
_Resources page of Pangolin dashboard (dark mode) showing HTTPS and TCP resources with access control rules._
## Similar Projects and Inspirations
**Cloudflare Tunnels**:
@ -147,7 +136,7 @@ View the [project board](https://github.com/orgs/fosrl/projects/1) for more deta
## 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

View file

@ -1,9 +1,16 @@
# To see all available options, please visit the docs:
# https://docs.fossorial.io/Pangolin/Configuration/config
app:
dashboard_url: "http://localhost:3002"
base_domain: "localhost"
log_level: "info"
save_logs: false
domains:
domain1:
base_domain: "example.com"
cert_resolver: "letsencrypt"
server:
external_port: 3000
internal_port: 3001
@ -14,7 +21,6 @@ server:
resource_session_request_param: "p_session_request"
traefik:
cert_resolver: "letsencrypt"
http_entrypoint: "web"
https_entrypoint: "websecure"

View file

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

View file

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

View file

@ -1,5 +1,4 @@
version: "3.7"
name: pangolin
services:
pangolin:
image: fosrl/pangolin:latest
@ -32,7 +31,6 @@ services:
- SYS_MODULE
ports:
- 51820:51820/udp
- 8080:8080 # Port for traefik because of the network_mode
- 443:443 # Port for traefik because of the network_mode
- 80:80 # Port for traefik because of the network_mode
@ -47,8 +45,8 @@ services:
command:
- --configFile=/etc/traefik/traefik_config.yml
volumes:
- ./traefik:/etc/traefik:ro # Volume to store the Traefik configuration
- ./letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
networks:
default:

View file

@ -1,13 +1,24 @@
all: build
all: update-versions go-build-release put-back
build:
CGO_ENABLED=0 go build -o bin/installer
release:
go-build-release:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
clean:
rm -f bin/installer
rm -f bin/installer_linux_amd64
rm -f bin/installer_linux_arm64
update-versions:
@echo "Fetching latest versions..."
cp main.go main.go.bak && \
PANGOLIN_VERSION=$$(curl -s https://api.github.com/repos/fosrl/pangolin/tags | jq -r '.[0].name') && \
GERBIL_VERSION=$$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') && \
BADGER_VERSION=$$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') && \
echo "Latest versions - Pangolin: $$PANGOLIN_VERSION, Gerbil: $$GERBIL_VERSION, Badger: $$BADGER_VERSION" && \
sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$$PANGOLIN_VERSION\"/" main.go && \
sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$$GERBIL_VERSION\"/" main.go && \
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$$BADGER_VERSION\"/" main.go && \
echo "Updated main.go with latest versions"
put-back:
mv main.go.bak main.go

353
install/config.go Normal file
View 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
}

View file

@ -1,9 +1,16 @@
# To see all available options, please visit the docs:
# https://docs.fossorial.io/Pangolin/Configuration/config
app:
dashboard_url: "https://{{.DashboardDomain}}"
base_domain: "{{.BaseDomain}}"
log_level: "info"
save_logs: false
domains:
domain1:
base_domain: "{{.BaseDomain}}"
cert_resolver: "letsencrypt"
server:
external_port: 3000
internal_port: 3001
@ -22,7 +29,6 @@ traefik:
cert_resolver: "letsencrypt"
http_entrypoint: "web"
https_entrypoint: "websecure"
prefer_wildcard_cert: false
gerbil:
start_port: 51820

View 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

View 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

View 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

View 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

View 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

View file

@ -1,3 +1,4 @@
name: pangolin
services:
pangolin:
image: fosrl/pangolin:{{.PangolinVersion}}
@ -10,7 +11,6 @@ services:
interval: "3s"
timeout: "3s"
retries: 5
{{if .InstallGerbil}}
gerbil:
image: fosrl/gerbil:{{.GerbilVersion}}
@ -34,15 +34,13 @@ services:
- 443:443 # Port for traefik because of the network_mode
- 80:80 # Port for traefik because of the network_mode
{{end}}
traefik:
image: traefik:v3.3.3
container_name: traefik
restart: unless-stopped
{{if .InstallGerbil}}
network_mode: service:gerbil # Ports appear on the gerbil service
{{end}}
{{if not .InstallGerbil}}
{{end}}{{if not .InstallGerbil}}
ports:
- 443:443
- 80:80
@ -55,6 +53,7 @@ services:
volumes:
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
networks:
default:

137
install/crowdsec.go Normal file
View 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))
}

View file

@ -5,4 +5,5 @@ go 1.23.0
require (
golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View file

@ -2,3 +2,6 @@ golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

12
install/input.txt Normal file
View file

@ -0,0 +1,12 @@
example.com
pangolin.example.com
admin@example.com
yes
admin@example.com
Password123!
Password123!
yes
no
no
no
yes

View file

@ -4,13 +4,16 @@ import (
"bufio"
"embed"
"fmt"
"io"
"io/fs"
"os"
"time"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
"bytes"
"text/template"
"unicode"
@ -24,7 +27,7 @@ func loadVersions(config *Config) {
config.BadgerVersion = "replaceme"
}
//go:embed fs/*
//go:embed config/*
var configFiles embed.FS
type Config struct {
@ -45,6 +48,8 @@ type Config struct {
EmailSMTPPass string
EmailNoReply string
InstallGerbil bool
TraefikBouncerKey string
DoCrowdsecInstall bool
}
func main() {
@ -56,9 +61,12 @@ func main() {
os.Exit(1)
}
var config Config
config.DoCrowdsecInstall = false
// check if there is already a config file
if _, err := os.Stat("config/config.yml"); err != nil {
config := collectUserInput(reader)
config = collectUserInput(reader)
loadVersions(&config)
@ -67,18 +75,53 @@ func main() {
os.Exit(1)
}
moveFile("config/docker-compose.yml", "docker-compose.yml")
if !isDockerInstalled() && runtime.GOOS == "linux" {
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
installDocker()
}
}
fmt.Println("\n=== Starting installation ===")
if isDockerInstalled() {
if readBool(reader, "Would you like to install and start the containers?", true) {
pullAndStartContainers()
}
}
} else {
fmt.Println("Config file already exists... skipping configuration")
fmt.Println("Looks like you already installed, so I am going to do the setup...")
}
if isDockerInstalled() {
if readBool(reader, "Would you like to install and start the containers?", true) {
pullAndStartContainers()
if !checkIsCrowdsecInstalledInCompose() {
fmt.Println("\n=== Crowdsec Install ===")
// check if crowdsec is installed
if readBool(reader, "Would you like to install Crowdsec?", 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
}
func readPassword(prompt string) string {
fmt.Print(prompt + ": ")
// Read password without echo
password, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println() // Add a newline since ReadPassword doesn't add one
if err != nil {
return ""
}
input := strings.TrimSpace(string(password))
if input == "" {
return readPassword(prompt)
}
return input
func readPassword(prompt string, reader *bufio.Reader) string {
if term.IsTerminal(int(syscall.Stdin)) {
fmt.Print(prompt + ": ")
// Read password without echo if we're in a terminal
password, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println() // Add a newline since ReadPassword doesn't add one
if err != nil {
return ""
}
input := strings.TrimSpace(string(password))
if input == "" {
return readPassword(prompt, reader)
}
return input
} else {
// Fallback to reading from stdin if not in a terminal
return readString(reader, prompt, "")
}
}
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 ===")
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain)
for {
pass1 := readPassword("Create admin user password")
pass2 := readPassword("Confirm admin user password")
pass1 := readPassword("Create admin user password", reader)
pass2 := readPassword("Confirm admin user password", reader)
if pass1 != pass2 {
fmt.Println("Passwords do not match")
@ -261,31 +306,33 @@ func createConfigFiles(config Config) error {
os.MkdirAll("config/logs", 0755)
// Walk through all embedded files
err := fs.WalkDir(configFiles, "fs", func(path string, d fs.DirEntry, err error) error {
err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip the root fs directory itself
if path == "fs" {
if path == "config" {
return nil
}
// Get the relative path by removing the "fs/" prefix
relPath := strings.TrimPrefix(path, "fs/")
if !config.DoCrowdsecInstall && strings.Contains(path, "crowdsec") {
return nil
}
if config.DoCrowdsecInstall && !strings.Contains(path, "crowdsec") {
return nil
}
// skip .DS_Store
if strings.Contains(relPath, ".DS_Store") {
if strings.Contains(path, ".DS_Store") {
return nil
}
// Create the full output path under "config/"
outPath := filepath.Join("config", relPath)
if d.IsDir() {
// Create directory
if err := os.MkdirAll(outPath, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %v", outPath, err)
if err := os.MkdirAll(path, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %v", path, err)
}
return nil
}
@ -303,14 +350,14 @@ func createConfigFiles(config Config) error {
}
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
return fmt.Errorf("failed to create parent directory for %s: %v", outPath, err)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("failed to create parent directory for %s: %v", path, err)
}
// Create output file
outFile, err := os.Create(outPath)
outFile, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to create %s: %v", outPath, err)
return fmt.Errorf("failed to create %s: %v", path, err)
}
defer outFile.Close()
@ -326,30 +373,10 @@ func createConfigFiles(config Config) error {
return fmt.Errorf("error walking config files: %v", err)
}
// get the current directory
dir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %v", err)
}
sourcePath := filepath.Join(dir, "config/docker-compose.yml")
destPath := filepath.Join(dir, "docker-compose.yml")
// Check if source file exists
if _, err := os.Stat(sourcePath); err != nil {
return fmt.Errorf("source docker-compose.yml not found: %v", err)
}
// Try to move the file
err = os.Rename(sourcePath, destPath)
if err != nil {
return fmt.Errorf("failed to move docker-compose.yml from %s to %s: %v",
sourcePath, destPath, err)
}
return nil
}
func installDocker() error {
// Detect Linux distribution
cmd := exec.Command("cat", "/etc/os-release")
@ -490,3 +517,166 @@ func pullAndStartContainers() error {
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
View 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 organizations 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 | |

View file

@ -2,7 +2,8 @@
const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
}
},
output: "standalone"
};
export default nextConfig;

14861
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -50,7 +50,6 @@
"cookie-parser": "1.4.7",
"cors": "2.8.5",
"drizzle-orm": "0.38.3",
"emblor": "1.4.7",
"eslint": "9.17.0",
"eslint-config-next": "15.1.3",
"express": "4.21.2",
@ -71,6 +70,7 @@
"qrcode.react": "4.2.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-easy-sort": "^1.6.0",
"react-hook-form": "7.54.2",
"rebuild": "0.1.2",
"semver": "7.6.3",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

After

Width:  |  Height:  |  Size: 729 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

View file

@ -64,7 +64,8 @@ export enum ActionsEnum {
updateResourceRule = "updateResourceRule",
createClient = "createClient",
deleteClient = "deleteClient",
listClients = "listClients"
listClients = "listClients",
listOrgDomains = "listOrgDomains",
}
export async function checkUserActionPermission(

View file

@ -129,18 +129,19 @@ export async function invalidateAllSessions(userId: string): Promise<void> {
export function serializeSessionCookie(
token: string,
isSecure: boolean
isSecure: boolean,
expiresAt: Date
): string {
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 {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`;
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/;`;
}
}
export function createBlankSessionTokenCookie(isSecure: boolean): string {
if (isSecure) {
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 {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/;`;
}

View file

@ -167,12 +167,19 @@ export function serializeResourceSessionCookie(
cookieName: string,
domain: string,
token: string,
isHttp: boolean = false
isHttp: boolean = false,
expiresAt?: Date
): string {
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 {
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
): string {
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 {
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${"." + domain}`;
return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Domain=${"." + domain}`;
}
}

View file

@ -1,10 +1,26 @@
import { InferSelectModel } from "drizzle-orm";
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const domains = sqliteTable("domains", {
domainId: text("domainId").primaryKey(),
baseDomain: text("baseDomain").notNull(),
configManaged: integer("configManaged", { mode: "boolean" })
.notNull()
.default(false)
});
export const orgs = sqliteTable("orgs", {
orgId: text("orgId").primaryKey(),
name: text("name").notNull(),
domain: text("domain").notNull()
name: text("name").notNull()
});
export const orgDomains = sqliteTable("orgDomains", {
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
domainId: text("domainId")
.notNull()
.references(() => domains.domainId, { onDelete: "cascade" })
});
export const sites = sqliteTable("sites", {
@ -50,6 +66,9 @@ export const resources = sqliteTable("resources", {
name: text("name").notNull(),
subdomain: text("subdomain"),
fullDomain: text("fullDomain"),
domainId: text("domainId").references(() => domains.domainId, {
onDelete: "set null"
}),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
blockAccess: integer("blockAccess", { mode: "boolean" })
.notNull()
@ -490,3 +509,4 @@ export type ResourceRule = InferSelectModel<typeof resourceRules>;
export type Client = InferSelectModel<typeof clients>;
export type RoleClient = InferSelectModel<typeof roleClients>;
export type UserClient = InferSelectModel<typeof userClients>;
export type Domain = InferSelectModel<typeof domains>;

View file

@ -3,6 +3,7 @@ export * from "@server/emails/sendEmail";
import nodemailer from "nodemailer";
import config from "@server/lib/config";
import logger from "@server/logger";
import SMTPTransport from "nodemailer/lib/smtp-transport";
function createEmailClient() {
const emailConfig = config.getRawConfig().email;
@ -13,7 +14,7 @@ function createEmailClient() {
return;
}
return nodemailer.createTransport({
const settings = {
host: emailConfig.smtp_host,
port: emailConfig.smtp_port,
secure: emailConfig.smtp_secure || false,
@ -21,7 +22,15 @@ function createEmailClient() {
user: emailConfig.smtp_user,
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();

View file

@ -1,11 +1,9 @@
import fs from "fs";
import yaml from "js-yaml";
import path from "path";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import {
__DIRNAME,
APP_PATH,
APP_VERSION,
configFilePath1,
configFilePath2
@ -15,12 +13,6 @@ import stoi from "./stoi";
import { start } from "repl";
const portSchema = z.number().positive().gt(0).lte(65535);
const hostnameSchema = z
.string()
.regex(
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$/
)
.or(z.literal("localhost"));
const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
return process.env[envVar] ?? valFromYaml;
@ -32,34 +24,42 @@ const configSchema = z.object({
.string()
.url()
.optional()
.transform(getEnvOrYaml("APP_DASHBOARDURL"))
.pipe(z.string().url())
.transform((url) => url.toLowerCase()),
base_domain: hostnameSchema
.optional()
.transform(getEnvOrYaml("APP_BASEDOMAIN"))
.pipe(hostnameSchema)
.transform((url) => url.toLowerCase()),
log_level: z.enum(["debug", "info", "warn", "error"]),
save_logs: z.boolean(),
log_failed_attempts: z.boolean().optional()
}),
domains: z
.record(
z.string(),
z.object({
base_domain: z
.string()
.nonempty("base_domain must not be empty")
.transform((url) => url.toLowerCase()),
cert_resolver: z.string().optional(),
prefer_wildcard_cert: z.boolean().optional()
})
)
.refine(
(domains) => {
const keys = Object.keys(domains);
if (keys.length === 0) {
return false;
}
return true;
},
{
message: "At least one domain must be defined"
}
),
server: z.object({
external_port: portSchema
.optional()
.transform(getEnvOrYaml("SERVER_EXTERNALPORT"))
.transform(stoi)
.pipe(portSchema),
internal_port: portSchema
.optional()
.transform(getEnvOrYaml("SERVER_INTERNALPORT"))
.transform(stoi)
.pipe(portSchema),
next_port: portSchema
.optional()
.transform(getEnvOrYaml("SERVER_NEXTPORT"))
.transform(stoi)
.pipe(portSchema),
external_port: portSchema.optional().transform(stoi).pipe(portSchema),
internal_port: portSchema.optional().transform(stoi).pipe(portSchema),
next_port: portSchema.optional().transform(stoi).pipe(portSchema),
internal_hostname: z.string().transform((url) => url.toLowerCase()),
session_cookie_name: z.string(),
resource_access_token_param: z.string(),
@ -89,20 +89,13 @@ const configSchema = z.object({
traefik: z.object({
http_entrypoint: z.string(),
https_entrypoint: z.string().optional(),
cert_resolver: z.string().optional(),
prefer_wildcard_cert: z.boolean().optional(),
additional_middlewares: z.array(z.string()).optional()
}),
gerbil: z.object({
start_port: portSchema
.optional()
.transform(getEnvOrYaml("GERBIL_STARTPORT"))
.transform(stoi)
.pipe(portSchema),
start_port: portSchema.optional().transform(stoi).pipe(portSchema),
base_endpoint: z
.string()
.optional()
.transform(getEnvOrYaml("GERBIL_BASEENDPOINT"))
.pipe(z.string())
.transform((url) => url.toLowerCase()),
use_subdomain: z.boolean(),
@ -135,6 +128,7 @@ const configSchema = z.object({
smtp_user: z.string().optional(),
smtp_pass: z.string().optional(),
smtp_secure: z.boolean().optional(),
smtp_tls_reject_unauthorized: z.boolean().optional(),
no_reply: z.string().email().optional()
})
.optional(),
@ -159,7 +153,8 @@ const configSchema = z.object({
disable_signup_without_invite: z.boolean().optional(),
disable_user_create_org: 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()
});
@ -169,14 +164,8 @@ export class Config {
constructor() {
this.loadConfig();
if (process.env.GENERATE_TRAEFIK_CONFIG === "true") {
this.createTraefikConfig();
}
}
public loadEnvironment() {}
public loadConfig() {
const loadConfig = (configPath: string) => {
try {
@ -199,45 +188,15 @@ export class Config {
} else if (fs.existsSync(configFilePath2)) {
environment = loadConfig(configFilePath2);
}
if (!environment) {
const exampleConfigPath = path.join(
__DIRNAME,
"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 (process.env.APP_BASE_DOMAIN) {
console.log("You're using deprecated environment variables. Transition to the configuration file. https://docs.fossorial.io/");
}
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);
@ -290,80 +249,14 @@ export class Config {
return this.rawConfig;
}
public getBaseDomain(): string {
return this.rawConfig.app.base_domain;
}
public getNoReplyEmail(): string | undefined {
return (
this.rawConfig.email?.no_reply || this.rawConfig.email?.smtp_user
);
}
private createTraefikConfig() {
try {
// check if traefik_config.yml and dynamic_config.yml exists in APP_PATH/traefik
const defaultTraefikConfigPath = path.join(
__DIRNAME,
"traefik_config.example.yml"
);
const defaultDynamicConfigPath = path.join(
__DIRNAME,
"dynamic_config.example.yml"
);
const traefikPath = path.join(APP_PATH, "traefik");
if (!fs.existsSync(traefikPath)) {
return;
}
// load default configs
let traefikConfig = fs.readFileSync(
defaultTraefikConfigPath,
"utf8"
);
let dynamicConfig = fs.readFileSync(
defaultDynamicConfigPath,
"utf8"
);
traefikConfig = traefikConfig
.split("{{.LetsEncryptEmail}}")
.join(this.rawConfig.users.server_admin.email);
traefikConfig = traefikConfig
.split("{{.INTERNAL_PORT}}")
.join(this.rawConfig.server.internal_port.toString());
dynamicConfig = dynamicConfig
.split("{{.DashboardDomain}}")
.join(new URL(this.rawConfig.app.dashboard_url).hostname);
dynamicConfig = dynamicConfig
.split("{{.NEXT_PORT}}")
.join(this.rawConfig.server.next_port.toString());
dynamicConfig = dynamicConfig
.split("{{.EXTERNAL_PORT}}")
.join(this.rawConfig.server.external_port.toString());
// write thiese to the traefik directory
const traefikConfigPath = path.join(
traefikPath,
"traefik_config.yml"
);
const dynamicConfigPath = path.join(
traefikPath,
"dynamic_config.yml"
);
fs.writeFileSync(traefikConfigPath, traefikConfig, "utf8");
fs.writeFileSync(dynamicConfigPath, dynamicConfig, "utf8");
console.log("Traefik configuration files created");
} catch (e) {
console.log(
"Failed to generate the Traefik configuration files. Please create them manually."
);
console.error(e);
}
public getDomain(domainId: string) {
return this.rawConfig.domains[domainId];
}
}

View file

@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url";
// 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 __DIRNAME = path.dirname(__FILENAME);

View file

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

View 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&ampersand'), 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);
}

View file

@ -29,11 +29,6 @@ export function isValidUrlGlobPattern(pattern: string): boolean {
return false;
}
// If segment contains *, it must be exactly *
if (segment.includes("*") && segment !== "*") {
return false;
}
// Check each character in the segment
for (let j = 0; j < segment.length; j++) {
const char = segment[j];
@ -56,7 +51,7 @@ export function isValidUrlGlobPattern(pattern: string): boolean {
// - unreserved (A-Z a-z 0-9 - . _ ~)
// - sub-delims (! $ & ' ( ) * + , ; =)
// - @ : for compatibility with some systems
if (!/^[A-Za-z0-9\-._~!$&'()*+,;=@:]$/.test(char)) {
if (!/^[A-Za-z0-9\-._~!$&'()*+,;#=@:]$/.test(char)) {
return false;
}
}

View file

@ -5,7 +5,8 @@ import {
resources,
userResources,
roleResources,
resourceAccessToken
resourceAccessToken,
sites
} from "@server/db/schema";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@ -59,7 +60,8 @@ function queryAccessTokens(
title: resourceAccessToken.title,
description: resourceAccessToken.description,
createdAt: resourceAccessToken.createdAt,
resourceName: resources.name
resourceName: resources.name,
siteName: sites.name
};
if (orgId) {
@ -70,6 +72,10 @@ function queryAccessTokens(
resources,
eq(resourceAccessToken.resourceId, resources.resourceId)
)
.leftJoin(
sites,
eq(resources.resourceId, sites.siteId)
)
.where(
and(
inArray(
@ -91,6 +97,10 @@ function queryAccessTokens(
resources,
eq(resourceAccessToken.resourceId, resources.resourceId)
)
.leftJoin(
sites,
eq(resources.resourceId, sites.siteId)
)
.where(
and(
inArray(

View file

@ -78,7 +78,7 @@ export async function login(
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
HttpCode.UNAUTHORIZED,
"Username or password is incorrect"
)
);
@ -98,7 +98,7 @@ export async function login(
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
HttpCode.UNAUTHORIZED,
"Username or password is incorrect"
)
);
@ -129,7 +129,7 @@ export async function login(
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
HttpCode.UNAUTHORIZED,
"The two-factor code you entered is incorrect"
)
);
@ -137,9 +137,13 @@ export async function login(
}
const token = generateSessionToken();
await createSession(token, existingUser.userId);
const sess = await createSession(token, existingUser.userId);
const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(token, isSecure);
const cookie = serializeSessionCookie(
token,
isSecure,
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);

View file

@ -170,9 +170,13 @@ export async function signup(
// });
const token = generateSessionToken();
await createSession(token, userId);
const sess = await createSession(token, userId);
const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(token, isSecure);
const cookie = serializeSessionCookie(
token,
isSecure,
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);
if (config.getRawConfig().flags?.require_email_verification) {

View file

@ -102,6 +102,8 @@ export async function exchangeSession(
const token = generateSessionToken();
let expiresAt: number | null = null;
if (requestSession.userSessionId) {
const [res] = await db
.select()
@ -118,6 +120,7 @@ export async function exchangeSession(
expiresAt: res.expiresAt,
sessionLength: SESSION_COOKIE_EXPIRES
});
expiresAt = res.expiresAt;
}
} else if (requestSession.accessTokenId) {
const [res] = await db
@ -140,8 +143,12 @@ export async function exchangeSession(
expiresAt: res.expiresAt,
sessionLength: res.sessionLength
});
expiresAt = res.expiresAt;
}
} else {
const expires = new Date(
Date.now() + SESSION_COOKIE_EXPIRES
).getTime();
await createResourceSession({
token,
resourceId: resource.resourceId,
@ -152,11 +159,10 @@ export async function exchangeSession(
whitelistId: requestSession.whitelistId,
accessTokenId: requestSession.accessTokenId,
doNotExtend: false,
expiresAt: new Date(
Date.now() + SESSION_COOKIE_EXPIRES
).getTime(),
expiresAt: expires,
sessionLength: RESOURCE_SESSION_COOKIE_EXPIRES
});
expiresAt = expires;
}
const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
@ -164,7 +170,8 @@ export async function exchangeSession(
cookieName,
resource.fullDomain!,
token,
!resource.ssl
!resource.ssl,
expiresAt ? new Date(expiresAt) : undefined
);
logger.debug(JSON.stringify("Exchange cookie: " + cookie));

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

View file

@ -90,7 +90,15 @@ export async function verifyResourceSession(
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:
| {
resource: Resource | null;
@ -111,11 +119,11 @@ export async function verifyResourceSession(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, host))
.where(eq(resources.fullDomain, cleanHost))
.limit(1);
if (!result) {
logger.debug("Resource not found", host);
logger.debug("Resource not found", cleanHost);
return notAllowed(res);
}
@ -131,7 +139,7 @@ export async function verifyResourceSession(
const { resource, pincode, password } = resourceData;
if (!resource) {
logger.debug("Resource not found", host);
logger.debug("Resource not found", cleanHost);
return notAllowed(res);
}
@ -142,16 +150,6 @@ export async function verifyResourceSession(
return notAllowed(res);
}
if (
!resource.sso &&
!pincode &&
!password &&
!resource.emailWhitelistEnabled
) {
logger.debug("Resource allowed because no auth");
return allowed(res);
}
// check the rules
if (resource.applyRules) {
const action = await checkRules(
@ -171,6 +169,16 @@ export async function verifyResourceSession(
// 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(
resource.resourceId
)}?redirect=${encodeURIComponent(originalRequestURL)}`;
@ -376,7 +384,7 @@ async function createAccessTokenSession(
tokenItem: ResourceAccessToken
) {
const token = generateSessionToken();
await createResourceSession({
const sess = await createResourceSession({
resourceId: resource.resourceId,
token,
accessTokenId: tokenItem.accessTokenId,
@ -389,7 +397,8 @@ async function createAccessTokenSession(
cookieName,
resource.fullDomain!,
token,
!resource.ssl
!resource.ssl,
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);
logger.debug("Access token is valid, creating new session");
@ -525,7 +534,7 @@ async function checkRules(
return;
}
function isPathAllowed(pattern: string, path: string): boolean {
export function isPathAllowed(pattern: string, path: string): boolean {
logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`);
// Normalize and split paths into segments
@ -566,7 +575,7 @@ function isPathAllowed(pattern: string, path: string): boolean {
return result;
}
// For wildcards, try consuming different numbers of path segments
// For full segment wildcards, try consuming different numbers of path segments
if (currentPatternPart === "*") {
logger.debug(
`${indent}Found wildcard at pattern index ${patternIndex}`
@ -598,6 +607,32 @@ function isPathAllowed(pattern: string, path: string): boolean {
return false;
}
// Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix")
if (currentPatternPart.includes("*")) {
logger.debug(
`${indent}Found in-segment wildcard in "${currentPatternPart}"`
);
// Convert the pattern segment to a regex pattern
const regexPattern = currentPatternPart
.replace(/\*/g, ".*") // Replace * with .* for regex wildcard
.replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
const regex = new RegExp(`^${regexPattern}$`);
if (regex.test(currentPathPart)) {
logger.debug(
`${indent}Segment with wildcard matches: "${currentPatternPart}" matches "${currentPathPart}"`
);
return matchSegments(patternIndex + 1, pathIndex + 1);
}
logger.debug(
`${indent}Segment with wildcard mismatch: "${currentPatternPart}" doesn't match "${currentPathPart}"`
);
return false;
}
// For regular segments, they must match exactly
if (currentPatternPart !== currentPathPart) {
logger.debug(
@ -616,4 +651,4 @@ function isPathAllowed(pattern: string, path: string): boolean {
const result = matchSegments(0, 0);
logger.debug(`Final result: ${result}`);
return result;
}
}

View file

@ -0,0 +1 @@
export * from "./listDomains";

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

View file

@ -3,6 +3,7 @@ import config from "@server/lib/config";
import * as site from "./site";
import * as org from "./org";
import * as resource from "./resource";
import * as domain from "./domain";
import * as target from "./target";
import * as user from "./user";
import * as auth from "./auth";
@ -165,6 +166,13 @@ authenticated.get(
resource.listResources
);
authenticated.get(
"/org/:orgId/domains",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listOrgDomains),
domain.listDomains
);
authenticated.post(
"/org/:orgId/create-invite",
verifyOrgAccess,

View file

@ -8,7 +8,7 @@ import {
Target,
targets
} 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 logger from "@server/logger";
@ -77,68 +77,84 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
allowedIps: [site.subnet]
});
const allResources = await db
.select({
// Resource fields
resourceId: resources.resourceId,
subdomain: resources.subdomain,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
blockAccess: resources.blockAccess,
sso: resources.sso,
emailWhitelistEnabled: resources.emailWhitelistEnabled,
http: resources.http,
proxyPort: resources.proxyPort,
protocol: resources.protocol,
// Targets as a subquery
targets: sql<string>`json_group_array(json_object(
'targetId', ${targets.targetId},
'ip', ${targets.ip},
'method', ${targets.method},
'port', ${targets.port},
'internalPort', ${targets.internalPort},
'enabled', ${targets.enabled}
))`.as("targets")
})
.from(resources)
.leftJoin(
targets,
and(
eq(targets.resourceId, resources.resourceId),
eq(targets.enabled, true)
// Improved version
const allResources = await db.transaction(async (tx) => {
// First get all resources for the site
const resourcesList = await tx
.select({
resourceId: resources.resourceId,
subdomain: resources.subdomain,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
blockAccess: resources.blockAccess,
sso: resources.sso,
emailWhitelistEnabled: resources.emailWhitelistEnabled,
http: resources.http,
proxyPort: resources.proxyPort,
protocol: resources.protocol
})
.from(resources)
.where(eq(resources.siteId, siteId));
// Get all enabled targets for these resources in a single query
const resourceIds = resourcesList.map((r) => r.resourceId);
const allTargets =
resourceIds.length > 0
? await tx
.select({
resourceId: targets.resourceId,
targetId: targets.targetId,
ip: targets.ip,
method: targets.method,
port: targets.port,
internalPort: targets.internalPort,
enabled: targets.enabled
})
.from(targets)
.where(
and(
inArray(targets.resourceId, resourceIds),
eq(targets.enabled, true)
)
)
: [];
// Combine the data in JS instead of using SQL for the JSON
return resourcesList.map((resource) => ({
...resource,
targets: allTargets.filter(
(target) => target.resourceId === resource.resourceId
)
)
.where(eq(resources.siteId, siteId))
.groupBy(resources.resourceId);
}));
});
let tcpTargets: string[] = [];
let udpTargets: string[] = [];
const { tcpTargets, udpTargets } = allResources.reduce(
(acc, resource) => {
// Skip resources with no targets
if (!resource.targets?.length) return acc;
for (const resource of allResources) {
const targets = JSON.parse(resource.targets);
if (!targets || targets.length === 0) {
continue;
}
if (resource.protocol === "tcp") {
tcpTargets = tcpTargets.concat(
targets.map(
// Format valid targets into strings
const formattedTargets = resource.targets
.filter(
(target: Target) =>
`${
target.internalPort ? target.internalPort + ":" : ""
}${target.ip}:${target.port}`
target?.internalPort && target?.ip && target?.port
)
);
} else {
udpTargets = tcpTargets.concat(
targets.map(
.map(
(target: Target) =>
`${
target.internalPort ? target.internalPort + ":" : ""
}${target.ip}:${target.port}`
)
);
}
}
`${target.internalPort}:${target.ip}:${target.port}`
);
// Add to the appropriate protocol array
if (resource.protocol === "tcp") {
acc.tcpTargets.push(...formattedTargets);
} else {
acc.udpTargets.push(...formattedTargets);
}
return acc;
},
{ tcpTargets: [] as string[], udpTargets: [] as string[] }
);
return {
message: {

View file

@ -2,7 +2,15 @@ import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { eq } from "drizzle-orm";
import { Org, orgs, roleActions, roles, userOrgs } from "@server/db/schema";
import {
domains,
Org,
orgDomains,
orgs,
roleActions,
roles,
userOrgs
} from "@server/db/schema";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@ -16,7 +24,6 @@ const createOrgSchema = z
.object({
orgId: z.string(),
name: z.string().min(1).max(255)
// domain: z.string().min(1).max(255).optional(),
})
.strict();
@ -82,14 +89,16 @@ export async function createOrg(
let org: Org | null = null;
await db.transaction(async (trx) => {
const domain = config.getBaseDomain();
const allDomains = await trx
.select()
.from(domains)
.where(eq(domains.configManaged, true));
const newOrg = await trx
.insert(orgs)
.values({
orgId,
name,
domain
name
})
.returning();
@ -109,6 +118,13 @@ export async function createOrg(
return;
}
await trx.insert(orgDomains).values(
allDomains.map((domain) => ({
orgId: newOrg[0].orgId,
domainId: domain.domainId
}))
);
await trx.insert(userOrgs).values({
userId: req.user!.userId,
orgId: newOrg[0].orgId,

View file

@ -1,8 +1,9 @@
import { SqliteError } from "better-sqlite3";
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import {
domains,
orgDomains,
orgs,
Resource,
resources,
@ -27,69 +28,29 @@ const createResourceParamsSchema = z
})
.strict();
const createResourceSchema = z
const createHttpResourceSchema = z
.object({
subdomain: z.string().optional(),
name: z.string().min(1).max(255),
subdomain: z
.string()
.optional()
.transform((val) => val?.toLowerCase()),
isBaseDomain: z.boolean().optional(),
siteId: z.number(),
http: z.boolean(),
protocol: z.string(),
proxyPort: z.number().optional(),
isBaseDomain: z.boolean().optional()
domainId: z.string()
})
.strict()
.refine(
(data) => {
if (!data.http) {
return z
.number()
.int()
.min(1)
.max(65535)
.safeParse(data.proxyPort).success;
}
return true;
},
{
message: "Invalid port number",
path: ["proxyPort"]
}
)
.refine(
(data) => {
if (data.http && !data.isBaseDomain) {
if (data.subdomain) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
},
{
message: "Invalid subdomain",
path: ["subdomain"]
}
{ message: "Invalid subdomain" }
)
.refine(
(data) => {
if (!config.getRawConfig().flags?.allow_raw_resources) {
if (data.proxyPort !== undefined) {
return false;
}
}
return true;
},
{
message: "Proxy port cannot be set"
}
)
// .refine(
// (data) => {
// if (data.proxyPort === 443 || data.proxyPort === 80) {
// return false;
// }
// return true;
// },
// {
// message: "Port 80 and 443 are reserved for http and https resources"
// }
// )
.refine(
(data) => {
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
@ -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 async function createResource(
@ -112,18 +96,6 @@ export async function createResource(
next: NextFunction
): Promise<any> {
try {
const parsedBody = createResourceSchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
let { name, subdomain, protocol, proxyPort, http, isBaseDomain } = parsedBody.data;
// Validate request params
const parsedParams = createResourceParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
@ -159,99 +131,25 @@ export async function createResource(
);
}
let fullDomain = "";
if (isBaseDomain) {
fullDomain = org[0].domain;
} else {
fullDomain = `${subdomain}.${org[0].domain}`;
if (typeof req.body.http !== "boolean") {
return next(
createHttpError(HttpCode.BAD_REQUEST, "http field is required")
);
}
// if http is false check to see if there is already a resource with the same port and protocol
if (!http) {
const existingResource = await db
.select()
.from(resources)
.where(
and(
eq(resources.protocol, protocol),
eq(resources.proxyPort, proxyPort!)
)
);
const { http } = req.body;
if (existingResource.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that protocol and port already exists"
)
);
}
if (http) {
return await createHttpResource(
{ req, res, next },
{ siteId, orgId }
);
} else {
// make sure the full domain is unique
const existingResource = await db
.select()
.from(resources)
.where(eq(resources.fullDomain, fullDomain));
if (existingResource.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that domain already exists"
)
);
}
return await createRawResource(
{ req, res, next },
{ siteId, orgId }
);
}
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) {
logger.error(error);
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
});
}

View file

@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
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 response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@ -18,7 +18,9 @@ const getResourceSchema = z
})
.strict();
export type GetResourceResponse = Resource;
export type GetResourceResponse = Resource & {
siteName: string;
};
export async function getResource(
req: Request,
@ -38,13 +40,17 @@ export async function getResource(
const { resourceId } = parsedParams.data;
const resource = await db
const [resp] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.leftJoin(sites, eq(sites.siteId, resources.siteId))
.limit(1);
if (resource.length === 0) {
const resource = resp.resources;
const site = resp.sites;
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
@ -54,7 +60,10 @@ export async function getResource(
}
return response(res, {
data: resource[0],
data: {
...resource,
siteName: site?.name
},
success: true,
error: false,
message: "Resource retrieved successfully",

View file

@ -1,8 +1,15 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { orgs, resources, sites } from "@server/db/schema";
import { eq, or, and } from "drizzle-orm";
import {
domains,
Org,
orgDomains,
orgs,
Resource,
resources
} from "@server/db/schema";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@ -20,17 +27,53 @@ const updateResourceParamsSchema = z
})
.strict();
const updateResourceBodySchema = z
const updateHttpResourceBodySchema = z
.object({
name: z.string().min(1).max(255).optional(),
subdomain: subdomainSchema.optional(),
subdomain: subdomainSchema
.optional()
.transform((val) => val?.toLowerCase()),
ssl: z.boolean().optional(),
sso: z.boolean().optional(),
blockAccess: z.boolean().optional(),
proxyPort: z.number().int().min(1).max(65535).optional(),
emailWhitelistEnabled: z.boolean().optional(),
isBaseDomain: 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()
.refine((data) => Object.keys(data).length > 0, {
@ -46,30 +89,6 @@ const updateResourceBodySchema = z
return true;
},
{ message: "Cannot update proxyPort" }
)
// .refine(
// (data) => {
// if (data.proxyPort === 443 || data.proxyPort === 80) {
// return false;
// }
// return true;
// },
// {
// message: "Port 80 and 443 are reserved for http and https resources"
// }
// )
.refine(
(data) => {
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
if (data.isBaseDomain) {
return false;
}
}
return true;
},
{
message: "Base domain resources are not allowed"
}
);
export async function updateResource(
@ -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 updateData = parsedBody.data;
const [result] = await db
.select()
@ -119,117 +127,33 @@ export async function updateResource(
);
}
if (updateData.subdomain) {
if (!resource.http) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Cannot update subdomain for non-http resource"
)
);
}
const valid = subdomainSchema.safeParse(
updateData.subdomain
).success;
if (!valid) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid subdomain provided"
)
);
}
}
if (updateData.proxyPort) {
const proxyPort = updateData.proxyPort;
const existingResource = await db
.select()
.from(resources)
.where(
and(
eq(resources.protocol, resource.protocol),
eq(resources.proxyPort, proxyPort!)
)
);
if (
existingResource.length > 0 &&
existingResource[0].resourceId !== resourceId
) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that protocol and port already exists"
)
);
}
}
if (!org?.domain) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Resource does not have a domain"
)
if (resource.http) {
// HANDLE UPDATING HTTP RESOURCES
return await updateHttpResource(
{
req,
res,
next
},
{
resource,
org
}
);
} else {
// HANDLE UPDATING RAW TCP/UDP RESOURCES
return await updateRawResource(
{
req,
res,
next
},
{
resource,
org
}
);
}
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) {
logger.error(error);
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
});
}

View file

@ -1,6 +1,6 @@
import { Request, Response } from "express";
import db from "@server/db";
import { and, eq } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import config from "@server/lib/config";
@ -12,52 +12,79 @@ export async function traefikConfigProvider(
res: Response
): Promise<any> {
try {
const allResources = await db
.select({
// Resource fields
resourceId: resources.resourceId,
subdomain: resources.subdomain,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
blockAccess: resources.blockAccess,
sso: resources.sso,
emailWhitelistEnabled: resources.emailWhitelistEnabled,
http: resources.http,
proxyPort: resources.proxyPort,
protocol: resources.protocol,
isBaseDomain: resources.isBaseDomain,
// Site fields
site: {
siteId: sites.siteId,
type: sites.type,
subnet: sites.subnet
},
// Org fields
org: {
orgId: orgs.orgId,
domain: orgs.domain
},
// Targets as a subquery
targets: sql<string>`json_group_array(json_object(
'targetId', ${targets.targetId},
'ip', ${targets.ip},
'method', ${targets.method},
'port', ${targets.port},
'internalPort', ${targets.internalPort},
'enabled', ${targets.enabled}
))`.as("targets")
})
.from(resources)
.innerJoin(sites, eq(sites.siteId, resources.siteId))
.innerJoin(orgs, eq(resources.orgId, orgs.orgId))
.leftJoin(
targets,
and(
eq(targets.resourceId, resources.resourceId),
eq(targets.enabled, true)
)
)
.groupBy(resources.resourceId);
// Get all resources with related data
const allResources = await db.transaction(async (tx) => {
// First query to get resources with site and org info
const resourcesWithRelations = await tx
.select({
// Resource fields
resourceId: resources.resourceId,
subdomain: resources.subdomain,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
blockAccess: resources.blockAccess,
sso: resources.sso,
emailWhitelistEnabled: resources.emailWhitelistEnabled,
http: resources.http,
proxyPort: resources.proxyPort,
protocol: resources.protocol,
isBaseDomain: resources.isBaseDomain,
domainId: resources.domainId,
// Site fields
site: {
siteId: sites.siteId,
type: sites.type,
subnet: sites.subnet
},
// Org fields
org: {
orgId: orgs.orgId
}
})
.from(resources)
.innerJoin(sites, eq(sites.siteId, resources.siteId))
.innerJoin(orgs, eq(resources.orgId, orgs.orgId));
// Get all resource IDs from the first query
const resourceIds = resourcesWithRelations.map((r) => r.resourceId);
// Second query to get all enabled targets for these resources
const allTargets =
resourceIds.length > 0
? await tx
.select({
resourceId: targets.resourceId,
targetId: targets.targetId,
ip: targets.ip,
method: targets.method,
port: targets.port,
internalPort: targets.internalPort,
enabled: targets.enabled
})
.from(targets)
.where(
and(
inArray(targets.resourceId, resourceIds),
eq(targets.enabled, true)
)
)
: [];
// 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) {
return res.status(HttpCode.OK).json({});
@ -101,19 +128,26 @@ export async function traefikConfigProvider(
};
for (const resource of allResources) {
const targets = JSON.parse(resource.targets);
const targets = resource.targets as Target[];
const site = resource.site;
const org = resource.org;
if (!org.domain) {
continue;
}
const routerName = `${resource.resourceId}-router`;
const serviceName = `${resource.resourceId}-service`;
const fullDomain = `${resource.fullDomain}`;
if (resource.http) {
if (!resource.domainId) {
continue;
}
if (!resource.fullDomain) {
logger.error(
`Resource ${resource.resourceId} has no fullDomain`
);
continue;
}
// HTTP configuration remains the same
if (!resource.subdomain && !resource.isBaseDomain) {
continue;
@ -136,9 +170,18 @@ export async function traefikConfigProvider(
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 = {
certResolver: config.getRawConfig().traefik.cert_resolver,
...(config.getRawConfig().traefik.prefer_wildcard_cert
certResolver: configDomain.cert_resolver,
...(configDomain.prefer_wildcard_cert
? {
domains: [
{

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

View file

@ -1,34 +1,103 @@
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 { eq, ne } from "drizzle-orm";
import logger from "@server/logger";
export async function copyInConfig() {
const domain = config.getBaseDomain();
const endpoint = config.getRawConfig().gerbil.base_endpoint;
const listenPort = config.getRawConfig().gerbil.start_port;
// update the domain on all of the orgs where the domain is not equal to the new domain
// 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) => {
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 = "";
if (resource.isBaseDomain) {
fullDomain = domain;
fullDomain = domain.baseDomain;
} else {
fullDomain = `${resource.subdomain}.${domain}`;
fullDomain = `${resource.subdomain}.${domain.baseDomain}`;
}
await trx
.update(resources)
.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));
}

View file

@ -2,12 +2,14 @@ import { ensureActions } from "./ensureActions";
import { copyInConfig } from "./copyInConfig";
import { setupServerAdmin } from "./setupServerAdmin";
import logger from "@server/logger";
import { clearStaleData } from "./clearStaleData";
export async function runSetupFunctions() {
try {
await copyInConfig(); // copy in the config to the db as needed
await setupServerAdmin();
await ensureActions(); // make sure all of the actions are in the db and the roles
await clearStaleData();
} catch (error) {
logger.error("Error running setup functions:", error);
process.exit(1);

View file

@ -15,6 +15,8 @@ import m6 from "./scripts/1.0.0-beta9";
import m7 from "./scripts/1.0.0-beta10";
import m8 from "./scripts/1.0.0-beta12";
import m13 from "./scripts/1.0.0-beta13";
import m15 from "./scripts/1.0.0-beta15";
import m16 from "./scripts/1.0.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// 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.10", run: m7 },
{ 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
] as const;
await run();
async function run() {
// backup the database
backupDb();
// run the migrations
await runMigrations();
}
@ -123,6 +124,11 @@ async function executeScripts() {
console.log(`Running migration ${migration.version}`);
try {
if (!process.env.DISABLE_BACKUP_ON_MIGRATION) {
// Backup the database before running the migration
backupDb();
}
await migration.run();
// Update version in database

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

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

View file

@ -7,7 +7,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { toast } from "@app/hooks/useToast";
@ -24,11 +24,11 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
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 { useEnvContext } from "@app/hooks/useEnvContext";
@ -40,13 +40,13 @@ type CreateRoleFormProps = {
const formSchema = z.object({
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({
open,
setOpen,
afterCreate,
afterCreate
}: CreateRoleFormProps) {
const { org } = useOrgContext();
@ -58,8 +58,8 @@ export default function CreateRoleForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
description: "",
},
description: ""
}
});
async function onSubmit(values: z.infer<typeof formSchema>) {
@ -70,7 +70,7 @@ export default function CreateRoleForm({
`/org/${org?.org.orgId}/role`,
{
name: values.name,
description: values.description,
description: values.description
} as CreateRoleBody
)
.catch((e) => {
@ -80,7 +80,7 @@ export default function CreateRoleForm({
description: formatAxiosError(
e,
"An error occurred while creating the role."
),
)
});
});
@ -88,7 +88,7 @@ export default function CreateRoleForm({
toast({
variant: "default",
title: "Role created",
description: "The role has been successfully created.",
description: "The role has been successfully created."
});
if (open) {
@ -135,10 +135,7 @@ export default function CreateRoleForm({
<FormItem>
<FormLabel>Role Name</FormLabel>
<FormControl>
<Input
placeholder="Enter name for the role"
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -151,10 +148,7 @@ export default function CreateRoleForm({
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder="Describe the role"
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -164,6 +158,9 @@ export default function CreateRoleForm({
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="create-role-form"
@ -172,9 +169,6 @@ export default function CreateRoleForm({
>
Create Role
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View file

@ -7,7 +7,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
@ -23,7 +23,7 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { ListRolesResponse } from "@server/routers/role";
@ -32,10 +32,10 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SelectValue
} from "@app/components/ui/select";
import { RoleRow } from "./RolesTable";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
@ -47,14 +47,14 @@ type CreateRoleFormProps = {
};
const formSchema = z.object({
newRoleId: z.string({ message: "New role is required" }),
newRoleId: z.string({ message: "New role is required" })
});
export default function DeleteRoleForm({
open,
roleToDelete,
setOpen,
afterDelete,
afterDelete
}: CreateRoleFormProps) {
const { org } = useOrgContext();
@ -66,9 +66,9 @@ export default function DeleteRoleForm({
useEffect(() => {
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(
`/org/${org?.org.orgId}/roles`
)
.get<
AxiosResponse<ListRolesResponse>
>(`/org/${org?.org.orgId}/roles`)
.catch((e) => {
console.error(e);
toast({
@ -77,7 +77,7 @@ export default function DeleteRoleForm({
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
),
)
});
});
@ -96,8 +96,8 @@ export default function DeleteRoleForm({
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
newRoleId: "",
},
newRoleId: ""
}
});
async function onSubmit(values: z.infer<typeof formSchema>) {
@ -106,8 +106,8 @@ export default function DeleteRoleForm({
const res = await api
.delete(`/role/${roleToDelete.roleId}`, {
data: {
roleId: values.newRoleId,
},
roleId: values.newRoleId
}
})
.catch((e) => {
toast({
@ -116,7 +116,7 @@ export default function DeleteRoleForm({
description: formatAxiosError(
e,
"An error occurred while removing the role."
),
)
});
});
@ -124,7 +124,7 @@ export default function DeleteRoleForm({
toast({
variant: "default",
title: "Role removed",
description: "The role has been successfully removed.",
description: "The role has been successfully removed."
});
if (open) {
@ -214,6 +214,9 @@ export default function DeleteRoleForm({
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="remove-role-form"
@ -222,9 +225,6 @@ export default function DeleteRoleForm({
>
Remove Role
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View file

@ -37,7 +37,7 @@ import {
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
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 { useEnvContext } from "@app/hooks/useEnvContext";
import { Checkbox } from "@app/components/ui/checkbox";
@ -194,10 +194,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Enter an email"
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -341,6 +338,9 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="invite-user-form"
@ -349,9 +349,6 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
>
Create Invitation
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View file

@ -185,7 +185,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
<Button variant={"outline"} className="ml-2">
<Button variant={"outlinePrimary"} className="ml-2">
Manage
<ArrowRight className="ml-2 w-4 h-4" />
</Button>

View file

@ -64,7 +64,7 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
</Breadcrumb>
</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">
User {user?.email}
</h2>
@ -73,7 +73,6 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
<SidebarSettings
sidebarNavItems={sidebarNavItems}
limitWidth={true}
>
{children}
</SidebarSettings>

View file

@ -210,11 +210,11 @@ export default function GeneralPage() {
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This is the display name of the
org
organization.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@ -238,7 +238,6 @@ export default function GeneralPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
<AlertTriangle className="h-5 w-5" />
Danger Zone
</SettingsSectionTitle>
<SettingsSectionDescription>

View file

@ -1,6 +1,12 @@
import { Metadata } from "next";
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 { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
@ -43,7 +49,7 @@ const topNavItems = [
{
title: "Shareable Links",
href: "/{orgId}/settings/share-links",
icon: <Link className="h-4 w-4" />
icon: <LinkIcon className="h-4 w-4" />
},
{
title: "General",
@ -100,19 +106,23 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
return (
<>
<div className="w-full border-b bg-card select-none sm:px-0 px-3 fixed top-0 z-10">
<div className="container mx-auto flex flex-col content-between">
<div className="my-4">
<UserProvider user={user}>
<Header orgId={params.orgId} orgs={orgs} />
</UserProvider>
<div className="w-full bg-card sm:px-0 px-3 fixed top-0 z-10">
<div className="border-b">
<div className="container mx-auto flex flex-col content-between">
<div className="my-4">
<UserProvider user={user}>
<Header orgId={params.orgId} orgs={orgs} />
</UserProvider>
</div>
<TopbarNav items={topNavItems} orgId={params.orgId} />
</div>
<TopbarNav items={topNavItems} orgId={params.orgId} />
</div>
</div>
<div className="container mx-auto sm:px-0 px-3 pt-[165px]">
{children}
<div className="container mx-auto sm:px-0 px-3 pt-[155px]">
<div className="container mx-auto sm:px-0 px-3">
{children}
</div>
</div>
</>
);

File diff suppressed because it is too large Load diff

View file

@ -233,7 +233,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
<Link
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
>
<Button variant={"outline"} className="ml-2">
<Button variant={"outlinePrimary"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>

View file

@ -2,27 +2,68 @@
import * as React from "react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
interface DomainOption {
baseDomain: string;
domainId: string;
}
interface CustomDomainInputProps {
domainSuffix: string;
domainOptions: DomainOption[];
selectedDomainId?: string;
placeholder?: string;
value: string;
onChange?: (value: string) => void;
onChange?: (value: string, selectedDomainId: string) => void;
}
export default function CustomDomainInput({
domainSuffix,
placeholder = "Enter subdomain",
domainOptions,
selectedDomainId,
placeholder = "Subdomain",
value: defaultValue,
onChange
}: CustomDomainInputProps) {
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;
setValue(newValue);
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"
placeholder={placeholder}
value={value}
onChange={handleChange}
className="rounded-r-none w-full"
onChange={handleInputChange}
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">
<span className="text-sm truncate">.{domainSuffix}</span>
</div>
<Select
onValueChange={handleDomainChange}
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>
);

View file

@ -1,9 +1,7 @@
"use client";
import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { ArrowRight, InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { Separator } from "@app/components/ui/separator";
import CopyToClipboard from "@app/components/CopyToClipboard";
@ -13,21 +11,14 @@ import {
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import Link from "next/link";
type ResourceInfoBoxType = {};
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
const [copied, setCopied] = useState(false);
const { org } = useOrgContext();
const { resource, authInfo } = useResourceContext();
let fullUrl = `${resource.ssl ? "https" : "http"}://`;
if (resource.isBaseDomain) {
fullUrl = fullUrl + org.org.domain;
} else {
fullUrl = fullUrl + `${resource.subdomain}.${org.org.domain}`;
}
let fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
return (
<Alert>
@ -36,7 +27,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
Resource Information
</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections>
<InfoSections cols={3}>
{resource.http ? (
<>
<InfoSection>
@ -50,22 +41,16 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
authInfo.whitelist ? (
<div className="flex items-start space-x-2 text-green-500">
<ShieldCheck className="w-4 h-4 mt-0.5" />
<span>
This resource is protected with
at least one auth method.
</span>
<span>Protected</span>
</div>
) : (
<div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff className="w-4 h-4" />
<span>
Anyone can access this resource.
</span>
<span>Not Protected</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
<Separator orientation="vertical" />
<InfoSection>
<InfoSectionTitle>URL</InfoSectionTitle>
<InfoSectionContent>
@ -75,6 +60,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
/>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>Site</InfoSectionTitle>
<InfoSectionContent>
{resource.siteName}
</InfoSectionContent>
</InfoSection>
</>
) : (
<>
@ -86,7 +77,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</span>
</InfoSectionContent>
</InfoSection>
<Separator orientation="vertical" />
<InfoSection>
<InfoSectionTitle>Port</InfoSectionTitle>
<InfoSectionContent>

View file

@ -8,7 +8,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { toast } from "@app/hooks/useToast";
@ -24,22 +24,22 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTitle
} from "@app/components/Credenza";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { Resource } from "@server/db/schema";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
const setPasswordFormSchema = z.object({
password: z.string().min(4).max(100),
password: z.string().min(4).max(100)
});
type SetPasswordFormValues = z.infer<typeof setPasswordFormSchema>;
const defaultValues: Partial<SetPasswordFormValues> = {
password: "",
password: ""
};
type SetPasswordFormProps = {
@ -53,7 +53,7 @@ export default function SetResourcePasswordForm({
open,
setOpen,
resourceId,
onSetPassword,
onSetPassword
}: SetPasswordFormProps) {
const api = createApiClient(useEnvContext());
@ -61,7 +61,7 @@ export default function SetResourcePasswordForm({
const form = useForm<SetPasswordFormValues>({
resolver: zodResolver(setPasswordFormSchema),
defaultValues,
defaultValues
});
useEffect(() => {
@ -76,7 +76,7 @@ export default function SetResourcePasswordForm({
setLoading(true);
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/password`, {
password: data.password,
password: data.password
})
.catch((e) => {
toast({
@ -85,14 +85,14 @@ export default function SetResourcePasswordForm({
description: formatAxiosError(
e,
"An error occurred while setting the resource password"
),
)
});
})
.then(() => {
toast({
title: "Resource password set",
description:
"The resource password has been set successfully",
"The resource password has been set successfully"
});
if (onSetPassword) {
@ -136,17 +136,16 @@ export default function SetResourcePasswordForm({
<Input
autoComplete="off"
type="password"
placeholder="Your secure password"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
Users will be able to access
this resource by entering this
password. It must be at least 4
characters long.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@ -154,6 +153,9 @@ export default function SetResourcePasswordForm({
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="set-password-form"
@ -162,9 +164,6 @@ export default function SetResourcePasswordForm({
>
Enable Password Protection
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View file

@ -8,7 +8,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { toast } from "@app/hooks/useToast";
@ -24,27 +24,27 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTitle
} from "@app/components/Credenza";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { Resource } from "@server/db/schema";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSlot
} from "@app/components/ui/input-otp";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
const setPincodeFormSchema = z.object({
pincode: z.string().length(6),
pincode: z.string().length(6)
});
type SetPincodeFormValues = z.infer<typeof setPincodeFormSchema>;
const defaultValues: Partial<SetPincodeFormValues> = {
pincode: "",
pincode: ""
};
type SetPincodeFormProps = {
@ -58,7 +58,7 @@ export default function SetResourcePincodeForm({
open,
setOpen,
resourceId,
onSetPincode,
onSetPincode
}: SetPincodeFormProps) {
const [loading, setLoading] = useState(false);
@ -66,7 +66,7 @@ export default function SetResourcePincodeForm({
const form = useForm<SetPincodeFormValues>({
resolver: zodResolver(setPincodeFormSchema),
defaultValues,
defaultValues
});
useEffect(() => {
@ -81,7 +81,7 @@ export default function SetResourcePincodeForm({
setLoading(true);
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/pincode`, {
pincode: data.pincode,
pincode: data.pincode
})
.catch((e) => {
toast({
@ -89,15 +89,15 @@ export default function SetResourcePincodeForm({
title: "Error setting resource PIN code",
description: formatAxiosError(
e,
"An error occurred while setting the resource PIN code",
),
"An error occurred while setting the resource PIN code"
)
});
})
.then(() => {
toast({
title: "Resource PIN code set",
description:
"The resource pincode has been set successfully",
"The resource pincode has been set successfully"
});
if (onSetPincode) {
@ -167,13 +167,13 @@ export default function SetResourcePincodeForm({
</InputOTP>
</div>
</FormControl>
<FormMessage />
<FormDescription>
Users will be able to access
this resource by entering this
PIN code. It must be at least 6
digits long.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@ -181,6 +181,9 @@ export default function SetResourcePincodeForm({
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="set-pincode-form"
@ -189,9 +192,6 @@ export default function SetResourcePincodeForm({
>
Enable PIN Code Protection
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View file

@ -8,14 +8,12 @@ import { useResourceContext } from "@app/hooks/useResourceContext";
import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";
import {
GetResourceAuthInfoResponse,
GetResourceWhitelistResponse,
ListResourceRolesResponse,
ListResourceUsersResponse
} from "@server/routers/resource";
import { Button } from "@app/components/ui/button";
import { set, z } from "zod";
import { Tag } from "emblor";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
@ -27,12 +25,8 @@ import {
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { TagInput } from "emblor";
// import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListUsersResponse } from "@server/routers/user";
import { Switch } from "@app/components/ui/switch";
import { Label } from "@app/components/ui/label";
import { Binary, Key, ShieldCheck } from "lucide-react";
import { Binary, Key } from "lucide-react";
import SetResourcePasswordForm from "./SetResourcePasswordForm";
import SetResourcePincodeForm from "./SetResourcePincodeForm";
import { createApiClient } from "@app/lib/api";
@ -44,11 +38,12 @@ import {
SettingsSectionHeader,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
SettingsSectionFooter,
SettingsSectionForm
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { InfoPopup } from "@app/components/ui/info-popup";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { useRouter } from "next/navigation";
const UsersRolesFormSchema = z.object({
@ -413,7 +408,7 @@ export default function ResourceAuthenticationPage() {
<SwitchInput
id="sso-toggle"
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}
onCheckedChange={(val) => setSsoEnabled(val)}
/>
@ -435,7 +430,6 @@ export default function ResourceAuthenticationPage() {
<FormItem className="flex flex-col items-start">
<FormLabel>Roles</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
@ -444,7 +438,8 @@ export default function ResourceAuthenticationPage() {
setActiveTagIndex={
setActiveRolesTagIndex
}
placeholder="Enter a role"
placeholder="Select a role"
size="sm"
tags={
usersRolesForm.getValues()
.roles
@ -473,23 +468,13 @@ export default function ResourceAuthenticationPage() {
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>
<FormMessage />
<FormDescription>
These roles will be able
to access this resource.
Admins can always access
this resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@ -500,7 +485,6 @@ export default function ResourceAuthenticationPage() {
<FormItem className="flex flex-col items-start">
<FormLabel>Users</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
@ -509,11 +493,12 @@ export default function ResourceAuthenticationPage() {
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder="Enter a user"
placeholder="Select a user"
tags={
usersRolesForm.getValues()
.users
}
size="sm"
setTags={(
newUsers
) => {
@ -538,25 +523,8 @@ export default function ResourceAuthenticationPage() {
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>
<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 />
</FormItem>
)}
@ -601,7 +569,7 @@ export default function ResourceAuthenticationPage() {
</span>
</div>
<Button
variant="outline"
variant="outlinePrimary"
onClick={
authInfo.password
? removeResourcePassword
@ -627,7 +595,7 @@ export default function ResourceAuthenticationPage() {
</span>
</div>
<Button
variant="outline"
variant="outlinePrimary"
onClick={
authInfo.pincode
? removeResourcePincode
@ -683,6 +651,7 @@ export default function ResourceAuthenticationPage() {
activeTagIndex={
activeEmailTagIndex
}
size={"sm"}
validateTag={(
tag
) => {
@ -727,18 +696,12 @@ export default function ResourceAuthenticationPage() {
false
}
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>
<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>
</FormItem>
)}

View file

@ -39,6 +39,7 @@ import {
import {
Table,
TableBody,
TableCaption,
TableCell,
TableContainer,
TableHead,
@ -103,8 +104,8 @@ export default function ReverseProxyTargets(props: {
resolver: zodResolver(addTargetSchema),
defaultValues: {
ip: "",
method: resource.http ? "http" : null
// protocol: "TCP",
method: resource.http ? "http" : null,
port: "" as any as number
} as z.infer<typeof addTargetSchema>
});
@ -199,7 +200,11 @@ export default function ReverseProxyTargets(props: {
};
setTargets([...targets, newTarget]);
addTargetForm.reset();
addTargetForm.reset({
ip: "",
method: resource.http ? "http" : null,
port: "" as any as number
});
}
const removeTarget = (targetId: number) => {
@ -241,10 +246,7 @@ export default function ReverseProxyTargets(props: {
>(`/resource/${params.resourceId}/target`, data);
target.targetId = res.data.data.targetId;
} else if (target.updated) {
await api.post(
`/target/${target.targetId}`,
data
);
await api.post(`/target/${target.targetId}`, data);
}
setTargets([
@ -261,9 +263,7 @@ export default function ReverseProxyTargets(props: {
for (const targetId of targetsToRemove) {
await api.delete(`/target/${targetId}`);
setTargets(
targets.filter((t) => t.targetId !== targetId)
);
setTargets(targets.filter((t) => t.targetId !== targetId));
}
toast({
@ -421,6 +421,7 @@ export default function ReverseProxyTargets(props: {
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
)
@ -436,7 +437,13 @@ export default function ReverseProxyTargets(props: {
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel()
getFilteredRowModel: getFilteredRowModel(),
state: {
pagination: {
pageIndex: 0,
pageSize: 1000
}
}
});
if (pageLoading) {
@ -452,8 +459,7 @@ export default function ReverseProxyTargets(props: {
SSL Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Setup SSL to secure your connections with
LetsEncrypt certificates
Set up SSL to secure your connections with certificates
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@ -475,7 +481,7 @@ export default function ReverseProxyTargets(props: {
Target Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Setup targets to route traffic to your services
Set up targets to route traffic to your services
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@ -484,7 +490,7 @@ export default function ReverseProxyTargets(props: {
onSubmit={addTargetForm.handleSubmit(addTarget)}
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 && (
<FormField
control={addTargetForm.control}
@ -517,6 +523,9 @@ export default function ReverseProxyTargets(props: {
<SelectItem value="https">
https
</SelectItem>
<SelectItem value="h2c">
h2c
</SelectItem>
</SelectContent>
</Select>
</FormControl>
@ -536,18 +545,6 @@ export default function ReverseProxyTargets(props: {
<Input id="ip" {...field} />
</FormControl>
<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>
)}
/>
@ -566,83 +563,68 @@ export default function ReverseProxyTargets(props: {
/>
</FormControl>
<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>
)}
/>
<Button
type="submit"
variant="outlinePrimary"
className="mt-8"
>
Add Target
</Button>
</div>
<Button type="submit" variant="outline">
Add Target
</Button>
</form>
</Form>
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column
.columnDef.header,
header.getContext()
)}
</TableHead>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column
.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No 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>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No targets. Add a target using the form.
</TableCell>
</TableRow>
)}
</TableBody>
<TableCaption>
Adding more than one target above will enable load
balancing.
</TableCaption>
</Table>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button

View file

@ -33,7 +33,6 @@ import { useEffect, useState } from "react";
import { AxiosResponse } from "axios";
import { useParams, useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { GetResourceAuthInfoResponse } from "@server/routers/resource";
import { toast } from "@app/hooks/useToast";
import {
SettingsContainer,
@ -53,6 +52,15 @@ import { subdomainSchema } from "@server/lib/schemas";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Label } from "@app/components/ui/label";
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
.object({
@ -60,7 +68,8 @@ const GeneralFormSchema = z
name: z.string().min(1).max(255),
proxyPort: z.number().optional(),
http: z.boolean(),
isBaseDomain: z.boolean().optional()
isBaseDomain: z.boolean().optional(),
domainId: z.string().optional()
})
.refine(
(data) => {
@ -100,6 +109,7 @@ type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
type TransferFormValues = z.infer<typeof TransferFormSchema>;
export default function GeneralForm() {
const [formKey, setFormKey] = useState(0);
const params = useParams();
const { resource, updateResource } = useResourceContext();
const { org } = useOrgContext();
@ -113,10 +123,13 @@ export default function GeneralForm() {
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [saveLoading, setSaveLoading] = useState(false);
const [domainSuffix, setDomainSuffix] = useState(org.org.domain);
const [transferLoading, setTransferLoading] = useState(false);
const [open, setOpen] = useState(false);
const [baseDomains, setBaseDomains] = useState<
ListDomainsResponse["domains"]
>([]);
const [loadingPage, setLoadingPage] = useState(true);
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
resource.isBaseDomain ? "basedomain" : "subdomain"
);
@ -128,7 +141,8 @@ export default function GeneralForm() {
subdomain: resource.subdomain ? resource.subdomain : undefined,
proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
http: resource.http,
isBaseDomain: resource.isBaseDomain ? true : false
isBaseDomain: resource.isBaseDomain ? true : false,
domainId: resource.domainId || undefined
},
mode: "onChange"
});
@ -147,19 +161,54 @@ export default function GeneralForm() {
);
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) {
setSaveLoading(true);
const res = await api
.post(`resource/${resource?.resourceId}`, {
name: data.name,
subdomain: data.subdomain,
proxyPort: data.proxyPort,
isBaseDomain: data.isBaseDomain
})
.post<AxiosResponse<UpdateResourceResponse>>(
`resource/${resource?.resourceId}`,
{
name: data.name,
subdomain: data.http ? data.subdomain : undefined,
proxyPort: data.proxyPort,
isBaseDomain: data.http ? data.isBaseDomain : undefined,
domainId: data.http ? data.domainId : undefined
}
)
.catch((e) => {
toast({
variant: "destructive",
@ -177,12 +226,17 @@ export default function GeneralForm() {
description: "The resource has been updated successfully"
});
const resource = res.data.data;
updateResource({
name: data.name,
subdomain: data.subdomain,
proxyPort: data.proxyPort,
isBaseDomain: data.isBaseDomain
isBaseDomain: data.isBaseDomain,
fullDomain: resource.fullDomain
});
router.refresh();
}
setSaveLoading(false);
}
@ -211,323 +265,415 @@ export default function GeneralForm() {
description: "The resource has been transferred successfully"
});
router.refresh();
updateResource({
siteName:
sites.find((site) => site.siteId === data.siteId)?.name ||
""
});
}
setTransferLoading(false);
}
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Settings
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the general settings for this resource
</SettingsSectionDescription>
</SettingsSectionHeader>
!loadingPage && (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Settings
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the general settings for this resource
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
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 && (
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form} key={formKey}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 gap-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="proxyPort"
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
Port Number
</FormLabel>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Enter port number"
value={
field.value ?? ""
}
onChange={(e) =>
field.onChange(
e.target.value
? parseInt(
e
.target
.value
)
: null
)
}
/>
<Input {...field} />
</FormControl>
<FormDescription>
This is the port that will
be used to access the
resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="general-settings-form"
>
Save Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
{resource.http && (
<>
{env.flags
.allowBaseDomainResources && (
<FormField
control={form.control}
name="isBaseDomain"
render={({ field }) => (
<FormItem>
<FormLabel>
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>
<SettingsSectionHeader>
<SettingsSectionTitle>
Transfer Resource
</SettingsSectionTitle>
<SettingsSectionDescription>
Transfer this resource to a different site
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...transferForm}>
<form
onSubmit={transferForm.handleSubmit(onTransfer)}
className="space-y-4"
id="transfer-form"
>
<FormField
control={transferForm.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
Destination Site
</FormLabel>
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(site) =>
site.siteId ===
field.value
)?.name
: "Select site"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput
placeholder="Search sites..."
className="h-9"
/>
<CommandEmpty>
No sites found.
</CommandEmpty>
<CommandGroup>
{sites.map(
(site) => (
<CommandItem
value={`${site.name}:${site.siteId}`}
key={
site.siteId
}
onSelect={() => {
transferForm.setValue(
"siteId",
site.siteId
);
setOpen(
false
);
}}
>
{
site.name
}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
site.siteId ===
<div className="col-span-2">
{domainType === "subdomain" ? (
<div className="w-fill space-y-2">
<FormLabel>
Subdomain
</FormLabel>
<div className="flex">
<div className="w-1/2">
<FormField
control={
form.control
}
name="subdomain"
render={({
field
}) => (
<FormItem>
<FormControl>
<Input
{...field}
className="border-r-0 rounded-r-none"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="w-1/2">
<FormField
control={
form.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
)
)}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
Select the new site to transfer
this resource to.
</FormDescription>
<FormMessage />
</FormItem>
}
value={
field.value
}
>
<FormControl>
<SelectTrigger className="rounded-l-none">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
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>
<Button
type="submit"
loading={transferLoading}
disabled={transferLoading}
form="transfer-form"
variant="destructive"
>
Transfer Resource
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
{!resource.http && (
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
Port Number
</FormLabel>
<FormControl>
<Input
type="number"
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>
)
);
}

View file

@ -130,9 +130,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
<OrgProvider org={org}>
<ResourceProvider resource={resource} authInfo={authInfo}>
<SidebarSettings sidebarNavItems={sidebarNavItems}>
<div className="mb-8">
<ResourceInfoBox />
</div>
<ResourceInfoBox />
{children}
</SidebarSettings>
</ResourceProvider>

View file

@ -33,6 +33,7 @@ import {
import {
Table,
TableBody,
TableCaption,
TableCell,
TableContainer,
TableHead,
@ -92,9 +93,9 @@ enum RuleAction {
}
enum RuleMatch {
PATH = "Path",
IP = "IP",
CIDR = "IP Range",
PATH = "Path"
CIDR = "IP Range"
}
export default function ResourceRules(props: {
@ -469,9 +470,9 @@ export default function ResourceRules(props: {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
</SelectContent>
</Select>
)
@ -524,7 +525,13 @@ export default function ResourceRules(props: {
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel()
getFilteredRowModel: getFilteredRowModel(),
state: {
pagination: {
pageIndex: 0,
pageSize: 1000
}
}
});
if (pageLoading) {
@ -617,7 +624,7 @@ export default function ResourceRules(props: {
onSubmit={addRuleForm.handleSubmit(addRule)}
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
control={addRuleForm.control}
name="action"
@ -665,17 +672,17 @@ export default function ResourceRules(props: {
<SelectValue />
</SelectTrigger>
<SelectContent>
{resource.http && (
<SelectItem value="PATH">
{RuleMatch.PATH}
</SelectItem>
)}
<SelectItem value="IP">
{RuleMatch.IP}
</SelectItem>
<SelectItem value="CIDR">
{RuleMatch.CIDR}
</SelectItem>
{resource.http && (
<SelectItem value="PATH">
{RuleMatch.PATH}
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
@ -705,68 +712,63 @@ export default function ResourceRules(props: {
</FormItem>
)}
/>
<Button
type="submit"
variant="outlinePrimary"
disabled={!rulesEnabled}
>
Add Rule
</Button>
</div>
<Button
type="submit"
variant="outline"
disabled={!rulesEnabled}
>
Add Rule
</Button>
</form>
</Form>
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column
.columnDef.header,
header.getContext()
)}
</TableHead>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column
.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No rules. Add a rule using the form.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<p className="text-sm text-muted-foreground">
Rules are evaluated by priority in ascending order.
</p>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No rules. Add a rule using the form.
</TableCell>
</TableRow>
)}
</TableBody>
<TableCaption>
Rules are evaluated by priority in ascending order.
</TableCaption>
</Table>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button

View file

@ -107,7 +107,12 @@ export default function CreateShareLinkForm({
const [isOpen, setIsOpen] = useState(false);
const [resources, setResources] = useState<
{ resourceId: number; name: string; resourceUrl: string }[]
{
resourceId: number;
name: string;
resourceUrl: string;
siteName: string | null;
}[]
>([]);
const timeUnits = [
@ -152,13 +157,16 @@ export default function CreateShareLinkForm({
if (res?.status === 200) {
setResources(
res.data.data.resources.filter((r) => {
return r.http;
}).map((r) => ({
resourceId: r.resourceId,
name: r.name,
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
}))
res.data.data.resources
.filter((r) => {
return r.http;
})
.map((r) => ({
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
);
setDirectLink(directLink);
const resource = resources.find((r) => r.resourceId === values.resourceId);
onCreated?.({
accessTokenId: token.accessTokenId,
resourceId: token.resourceId,
resourceName: values.resourceName,
title: token.title,
createdAt: token.createdAt,
expiresAt: token.expiresAt
expiresAt: token.expiresAt,
siteName: resource?.siteName || null,
});
}
setLoading(false);
}
function getSelectedResourceName(id: number) {
const resource = resources.find((r) => r.resourceId === id);
return `${resource?.name} ${resource?.siteName ? `(${resource.siteName})` : ""}`;
}
return (
<>
<Credenza
@ -274,7 +291,7 @@ export default function CreateShareLinkForm({
name="resourceId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="mb-2">
<FormLabel>
Resource
</FormLabel>
<Popover>
@ -290,14 +307,9 @@ export default function CreateShareLinkForm({
)}
>
{field.value
? resources.find(
(
r
) =>
r.resourceId ===
field.value
? getSelectedResourceName(
field.value
)
?.name
: "Select resource"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@ -305,7 +317,7 @@ export default function CreateShareLinkForm({
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="Search resources..." />
<CommandInput placeholder="Search resources" />
<CommandList>
<CommandEmpty>
No
@ -318,9 +330,7 @@ export default function CreateShareLinkForm({
r
) => (
<CommandItem
value={
`${r.name}:${r.resourceId}`
}
value={`${r.name}:${r.resourceId}`}
key={
r.resourceId
}
@ -348,9 +358,7 @@ export default function CreateShareLinkForm({
: "opacity-0"
)}
/>
{
r.name
}
{`${r.name} ${r.siteName ? `(${r.siteName})` : ""}`}
</CommandItem>
)
)}
@ -369,14 +377,11 @@ export default function CreateShareLinkForm({
name="title"
render={({ field }) => (
<FormItem>
<Label>
<FormLabel>
Title (optional)
</Label>
</FormLabel>
<FormControl>
<Input
placeholder="Enter title"
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -384,67 +389,68 @@ export default function CreateShareLinkForm({
/>
<div className="space-y-4">
<Label>Expire In</Label>
<div className="grid grid-cols-2 gap-4 mt-2">
<FormField
control={form.control}
name="timeUnit"
render={({ field }) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
defaultValue={field.value.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
</FormControl>
<SelectContent>
{timeUnits.map(
(
option
) => (
<SelectItem
key={
option.unit
}
value={
option.unit
}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel>Expire In</FormLabel>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="timeUnit"
render={({ field }) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
defaultValue={field.value.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
</FormControl>
<SelectContent>
{timeUnits.map(
(
option
) => (
<SelectItem
key={
option.unit
}
value={
option.unit
}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeValue"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
type="number"
min={1}
placeholder="Enter duration"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeValue"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
type="number"
min={1}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className="flex items-center space-x-2">
@ -554,6 +560,9 @@ export default function CreateShareLinkForm({
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="button"
onClick={form.handleSubmit(onSubmit)}
@ -562,9 +571,6 @@ export default function CreateShareLinkForm({
>
Create Link
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View file

@ -41,6 +41,7 @@ export type ShareLinkRow = {
title: string | null;
createdAt: number;
expiresAt: number | null;
siteName: string | null;
};
type ShareLinksTableProps = {
@ -145,7 +146,8 @@ export default function ShareLinksTable({
return (
<Link href={`/${orgId}/settings/resources/${r.resourceId}`}>
<Button variant="outline">
{r.resourceName}
{r.resourceName}{" "}
{r.siteName ? `(${r.siteName})` : ""}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
@ -273,6 +275,21 @@ export default function ShareLinksTable({
}
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>
)
}
];

View file

@ -41,6 +41,7 @@ import Link from "next/link";
import {
ArrowUpRight,
ChevronsUpDown,
Loader2,
SquareArrowOutUpRight
} from "lucide-react";
import {
@ -48,6 +49,7 @@ import {
CollapsibleContent,
CollapsibleTrigger
} from "@app/components/ui/collapsible";
import LoaderPlaceholder from "@app/components/PlaceHolderLoader";
const createSiteFormSchema = z.object({
name: z
@ -97,6 +99,8 @@ export default function CreateSiteForm({
const [siteDefaults, setSiteDefaults] =
useState<PickSiteDefaultsResponse | null>(null);
const [loadingPage, setLoadingPage] = useState(true);
const handleCheckboxChange = (checked: boolean) => {
// setChecked?.(checked);
setIsChecked(checked);
@ -121,27 +125,36 @@ export default function CreateSiteForm({
useEffect(() => {
if (!open) return;
// reset all values
setLoading?.(false);
setIsLoading(false);
form.reset();
setChecked?.(false);
setKeypair(null);
setSiteDefaults(null);
const load = async () => {
setLoadingPage(true);
// reset all values
setLoading?.(false);
setIsLoading(false);
form.reset();
setChecked?.(false);
setKeypair(null);
setSiteDefaults(null);
const generatedKeypair = generateKeypair();
setKeypair(generatedKeypair);
const generatedKeypair = generateKeypair();
setKeypair(generatedKeypair);
api.get(`/org/${orgId}/pick-site-defaults`)
.catch((e) => {
// update the default value of the form to be local method
form.setValue("method", "local");
})
.then((res) => {
if (res && res.status === 200) {
setSiteDefaults(res.data.data);
}
});
await api
.get(`/org/${orgId}/pick-site-defaults`)
.catch((e) => {
// update the default value of the form to be local method
form.setValue("method", "local");
})
.then((res) => {
if (res && res.status === 200) {
setSiteDefaults(res.data.data);
}
});
await new Promise((resolve) => setTimeout(resolve, 200));
setLoadingPage(false);
};
load();
}, [open]);
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}`;
return (
return loadingPage ? (
<LoaderPlaceholder height="300px" />
) : (
<div className="space-y-4">
<Form {...form}>
<form
@ -272,17 +287,12 @@ PersistentKeepalive = 5`
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder="Site name"
{...field}
/>
<Input autoComplete="off" {...field} />
</FormControl>
<FormDescription>
This is the name that will be displayed for
this site.
</FormDescription>
<FormMessage />
<FormDescription>
This is the the display name for the site.
</FormDescription>
</FormItem>
)}
/>
@ -319,10 +329,10 @@ PersistentKeepalive = 5`
</SelectContent>
</Select>
</FormControl>
<FormMessage />
<FormDescription>
This is how you will expose connections.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@ -335,7 +345,6 @@ PersistentKeepalive = 5`
rel="noopener noreferrer"
>
<span>
{" "}
Learn how to install Newt on your system
</span>
<SquareArrowOutUpRight size={14} />
@ -354,7 +363,7 @@ PersistentKeepalive = 5`
) : form.watch("method") === "wireguard" &&
isLoading ? (
<p>Loading WireGuard configuration...</p>
) : form.watch("method") === "newt" ? (
) : form.watch("method") === "newt" && siteDefaults ? (
<>
<div className="mb-2">
<Collapsible
@ -362,12 +371,16 @@ PersistentKeepalive = 5`
onOpenChange={setIsOpen}
className="space-y-2"
>
<div className="mx-auto">
<div className="mx-auto mb-2">
<CopyTextBox
text={newtConfig}
wrapText={false}
/>
</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">
<CollapsibleTrigger asChild>
<Button
@ -376,8 +389,8 @@ PersistentKeepalive = 5`
className="p-0 flex items-center justify-between w-full"
>
<h4 className="text-sm font-semibold">
Expand for Docker Deployment
Details
Expand for Docker
Deployment Details
</h4>
<div>
<ChevronsUpDown className="h-4 w-4" />
@ -409,10 +422,6 @@ PersistentKeepalive = 5`
</CollapsibleContent>
</Collapsible>
</div>
<span className="text-sm text-muted-foreground">
You will only be able to see the
configuration once.
</span>
</>
) : null}
</div>

View file

@ -58,6 +58,9 @@ export default function CreateSiteFormModal({
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="create-site-form"
@ -69,9 +72,6 @@ export default function CreateSiteFormModal({
>
Create Site
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View file

@ -268,7 +268,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
<Link
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<Button variant={"outline"} className="ml-2">
<Button variant={"outlinePrimary"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>

View file

@ -3,7 +3,6 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { InfoIcon } from "lucide-react";
import { useSiteContext } from "@app/hooks/useSiteContext";
import { Separator } from "@app/components/ui/separator";
import {
InfoSection,
InfoSectionContent,
@ -33,7 +32,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">Site Information</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections>
<InfoSections cols={2}>
{(site.type == "newt" || site.type == "wireguard") && (
<>
<InfoSection>
@ -52,8 +51,6 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
)}
</InfoSectionContent>
</InfoSection>
<Separator orientation="vertical" />
</>
)}
<InfoSection>

View file

@ -33,7 +33,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useState } from "react";
const GeneralFormSchema = z.object({
name: z.string()
name: z.string().nonempty("Name is required")
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@ -114,11 +114,11 @@ export default function GeneralPage() {
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This is the display name of the
site
site.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>

View file

@ -68,9 +68,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
<SiteProvider site={site}>
<SidebarSettings sidebarNavItems={sidebarNavItems}>
<div className="mb-8">
<SiteInfoCard />
</div>
<SiteInfoCard />
{children}
</SidebarSettings>
</SiteProvider>

View file

@ -41,7 +41,7 @@ export default async function Page(props: {
Looks like you've been invited!
</h2>
<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.
</p>
</div>

View file

@ -38,7 +38,7 @@ import { Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "../../../components/ui/alert";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
@ -182,7 +182,7 @@ export default function ResetPasswordForm({
return;
}
setSuccessMessage("Password reset successfully! Back to login...");
setSuccessMessage("Password reset successfully! Back to log in...");
setTimeout(() => {
if (redirect) {
@ -223,16 +223,13 @@ export default function ResetPasswordForm({
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Enter your email"
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
We'll send a password reset
code to this email address.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@ -255,7 +252,6 @@ export default function ResetPasswordForm({
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Email"
{...field}
disabled
/>
@ -276,12 +272,15 @@ export default function ResetPasswordForm({
</FormLabel>
<FormControl>
<Input
placeholder="Enter reset code sent to your email"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
Check your email for the
reset code.
</FormDescription>
</FormItem>
)}
/>
@ -298,7 +297,6 @@ export default function ResetPasswordForm({
<FormControl>
<Input
type="password"
placeholder="Password"
{...field}
/>
</FormControl>
@ -317,7 +315,6 @@ export default function ResetPasswordForm({
<FormControl>
<Input
type="password"
placeholder="Confirm Password"
{...field}
/>
</FormControl>
@ -349,7 +346,9 @@ export default function ResetPasswordForm({
<InputOTP
maxLength={6}
{...field}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
pattern={
REGEXP_ONLY_DIGITS_AND_CHARS
}
>
<InputOTPGroup>
<InputOTPSlot

View file

@ -263,7 +263,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
}
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>
<FormControl>
<Input
placeholder="Enter password"
type="password"
{...field}
/>
@ -517,7 +517,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</FormLabel>
<FormControl>
<Input
placeholder="Enter email"
type="email"
{...field}
/>
@ -576,7 +575,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</FormLabel>
<FormControl>
<Input
placeholder="Enter OTP"
type="password"
{...field}
/>

View file

@ -145,7 +145,7 @@ export default function SignupForm({
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="Email" {...field} />
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -160,7 +160,6 @@ export default function SignupForm({
<FormControl>
<Input
type="password"
placeholder="Password"
{...field}
/>
</FormControl>
@ -177,7 +176,6 @@ export default function SignupForm({
<FormControl>
<Input
type="password"
placeholder="Confirm Password"
{...field}
/>
</FormControl>

View file

@ -57,7 +57,7 @@ export default async function Page(props: {
Looks like you've been invited!
</h2>
<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.
</p>
</div>

View file

@ -145,7 +145,6 @@ export default function VerifyEmailForm({
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Email"
{...field}
disabled
/>
@ -196,12 +195,11 @@ export default function VerifyEmailForm({
</InputOTP>
</div>
</FormControl>
<FormMessage />
<FormDescription>
We sent a verification code to your
email address. Please enter the code
to verify your email address.
email address.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>

View file

@ -21,8 +21,8 @@
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 85%;
--input: 20 5.9% 85%;
--border: 20 5.9% 80%;
--input: 20 5.9% 75%;
--ring: 24.6 95% 53.1%;
--radius: 0.75rem;
--chart-1: 12 76% 61%;
@ -49,8 +49,8 @@
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 25.0%;
--input: 12 6.5% 25.0%;
--border: 12 6.5% 30.0%;
--input: 12 6.5% 35.0%;
--ring: 20.5 90.2% 48.2%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;

Some files were not shown because too many files have changed in this diff Show more