Compare commits

..

34 Commits

Author SHA1 Message Date
Milo Schwartz
025c2c5306 Merge pull request #33 from fosrl/hotfix
fix regex for base_domain
2025-01-11 19:59:23 -05:00
Milo Schwartz
fa39b708a9 fix regex for base_domain 2025-01-11 19:56:49 -05:00
Milo Schwartz
5774e534e5 Merge pull request #32 from fosrl/dev
add site_block_size to config, improve target input form validation, and lock down redirects
2025-01-11 15:21:53 -05:00
Milo Schwartz
e32301ade4 Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-01-11 15:10:16 -05:00
Milo Schwartz
a2bf3ba7e7 router refresh on logout 2025-01-11 15:10:02 -05:00
Owen Schwartz
62ba797cd0 Update installer to work with new domain split 2025-01-11 14:46:01 -05:00
Milo Schwartz
82192fa180 Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-01-11 14:13:08 -05:00
Milo Schwartz
7b20329743 change target form verbiage and update readme 2025-01-11 13:32:06 -05:00
Owen Schwartz
a85303161c Constrict blocks and use CGNAT range for default 2025-01-11 12:36:28 -05:00
Owen Schwartz
38544cc2d6 Add site_block_size and migration for beta.3 2025-01-11 12:25:33 -05:00
Owen Schwartz
484a099ee3 Seperate ask for base domain and dashboard domain 2025-01-11 11:33:06 -05:00
Owen Schwartz
832d7e5d6d Rename "IP Address" to "IP / Hostname" 2025-01-11 11:17:49 -05:00
Owen Schwartz
c8c756df28 Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-01-11 11:14:44 -05:00
Milo Schwartz
c3d19454f7 allow resource redirect if host is same 2025-01-10 00:13:51 -05:00
Milo Schwartz
fcc6cad6d7 hide create button if create org disable and bump version 2025-01-09 23:39:45 -05:00
Milo Schwartz
6c813186b8 verify redirects are safe before redirecting 2025-01-09 23:26:07 -05:00
Milo Schwartz
a556339b76 allow hyphens in base_domain regex 2025-01-08 23:13:35 -05:00
Milo Schwartz
d2b10def35 Merge pull request #16 from fosrl/dev
add security policy
2025-01-08 21:54:52 -05:00
Milo Schwartz
4421f470a4 add security policy 2025-01-08 21:47:26 -05:00
Milo Schwartz
235e91294e remove base_url from config (#13)
* add example config dir, logos, and update CONTRIBUTING.md

* update dockerignore

* split base_url into dashboard_url and base_domain

* Remove unessicary ports

* Allow anything for the ip

* Update docker tags

* Complex regex for domains/ips

* update gitignore

---------

Co-authored-by: Owen Schwartz <owen@txv.io>
2025-01-07 22:41:35 -05:00
Milo Schwartz
184a22c238 Merge branch 'main' into dev 2025-01-07 22:41:20 -05:00
Milo Schwartz
b598fc3fba update gitignore 2025-01-07 22:37:20 -05:00
Owen Schwartz
dc7bd41eb9 Complex regex for domains/ips 2025-01-07 21:52:45 -05:00
Owen Schwartz
fb754bc4e0 Update docker tags 2025-01-07 21:45:12 -05:00
Owen Schwartz
ab69ded396 Allow anything for the ip 2025-01-07 21:31:32 -05:00
Owen Schwartz
b4dd827ce1 Remove unessicary ports 2025-01-07 21:25:49 -05:00
Milo Schwartz
e1f0834af4 split base_url into dashboard_url and base_domain 2025-01-07 20:32:24 -05:00
Milo Schwartz
a36691e5ab docs and logos (#7)
* add example config dir, logos, and update CONTRIBUTING.md

* update dockerignore
2025-01-06 22:43:17 -05:00
Milo Schwartz
26a165ab71 update dockerignore 2025-01-06 22:36:06 -05:00
Milo Schwartz
7ab89b1adb add example config dir, logos, and update CONTRIBUTING.md 2025-01-06 22:25:37 -05:00
Owen Schwartz
b1d111a089 Merge pull request #2 from eltociear/patch-1
docs: update README.md
2025-01-06 11:38:56 -05:00
Owen Schwartz
9e8086908d Fix installer on arm 2025-01-06 09:58:00 -05:00
Ikko Eltociear Ashimine
cf6e48be9a docs: update README.md
Automaticlaly -> Automatically
2025-01-06 14:19:01 +09:00
Owen Schwartz
1df1b55e24 Fix docker install on debain 2025-01-05 23:23:43 -05:00
61 changed files with 536 additions and 173 deletions

View File

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

4
.gitignore vendored
View File

@@ -25,7 +25,9 @@ next-env.d.ts
migrations
package-lock.json
tsconfig.tsbuildinfo
config/
config/config.yml
dist
.dist
installer
*.tar
bin

View File

@@ -1,6 +1,12 @@
## Contributing
Contributions are welcome! Please see the following page in our documentation with future plans and feature ideas if you are looking for a place to start.
Contributions are welcome!
Please see the contribution and local development guide on the docs page before getting started:
https://docs.fossorial.io/development
For ideas about what features to work on and our future plans, please see the roadmap:
https://docs.fossorial.io/roadmap
@@ -15,4 +21,4 @@ By creating this pull request, I grant the project maintainers an unlimited,
perpetual license to use, modify, and redistribute these contributions under any terms they
choose, including both the AGPLv3 and the Fossorial Commercial license terms. I
represent that I have the right to grant this license for all contributed content.
```
```

View File

@@ -26,7 +26,7 @@ COPY --from=builder /app/.next ./.next
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/init ./dist/init
COPY config.example.yml ./dist/config.example.yml
COPY config/config.example.yml ./dist/config.example.yml
COPY server/db/names.json ./dist/names.json
COPY public ./public

View File

@@ -1,18 +1,23 @@
all: build push
build-release:
@if [ -z "$(tag)" ]; then \
echo "Error: tag is required. Usage: make build-all tag=<tag>"; \
exit 1; \
fi
docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:latest -f Dockerfile --push .
docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:$(tag) -f Dockerfile --push .
build-arm:
docker buildx build --platform linux/arm64 -t fosrl/pangolin:latest .
build-x86:
docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest .
docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest .
build-x86-ecr:
docker buildx build --platform linux/amd64 -t 216989133116.dkr.ecr.us-east-1.amazonaws.com/pangolin:latest --push .
build:
docker build -t fosrl/pangolin:latest .
push:
docker push fosrl/pangolin:latest
test:
docker run -it -p 3000:3000 -p 3001:3001 -p 3002:3002 -v ./config:/app/config fosrl/pangolin:latest

View File

@@ -1,5 +1,11 @@
# Pangolin
[![Documentation](https://img.shields.io/badge/docs-latest-blue.svg?style=flat-square)](https://docs.fossorial.io/)
[![Docker](https://img.shields.io/docker/pulls/fosrl/pangolin?style=flat-square)](https://hub.docker.com/r/fosrl/pangolin)
![Stars](https://img.shields.io/github/stars/fosrl/pangolin?style=flat-square)
[![Discord](https://img.shields.io/discord/1325658630518865980?logo=discord&style=flat-square)](https://discord.gg/HCJR8Xhme4)
[![Youtube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@fossorial-app)
Pangolin is a self-hosted tunneled reverse proxy management server with identity and access management, designed to securely expose private resources through use with the Traefik reverse proxy and WireGuard tunnel clients like Newt. With Pangolin, you retain full control over your infrastructure while providing a user-friendly and feature-rich solution for managing proxies, authentication, and access, and simplifying complex network setups, all with a clean and simple UI.
### Installation and Documentation
@@ -96,7 +102,7 @@ Pangolin has a straightforward and simple dashboard UI:
3. **Connect Private Sites**:
- Install Newt or use another WireGuard client on private sites.
- Automaticlaly establish a connection from these sites to the central server.
- Automatically establish a connection from these sites to the central server.
4. **Configure Users & Roles**
- Define organizations and invite users.
- Implement user- or role-based permissions to control resource access.
@@ -123,4 +129,7 @@ Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license.
## Contributions
Please see [CONTRIBUTIONS](./CONTRIBUTING.md) in the repository for guidelines and best practices.
Please see [CONTRIBUTING](./CONTRIBUTING.md) in the repository for guidelines and best practices.
Please post bug reports and other functional issues in the [Issues](https://github.com/fosrl/pangolin/issues) section of the repository.
For all feature requests, or other ideas, please use the [Discussions](https://github.com/orgs/fosrl/discussions) section.

14
SECURITY.md Normal file
View File

@@ -0,0 +1,14 @@
# Security Policy
If you discover a security vulnerability, please follow the steps below to responsibly disclose it to us:
1. **Do not create a public GitHub issue or discussion post.** This could put the security of other users at risk.
2. Send a detailed report to [security@fossorial.io](mailto:security@fossorial.io) or send a **private** message to a maintainer on [Discord](https://discord.gg/HCJR8Xhme4). Include:
- Description and location of the vulnerability.
- Potential impact of the vulnerability.
- Steps to reproduce the vulnerability.
- Potential solutions to fix the vulnerability.
- Your name/handle and a link for recognition (optional).
We aim to address the issue as soon as possible.

0
config/.gitkeep Normal file
View File

View File

@@ -1,13 +1,14 @@
app:
base_url: https://proxy.example.com
log_level: info
dashboard_url: http://localhost
base_domain: localhost
log_level: debug
save_logs: false
server:
external_port: 3000
internal_port: 3001
next_port: 3002
internal_hostname: pangolin
internal_hostname: localhost
secure_cookies: false
session_cookie_name: p_session
resource_session_cookie_name: p_resource_session
@@ -16,34 +17,24 @@ traefik:
cert_resolver: letsencrypt
http_entrypoint: web
https_entrypoint: websecure
prefer_wildcard_cert: true
gerbil:
start_port: 51820
base_endpoint: proxy.example.com
use_subdomain: false
block_size: 16
subnet_group: 10.0.0.0/8
base_endpoint: localhost
block_size: 24
site_block_size: 30
subnet_group: 100.89.137.0/20
use_subdomain: true
rate_limits:
global:
window_minutes: 1
max_requests: 100
email:
smtp_host: host.hoster.net
smtp_port: 587
smtp_user: no-reply@example.com
smtp_pass: aaaaaaaaaaaaaaaaaa
no_reply: no-reply@example.com
users:
server_admin:
email: admin@example.com
password: Password123!
flags:
require_email_verification: true
disable_signup_without_invite: true
disable_user_create_org: true
require_email_verification: false

0
config/db/.gitkeep Normal file
View File

0
config/logs/.gitkeep Normal file
View File

View File

@@ -2,12 +2,9 @@ version: "3.7"
services:
pangolin:
image: fosrl/pangolin:1.0.0-beta.1
image: fosrl/pangolin:latest
container_name: pangolin
restart: unless-stopped
ports:
- 3001:3001
- 3000:3000
volumes:
- ./config:/app/config
healthcheck:
@@ -17,7 +14,7 @@ services:
retries: 5
gerbil:
image: fosrl/gerbil:1.0.0-beta.1
image: fosrl/gerbil:latest
container_name: gerbil
restart: unless-stopped
depends_on:

View File

@@ -1,8 +1,14 @@
all: build
build:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o installer
build:
CGO_ENABLED=0 go build -o bin/installer
release:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
clean:
rm installer
rm bin/installer
rm bin/installer_linux_amd64
rm bin/installer_linux_arm64

View File

@@ -1,5 +1,6 @@
app:
base_url: https://{{.Domain}}
dashboard_url: https://{{.DashboardDomain}}
base_domain: {{.BaseDomain}}
log_level: info
save_logs: false
@@ -20,10 +21,11 @@ traefik:
gerbil:
start_port: 51820
base_endpoint: {{.Domain}}
base_endpoint: {{.DashboardDomain}}
use_subdomain: false
block_size: 16
subnet_group: 10.0.0.0/8
block_size: 24
site_block_size: 30
subnet_group: 100.89.137.0/20
rate_limits:
global:

View File

@@ -1,11 +1,8 @@
services:
pangolin:
image: fosrl/pangolin:1.0.0-beta.1
image: fosrl/pangolin:latest
container_name: pangolin
restart: unless-stopped
ports:
- 3001:3001
- 3000:3000
volumes:
- ./config:/app/config
healthcheck:
@@ -15,7 +12,7 @@ services:
retries: 5
gerbil:
image: fosrl/gerbil:1.0.0-beta.1
image: fosrl/gerbil:latest
container_name: gerbil
restart: unless-stopped
depends_on:

View File

@@ -8,7 +8,7 @@ http:
routers:
# HTTP to HTTPS redirect router
main-app-router-redirect:
rule: "Host(`{{.Domain}}`)"
rule: "Host(`{{.DashboardDomain}}`)"
service: next-service
entryPoints:
- web
@@ -17,7 +17,7 @@ http:
# Next.js router (handles everything except API and WebSocket paths)
next-router:
rule: "Host(`{{.Domain}}`) && !PathPrefix(`/api/v1`)"
rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)"
service: next-service
entryPoints:
- websecure
@@ -26,7 +26,7 @@ http:
# API router (handles /api/v1 paths)
api-router:
rule: "Host(`{{.Domain}}`) && PathPrefix(`/api/v1`)"
rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)"
service: api-service
entryPoints:
- websecure
@@ -35,7 +35,7 @@ http:
# WebSocket router
ws-router:
rule: "Host(`{{.Domain}}`)"
rule: "Host(`{{.DashboardDomain}}`)"
service: api-service
entryPoints:
- websecure

View File

@@ -18,7 +18,8 @@ import (
var configFiles embed.FS
type Config struct {
Domain string `yaml:"domain"`
BaseDomain string `yaml:"baseDomain"`
DashboardDomain string `yaml:"dashboardUrl"`
LetsEncryptEmail string `yaml:"letsEncryptEmail"`
AdminUserEmail string `yaml:"adminUserEmail"`
AdminUserPassword string `yaml:"adminUserPassword"`
@@ -44,7 +45,10 @@ func main() {
// check if there is already a config file
if _, err := os.Stat("config/config.yml"); err != nil {
config := collectUserInput(reader)
createConfigFiles(config)
if err := createConfigFiles(config); err != nil {
fmt.Printf("Error creating config files: %v\n", err)
os.Exit(1)
}
if !isDockerInstalled() && runtime.GOOS == "linux" {
if shouldInstallDocker() {
@@ -102,12 +106,13 @@ func collectUserInput(reader *bufio.Reader) Config {
// Basic configuration
fmt.Println("\n=== Basic Configuration ===")
config.Domain = readString(reader, "Enter your domain name", "")
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", "pangolin."+config.BaseDomain)
config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "")
// Admin user configuration
fmt.Println("\n=== Admin User Configuration ===")
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.Domain)
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain)
for {
config.AdminUserPassword = readString(reader, "Enter admin user password", "")
if valid, message := validatePassword(config.AdminUserPassword); valid {
@@ -140,10 +145,14 @@ func collectUserInput(reader *bufio.Reader) Config {
}
// Validate required fields
if config.Domain == "" {
if config.BaseDomain == "" {
fmt.Println("Error: Domain name is required")
os.Exit(1)
}
if config.DashboardDomain == "" {
fmt.Println("Error: Dashboard Domain name is required")
os.Exit(1)
}
if config.LetsEncryptEmail == "" {
fmt.Println("Error: Let's Encrypt email is required")
os.Exit(1)
@@ -269,8 +278,26 @@ func createConfigFiles(config Config) error {
return fmt.Errorf("error walking config files: %v", err)
}
// move the docker-compose.yml file to the root directory
os.Rename("config/docker-compose.yml", "docker-compose.yml")
// 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
}
@@ -289,39 +316,66 @@ func installDocker() error {
if err != nil {
return fmt.Errorf("failed to detect Linux distribution: %v", err)
}
osRelease := string(output)
var installCmd *exec.Cmd
// Detect system architecture
archCmd := exec.Command("uname", "-m")
archOutput, err := archCmd.Output()
if err != nil {
return fmt.Errorf("failed to detect system architecture: %v", err)
}
arch := strings.TrimSpace(string(archOutput))
// Map architecture to Docker's architecture naming
var dockerArch string
switch arch {
case "x86_64":
dockerArch = "amd64"
case "aarch64":
dockerArch = "arm64"
default:
return fmt.Errorf("unsupported architecture: %s", arch)
}
var installCmd *exec.Cmd
switch {
case strings.Contains(osRelease, "ID=ubuntu") || strings.Contains(osRelease, "ID=debian"):
installCmd = exec.Command("bash", "-c", `
apt-get update &&
apt-get install -y apt-transport-https ca-certificates curl software-properties-common &&
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg &&
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list &&
apt-get update &&
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
`)
case strings.Contains(osRelease, "ID=ubuntu"):
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
apt-get update &&
apt-get install -y apt-transport-https ca-certificates curl software-properties-common &&
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg &&
echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list &&
apt-get update &&
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
`, dockerArch))
case strings.Contains(osRelease, "ID=debian"):
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
apt-get update &&
apt-get install -y apt-transport-https ca-certificates curl software-properties-common &&
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg &&
echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list &&
apt-get update &&
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
`, dockerArch))
case strings.Contains(osRelease, "ID=fedora"):
installCmd = exec.Command("bash", "-c", `
dnf -y install dnf-plugins-core &&
dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo &&
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
`)
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
dnf -y install dnf-plugins-core &&
dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo &&
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
`))
case strings.Contains(osRelease, "ID=opensuse") || strings.Contains(osRelease, "ID=\"opensuse-"):
installCmd = exec.Command("bash", "-c", `
zypper install -y docker docker-compose &&
systemctl enable docker
`)
case strings.Contains(osRelease, "ID=rhel") || strings.Contains(osRelease, "ID=\"rhel"):
installCmd = exec.Command("bash", "-c", `
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
dnf remove -y runc &&
dnf -y install yum-utils &&
dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo &&
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin &&
systemctl enable docker
`)
`))
case strings.Contains(osRelease, "ID=amzn"):
installCmd = exec.Command("bash", "-c", `
yum update -y &&
@@ -332,7 +386,6 @@ func installDocker() error {
default:
return fmt.Errorf("unsupported Linux distribution")
}
installCmd.Stdout = os.Stdout
installCmd.Stderr = os.Stderr
return installCmd.Run()

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -31,7 +31,7 @@ export function createApiServer() {
);
} else {
const corsOptions = {
origin: config.getRawConfig().app.base_url,
origin: config.getRawConfig().app.dashboard_url,
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
allowedHeaders: ["Content-Type", "X-CSRF-Token"]
};

View File

@@ -17,7 +17,7 @@ export async function sendEmailVerificationCode(
VerifyEmail({
username: email,
verificationCode: code,
verifyLink: `${config.getRawConfig().app.base_url}/auth/verify-email`
verifyLink: `${config.getRawConfig().app.dashboard_url}/auth/verify-email`
}),
{
to: email,

View File

@@ -3,18 +3,25 @@ import yaml from "js-yaml";
import path from "path";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
import { __DIRNAME, APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
import { loadAppVersion } from "@server/lib/loadAppVersion";
import { passwordSchema } from "@server/auth/passwordSchema";
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 environmentSchema = z.object({
app: z.object({
base_url: z
dashboard_url: z
.string()
.url()
.transform((url) => url.toLowerCase()),
base_domain: hostnameSchema,
log_level: z.enum(["debug", "info", "warn", "error"]),
save_logs: z.boolean()
}),
@@ -38,7 +45,8 @@ const environmentSchema = z.object({
base_endpoint: z.string().transform((url) => url.toLowerCase()),
use_subdomain: z.boolean(),
subnet_group: z.string(),
block_size: z.number().positive().gt(0)
block_size: z.number().positive().gt(0),
site_block_size: z.number().positive().gt(0)
}),
rate_limits: z.object({
global: z.object({
@@ -58,7 +66,7 @@ const environmentSchema = z.object({
smtp_port: portSchema,
smtp_user: z.string(),
smtp_pass: z.string(),
no_reply: z.string().email(),
no_reply: z.string().email()
})
.optional(),
users: z.object({
@@ -99,9 +107,6 @@ export class Config {
}
};
const configFilePath1 = path.join(APP_PATH, "config.yml");
const configFilePath2 = path.join(APP_PATH, "config.yaml");
let environment: any;
if (fs.existsSync(configFilePath1)) {
environment = loadConfig(configFilePath1);
@@ -190,15 +195,7 @@ export class Config {
}
public getBaseDomain(): string {
const newUrl = new URL(this.rawConfig.app.base_url);
const hostname = newUrl.hostname;
const parts = hostname.split(".");
if (parts.length <= 2) {
return parts.join(".");
}
return parts.slice(1).join(".");
return this.rawConfig.app.base_domain;
}
}

View File

@@ -6,3 +6,6 @@ export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);
export const APP_PATH = path.join("config");
export const configFilePath1 = path.join(APP_PATH, "config.yml");
export const configFilePath2 = path.join(APP_PATH, "config.yaml");

View File

@@ -82,7 +82,7 @@ export async function requestPasswordReset(
});
});
const url = `${config.getRawConfig().app.base_url}/auth/reset-password?email=${email}&token=${token}`;
const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${email}&token=${token}`;
await sendEmail(
ResetPasswordCode({

View File

@@ -101,7 +101,7 @@ export async function verifyResourceSession(
return allowed(res);
}
const redirectUrl = `${config.getRawConfig().app.base_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
if (!sessions) {
return notAllowed(res);

View File

@@ -82,7 +82,6 @@ export async function createOrg(
let org: Org | null = null;
await db.transaction(async (trx) => {
// create a url from config.getRawConfig().app.base_url and get the hostname
const domain = config.getBaseDomain();
const newOrg = await trx

View File

@@ -8,6 +8,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { findNextAvailableCidr } from "@server/lib/ip";
import { generateId } from "@server/auth/sessions/app";
import config from "@server/lib/config";
export type PickSiteDefaultsResponse = {
exitNodeId: number;
@@ -51,9 +52,9 @@ export async function pickSiteDefaults(
// TODO: we need to lock this subnet for some time so someone else does not take it
let subnets = sitesQuery.map((site) => site.subnet);
// exclude the exit node address by replacing after the / with a /28
subnets.push(exitNode.address.replace(/\/\d+$/, "/29"));
const newSubnet = findNextAvailableCidr(subnets, 29, exitNode.address);
// exclude the exit node address by replacing after the / with a site block size
subnets.push(exitNode.address.replace(/\/\d+$/, `/${config.getRawConfig().gerbil.site_block_size}`));
const newSubnet = findNextAvailableCidr(subnets, config.getRawConfig().gerbil.site_block_size, exitNode.address);
if (!newSubnet) {
return next(
createHttpError(

View File

@@ -12,6 +12,34 @@ import { isIpInCidr } from "@server/lib/ip";
import { fromError } from "zod-validation-error";
import { addTargets } from "../newt/targets";
// Regular expressions for validation
const DOMAIN_REGEX =
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
const IPV4_REGEX =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
// Schema for domain names and IP addresses
const domainSchema = z
.string()
.min(1, "Domain cannot be empty")
.max(255, "Domain name too long")
.refine(
(value) => {
// Check if it's a valid IP address (v4 or v6)
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
return true;
}
// Check if it's a valid domain name
return DOMAIN_REGEX.test(value);
},
{
message: "Invalid domain name or IP address format",
path: ["domain"]
}
);
const createTargetParamsSchema = z
.object({
resourceId: z
@@ -23,7 +51,7 @@ const createTargetParamsSchema = z
const createTargetSchema = z
.object({
ip: z.string().ip().or(z.literal('localhost')),
ip: domainSchema,
method: z.string().min(1).max(10),
port: z.number().int().min(1).max(65535),
protocol: z.string().optional(),

View File

@@ -11,6 +11,34 @@ import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers";
import { addTargets } from "../newt/targets";
// Regular expressions for validation
const DOMAIN_REGEX =
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
const IPV4_REGEX =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
// Schema for domain names and IP addresses
const domainSchema = z
.string()
.min(1, "Domain cannot be empty")
.max(255, "Domain name too long")
.refine(
(value) => {
// Check if it's a valid IP address (v4 or v6)
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
return true;
}
// Check if it's a valid domain name
return DOMAIN_REGEX.test(value);
},
{
message: "Invalid domain name or IP address format",
path: ["domain"]
}
);
const updateTargetParamsSchema = z
.object({
targetId: z.string().transform(Number).pipe(z.number().int().positive())
@@ -19,7 +47,7 @@ const updateTargetParamsSchema = z
const updateTargetBodySchema = z
.object({
ip: z.string().ip().or(z.literal('localhost')).optional(), // for now we cant update the ip; you will have to delete
ip: domainSchema.optional(),
method: z.string().min(1).max(10).optional(),
port: z.number().int().min(1).max(65535).optional(),
enabled: z.boolean().optional()

View File

@@ -72,6 +72,16 @@ export async function acceptInvite(
const { user, session } = await verifySession(req);
// at this point we know the user exists
if (!user) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"You must be logged in to accept an invite"
)
);
}
if (user && user.email !== existingInvite.email) {
return next(
createHttpError(

View File

@@ -152,7 +152,7 @@ export async function inviteUser(
});
});
const inviteLink = `${config.getRawConfig().app.base_url}/invite?token=${inviteId}-${token}`;
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}`;
if (doEmail) {
await sendEmail(

View File

@@ -5,7 +5,6 @@ import { eq, ne } from "drizzle-orm";
import logger from "@server/logger";
export async function copyInConfig() {
// create a url from config.getRawConfig().app.base_url and get the hostname
const domain = config.getBaseDomain();
const endpoint = config.getRawConfig().gerbil.base_endpoint;

View File

@@ -7,13 +7,17 @@ import { desc } from "drizzle-orm";
import { __DIRNAME } from "@server/lib/consts";
import { loadAppVersion } from "@server/lib/loadAppVersion";
import m1 from "./scripts/1.0.0-beta1";
import m2 from "./scripts/1.0.0-beta2";
import m3 from "./scripts/1.0.0-beta3";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
// Define the migration list with versions and their corresponding functions
const migrations = [
{ version: "1.0.0-beta.1", run: m1 }
{ version: "1.0.0-beta.1", run: m1 },
{ version: "1.0.0-beta.2", run: m2 },
{ version: "1.0.0-beta.3", run: m3 }
// Add new migrations here as they are created
] as const;

View File

@@ -1,7 +1,5 @@
import logger from "@server/logger";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.1");
console.log("Running setup script 1.0.0-beta.1...");
// SQL operations would go here in ts format
console.log("Done...");
console.log("Done.");
}

View File

@@ -0,0 +1,59 @@
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs";
import yaml from "js-yaml";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.2...");
// 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);
// Validate the structure
if (!rawConfig.app || !rawConfig.app.base_url) {
throw new Error(`Invalid config file: app.base_url is missing.`);
}
// Move base_url to dashboard_url and calculate base_domain
const baseUrl = rawConfig.app.base_url;
rawConfig.app.dashboard_url = baseUrl;
rawConfig.app.base_domain = getBaseDomain(baseUrl);
// Remove the old base_url
delete rawConfig.app.base_url;
// Write the updated YAML back to the file
const updatedYaml = yaml.dump(rawConfig);
fs.writeFileSync(filePath, updatedYaml, "utf8");
console.log("Done.");
}
function getBaseDomain(url: string): string {
const newUrl = new URL(url);
const hostname = newUrl.hostname;
const parts = hostname.split(".");
if (parts.length <= 2) {
return parts.join(".");
}
return parts.slice(-2).join(".");
}

View File

@@ -0,0 +1,42 @@
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs";
import yaml from "js-yaml";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.3...");
// 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);
// Validate the structure
if (!rawConfig.gerbil) {
throw new Error(`Invalid config file: gerbil is missing.`);
}
// Update the config
rawConfig.gerbil.site_block_size = 29;
// Write the updated YAML back to the file
const updatedYaml = yaml.dump(rawConfig);
fs.writeFileSync(filePath, updatedYaml, "utf8");
console.log("Done.");
}

View File

@@ -25,7 +25,7 @@ export default async function OrgLayout(props: {
const user = await getUser();
if (!user) {
redirect(`/?redirect=/${orgId}`);
redirect(`/`);
}
try {

View File

@@ -26,7 +26,7 @@ export default async function GeneralSettingsPage({
const user = await getUser();
if (!user) {
redirect(`/?redirect=/${orgId}/settings/general`);
redirect(`/`);
}
let orgUser = null;

View File

@@ -61,7 +61,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const user = await getUser();
if (!user) {
redirect(`/?redirect=/${params.orgId}/`);
redirect(`/`);
}
const cookie = await authCookieHeader();

View File

@@ -62,9 +62,38 @@ import {
SettingsSectionFooter
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { useSiteContext } from "@app/hooks/useSiteContext";
// Regular expressions for validation
const DOMAIN_REGEX =
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
const IPV4_REGEX =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
// Schema for domain names and IP addresses
const domainSchema = z
.string()
.min(1, "Domain cannot be empty")
.max(255, "Domain name too long")
.refine(
(value) => {
// Check if it's a valid IP address (v4 or v6)
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
return true;
}
// Check if it's a valid domain name
return DOMAIN_REGEX.test(value);
},
{
message: "Invalid domain name or IP address format",
path: ["domain"]
}
);
const addTargetSchema = z.object({
ip: z.union([z.string().ip(), z.literal("localhost")]),
ip: domainSchema,
method: z.string(),
port: z.coerce.number().int().positive()
// protocol: z.string(),
@@ -179,7 +208,7 @@ export default function ReverseProxyTargets(props: {
// make sure that the target IP is within the site subnet
const targetIp = data.ip;
const subnet = site.subnet;
if (targetIp === "localhost" || !isIPInSubnet(targetIp, subnet)) {
if (!isIPInSubnet(targetIp, subnet)) {
toast({
variant: "destructive",
title: "Invalid target IP",
@@ -323,7 +352,7 @@ export default function ReverseProxyTargets(props: {
},
{
accessorKey: "ip",
header: "IP Address",
header: "IP / Hostname",
cell: ({ row }) => (
<Input
defaultValue={row.original.ip}
@@ -500,11 +529,23 @@ export default function ReverseProxyTargets(props: {
name="ip"
render={({ field }) => (
<FormItem>
<FormLabel>IP Address</FormLabel>
<FormLabel>IP / Hostname</FormLabel>
<FormControl>
<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>
)}
/>
@@ -523,6 +564,19 @@ 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>
)}
/>

View File

@@ -2,13 +2,12 @@ import ResourceProvider from "@app/providers/ResourceProvider";
import { internal } from "@app/lib/api";
import {
GetResourceAuthInfoResponse,
GetResourceResponse,
GetResourceResponse
} from "@server/routers/resource";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { SidebarSettings } from "@app/components/SidebarSettings";
import { Cloud, Settings, Shield } from "lucide-react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
@@ -20,7 +19,7 @@ import {
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import Link from "next/link";
@@ -39,7 +38,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
try {
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
`/resource/${params.resourceId}`,
await authCookieHeader(),
await authCookieHeader()
);
resource = res.data.data;
} catch {
@@ -68,8 +67,8 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
await authCookieHeader(),
),
await authCookieHeader()
)
);
const res = await getOrg();
org = res.data.data;
@@ -84,19 +83,19 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
const sidebarNavItems = [
{
title: "General",
href: `/{orgId}/settings/resources/{resourceId}/general`,
href: `/{orgId}/settings/resources/{resourceId}/general`
// icon: <Settings className="w-4 h-4" />,
},
{
title: "Connectivity",
href: `/{orgId}/settings/resources/{resourceId}/connectivity`,
href: `/{orgId}/settings/resources/{resourceId}/connectivity`
// icon: <Cloud className="w-4 h-4" />,
},
{
title: "Authentication",
href: `/{orgId}/settings/resources/{resourceId}/authentication`,
href: `/{orgId}/settings/resources/{resourceId}/authentication`
// icon: <Shield className="w-4 h-4" />,
},
}
];
return (

View File

@@ -21,7 +21,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
<>
{user && (
<UserProvider user={user}>
<div>
<div className="p-3">
<ProfileIcon />
</div>
</UserProvider>

View File

@@ -13,6 +13,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import Image from "next/image";
import { cleanRedirect } from "@app/lib/cleanRedirect";
type DashboardLoginFormProps = {
redirect?: string;
@@ -57,10 +58,9 @@ export default function DashboardLoginForm({
<LoginForm
redirect={redirect}
onLogin={() => {
if (redirect && redirect.includes("http")) {
window.location.href = redirect;
} else if (redirect) {
router.push(redirect);
if (redirect) {
const safe = cleanRedirect(redirect);
router.push(safe);
} else {
router.push("/");
}

View File

@@ -5,6 +5,7 @@ import { cache } from "react";
import DashboardLoginForm from "./DashboardLoginForm";
import { Mail } from "lucide-react";
import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect";
export const dynamic = "force-dynamic";
@@ -25,6 +26,11 @@ export default async function Page(props: {
redirect("/");
}
let redirectUrl: string | undefined = undefined;
if (searchParams.redirect) {
redirectUrl = cleanRedirect(searchParams.redirect as string);
}
return (
<>
{isInvite && (
@@ -42,16 +48,16 @@ export default async function Page(props: {
</div>
)}
<DashboardLoginForm redirect={searchParams.redirect as string} />
<DashboardLoginForm redirect={redirectUrl} />
{(!signUpDisabled || isInvite) && (
<p className="text-center text-muted-foreground mt-4">
Don't have an account?{" "}
<Link
href={
!searchParams.redirect
!redirectUrl
? `/auth/signup`
: `/auth/signup?redirect=${searchParams.redirect}`
: `/auth/signup?redirect=${redirectUrl}`
}
className="underline"
>

View File

@@ -43,6 +43,7 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { passwordSchema } from "@server/auth/passwordSchema";
import { cleanRedirect } from "@app/lib/cleanRedirect";
const requestSchema = z.object({
email: z.string().email()
@@ -186,11 +187,9 @@ export default function ResetPasswordForm({
setSuccessMessage("Password reset successfully! Back to login...");
setTimeout(() => {
if (redirect && redirect.includes("http")) {
window.location.href = redirect;
}
if (redirect) {
router.push(redirect);
const safe = cleanRedirect(redirect);
router.push(safe);
} else {
router.push("/login");
}

View File

@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { cache } from "react";
import ResetPasswordForm from "./ResetPasswordForm";
import Link from "next/link";
import { cleanRedirect } from "@app/lib/cleanRedirect";
export const dynamic = "force-dynamic";
@@ -21,6 +22,11 @@ export default async function Page(props: {
redirect("/");
}
let redirectUrl: string | undefined = undefined;
if (searchParams.redirect) {
redirectUrl = cleanRedirect(searchParams.redirect);
}
return (
<>
<ResetPasswordForm
@@ -34,7 +40,7 @@ export default async function Page(props: {
href={
!searchParams.redirect
? `/auth/signup`
: `/auth/signup?redirect=${searchParams.redirect}`
: `/auth/signup?redirect=${redirectUrl}`
}
className="underline"
>

View File

@@ -481,11 +481,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
className={`${numMethods <= 1 ? "mt-0" : ""}`}
>
<LoginForm
redirect={
typeof window !== "undefined"
? window.location.href
: ""
}
redirect={`/auth/resource/${props.resource.id}`}
onLogin={async () =>
await handleSSOAuth()
}

View File

@@ -55,7 +55,17 @@ export default async function ResourceAuthPage(props: {
);
}
const redirectUrl = searchParams.redirect || authInfo.url;
let redirectUrl = authInfo.url;
if (searchParams.redirect) {
try {
const serverResourceHost = new URL(authInfo.url).host;
const redirectHost = new URL(searchParams.redirect).host;
if (serverResourceHost === redirectHost) {
redirectUrl = searchParams.redirect;
}
} catch (e) {}
}
const hasAuth =
authInfo.password ||

View File

@@ -30,6 +30,7 @@ import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import Image from "next/image";
import { cleanRedirect } from "@app/lib/cleanRedirect";
type SignupFormProps = {
redirect?: string;
@@ -92,17 +93,17 @@ export default function SignupForm({
if (res.data?.data?.emailVerificationRequired) {
if (redirect) {
router.push(`/auth/verify-email?redirect=${redirect}`);
const safe = cleanRedirect(redirect);
router.push(`/auth/verify-email?redirect=${safe}`);
} else {
router.push("/auth/verify-email");
}
return;
}
if (redirect && redirect.includes("http")) {
window.location.href = redirect;
} else if (redirect) {
router.push(redirect);
if (redirect) {
const safe = cleanRedirect(redirect);
router.push(safe);
} else {
router.push("/");
}

View File

@@ -1,5 +1,6 @@
import SignupForm from "@app/app/auth/signup/SignupForm";
import { verifySession } from "@app/lib/auth/verifySession";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { pullEnv } from "@app/lib/pullEnv";
import { Mail } from "lucide-react";
import Link from "next/link";
@@ -41,6 +42,11 @@ export default async function Page(props: {
}
}
let redirectUrl: string | undefined;
if (searchParams.redirect) {
redirectUrl = cleanRedirect(searchParams.redirect);
}
return (
<>
{isInvite && (
@@ -59,7 +65,7 @@ export default async function Page(props: {
)}
<SignupForm
redirect={searchParams.redirect as string}
redirect={redirectUrl}
inviteToken={inviteToken}
inviteId={inviteId}
/>
@@ -68,9 +74,9 @@ export default async function Page(props: {
Already have an account?{" "}
<Link
href={
!searchParams.redirect
!redirectUrl
? `/auth/login`
: `/auth/login?redirect=${searchParams.redirect}`
: `/auth/login?redirect=${redirectUrl}`
}
className="underline"
>

View File

@@ -36,6 +36,7 @@ import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/api";;
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { cleanRedirect } from "@app/lib/cleanRedirect";
const FormSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
@@ -91,11 +92,9 @@ export default function VerifyEmailForm({
"Email successfully verified! Redirecting you..."
);
setTimeout(() => {
if (redirect && redirect.includes("http")) {
window.location.href = redirect;
}
if (redirect) {
router.push(redirect);
const safe = cleanRedirect(redirect);
router.push(safe);
} else {
router.push("/");
}

View File

@@ -1,5 +1,6 @@
import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm";
import { verifySession } from "@app/lib/auth/verifySession";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { pullEnv } from "@app/lib/pullEnv";
import { redirect } from "next/navigation";
import { cache } from "react";
@@ -27,11 +28,16 @@ export default async function Page(props: {
redirect("/");
}
let redirectUrl: string | undefined;
if (searchParams.redirect) {
redirectUrl = cleanRedirect(searchParams.redirect as string);
}
return (
<>
<VerifyEmailForm
email={user.email}
redirect={searchParams.redirect as string}
redirect={redirectUrl}
/>
</>
);

View File

@@ -14,7 +14,7 @@ import { XCircle } from "lucide-react";
import { useRouter } from "next/navigation";
type InviteStatusCardProps = {
type: "rejected" | "wrong_user" | "user_does_not_exist";
type: "rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in";
token: string;
};

View File

@@ -60,6 +60,8 @@ export default async function InvitePage(props: {
)
) {
return "user_does_not_exist";
} else if (error.includes("You must be logged in to accept an invite")) {
return "not_logged_in";
} else {
return "rejected";
}
@@ -71,6 +73,10 @@ export default async function InvitePage(props: {
redirect(`/auth/signup?redirect=/invite?token=${params.token}`);
}
if (!user && type === "not_logged_in") {
redirect(`/auth/login?redirect=/invite?token=${params.token}`);
}
return (
<>
<InviteStatusCard type={type} token={tokenParam} />

View File

@@ -6,6 +6,8 @@ import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider";
import { Separator } from "@app/components/ui/separator";
import { pullEnv } from "@app/lib/pullEnv";
import { BookOpenText } from "lucide-react";
import Image from "next/image";
export const metadata: Metadata = {
title: `Dashboard - Pangolin`,
@@ -38,10 +40,10 @@ export default async function RootLayout({
<div className="flex-grow">{children}</div>
{/* Footer */}
<footer className="w-full mt-12 py-3 mb-6">
<div className="container mx-auto flex flex-wrap justify-center items-center h-4 space-x-4 text-sm text-neutral-400 select-none">
<div className="whitespace-nowrap">
Pangolin
<footer className="w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600 select-none">
<div className="flex items-center space-x-2 whitespace-nowrap">
<span>Pangolin</span>
</div>
<Separator orientation="vertical" />
<div className="whitespace-nowrap">
@@ -60,7 +62,7 @@ export default async function RootLayout({
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4"
className="w-3 h-3"
>
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
</svg>
@@ -70,10 +72,11 @@ export default async function RootLayout({
href="https://docs.fossorial.io/Pangolin/overview"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
aria-label="Documentation"
className="flex items-center space-x-3 whitespace-nowrap"
>
<span>Docs</span>
<span>Documentation</span>
<BookOpenText className="w-3 h-3" />
</a>
{version && (
<>

View File

@@ -11,6 +11,7 @@ import { redirect } from "next/navigation";
import { cache } from "react";
import OrganizationLanding from "./components/OrganizationLanding";
import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect";
export const dynamic = "force-dynamic";
@@ -29,7 +30,8 @@ export default async function Page(props: {
if (!user) {
if (params.redirect) {
redirect(`/auth/login?redirect=${params.redirect}`);
const safe = cleanRedirect(params.redirect);
redirect(`/auth/login?redirect=${safe}`);
} else {
redirect(`/auth/login`);
}
@@ -40,7 +42,8 @@ export default async function Page(props: {
env.flags.emailVerificationRequired
) {
if (params.redirect) {
redirect(`/auth/verify-email?redirect=${params.redirect}`);
const safe = cleanRedirect(params.redirect);
redirect(`/auth/verify-email?redirect=${safe}`);
} else {
redirect(`/auth/verify-email`);
}
@@ -80,6 +83,7 @@ export default async function Page(props: {
<div className="w-full max-w-md mx-auto md:mt-32 mt-4">
<OrganizationLanding
disableCreateOrg={env.flags.disableUserCreateOrg && !user.serverAdmin}
organizations={orgs.map((org) => ({
name: org.name,
id: org.orgId

View File

@@ -41,7 +41,7 @@ import Image from 'next/image'
type LoginFormProps = {
redirect?: string;
onLogin?: () => void;
onLogin?: () => void | Promise<void>;
};
const formSchema = z.object({

View File

@@ -57,6 +57,7 @@ export default function ProfileIcon() {
})
.then(() => {
router.push("/auth/login");
router.refresh();
});
}

18
src/lib/cleanRedirect.ts Normal file
View File

@@ -0,0 +1,18 @@
type PatternConfig = {
name: string;
regex: RegExp;
};
const patterns: PatternConfig[] = [
{ name: "Invite Token", regex: /^\/invite\?token=[a-zA-Z0-9-]+$/ },
{ name: "Setup", regex: /^\/setup$/ },
{ name: "Resource Auth Portal", regex: /^\/auth\/resource\/\d+$/ }
];
export function cleanRedirect(input: string): string {
if (!input || typeof input !== "string") {
return "/";
}
const isAccepted = patterns.some((pattern) => pattern.regex.test(input));
return isAccepted ? input : "/";
}