Compare commits

...

70 Commits

Author SHA1 Message Date
Milo Schwartz
d27ecaae5e Merge pull request #77 from fosrl/hotfix-2
remove double createHttpError
2025-01-17 22:00:25 -05:00
Milo Schwartz
f0898613a2 remove double createHttpError 2025-01-17 21:59:06 -05:00
Owen Schwartz
40a2933e25 Merge pull request #76 from fosrl/bump-version
Bump version
2025-01-17 21:55:34 -05:00
Owen Schwartz
a208ab36b8 Bump version 2025-01-17 21:53:16 -05:00
Milo Schwartz
680c665242 Merge pull request #75 from mallendeo/patch-1
fix: add missing `await` when verifying pincode
2025-01-17 21:26:39 -05:00
Mauricio Allende
6b141c3ea0 fix: add missing await when verifying pincode
`validPincode` ends up as a `Promise` and evaluates as a thruthy value wether the pin is correct or not.
2025-01-17 22:54:20 -03:00
Milo Schwartz
e4fe749251 Merge pull request #58 from fosrl/dev
various changes to to allow for unraid deployment
2025-01-15 23:52:49 -05:00
Milo Schwartz
ed5e6ec0f7 add port templates to traefik example files 2025-01-15 23:36:32 -05:00
Milo Schwartz
1aec431c36 optionally generate traefik files, set cors in config, and set trust proxy in config 2025-01-15 23:26:31 -05:00
Owen Schwartz
cb87463a69 Merge branch 'main' into dev 2025-01-15 21:38:15 -05:00
Owen Schwartz
4b5c74e8d6 Import start port at startup for now for exit node 2025-01-15 21:37:10 -05:00
Milo Schwartz
ab18e15a71 allow controlling cors from config and add cors middleware to traefik 2025-01-13 23:59:10 -05:00
Milo Schwartz
7ff5376d13 log url to docs if config error 2025-01-12 20:42:16 -05:00
Milo Schwartz
516c68224a Merge pull request #42 from fosrl/dev
fix missing exitNodeId on new newt sites
2025-01-12 20:39:08 -05:00
Owen Schwartz
7b93fbeba3 Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-01-12 18:07:50 -05:00
Owen Schwartz
f958067139 Fix missing exitNodeId on new newt sites 2025-01-12 18:07:38 -05:00
Milo Schwartz
4e606836a1 Merge pull request #40 from fosrl/dev
add migration to update badger
2025-01-12 16:47:27 -05:00
Milo Schwartz
5da5ee3581 add migration to update badger 2025-01-12 16:46:27 -05:00
Milo Schwartz
302ac2e644 Merge pull request #39 from fosrl/dev
local sites and direct share links
2025-01-12 16:12:50 -05:00
Owen Schwartz
baab56b6d8 Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-01-12 16:09:17 -05:00
Owen Schwartz
79c4f13440 Update to beta.5 2025-01-12 16:09:08 -05:00
Milo Schwartz
7b3db11b82 Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-01-12 15:59:36 -05:00
Milo Schwartz
3ffca75915 add targets for local sites 2025-01-12 15:59:28 -05:00
Owen Schwartz
f72dd3471e Merge branch 'no-gerbil' into dev 2025-01-12 15:58:29 -05:00
Owen Schwartz
3f55103542 Resolve ui quirks, add link 2025-01-12 15:58:07 -05:00
Milo Schwartz
b39fe87eea increase badger version in installer 2025-01-12 15:53:44 -05:00
Milo Schwartz
bfc81e52b0 bootstrap volume to create db closes #6 2025-01-12 15:41:35 -05:00
Milo Schwartz
54f5d159a5 bootstrap volume 2025-01-12 15:02:19 -05:00
Milo Schwartz
a2ed7c7117 complete integration of direct share link as discussed in #35 2025-01-12 13:43:16 -05:00
Owen Schwartz
161e87dbda Local sites working 2025-01-12 13:09:30 -05:00
Owen Schwartz
4c7581df4f Allow "local" sites witn no tunnel 2025-01-12 12:31:04 -05:00
Owen Schwartz
bfd1b21f9c Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-01-12 10:39:42 -05:00
Owen Schwartz
84ee25e441 Add version lock to dockerfile and hide password 2025-01-12 10:39:27 -05:00
Milo Schwartz
47683f2b8c add authors to readme 2025-01-11 22:37:50 -05:00
Milo Schwartz
81f1f48045 Merge branch 'main' into dev 2025-01-11 22:35:46 -05:00
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
f5fda5d8ea allow access token in resource url 2025-01-11 19:47:07 -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
95 changed files with 1874 additions and 544 deletions

View File

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

4
.gitignore vendored
View File

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

View File

@@ -1,6 +1,12 @@
## Contributing ## 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 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 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 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. represent that I have the right to grant this license for all contributed content.
``` ```

View File

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

View File

@@ -1,18 +1,23 @@
build-release:
all: build push @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: build-arm:
docker buildx build --platform linux/arm64 -t fosrl/pangolin:latest . docker buildx build --platform linux/arm64 -t fosrl/pangolin:latest .
build-x86: 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: build:
docker build -t fosrl/pangolin:latest . docker build -t fosrl/pangolin:latest .
push:
docker push fosrl/pangolin:latest
test: test:
docker run -it -p 3000:3000 -p 3001:3001 -p 3002:3002 -v ./config:/app/config fosrl/pangolin:latest 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 # 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. 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 ### Installation and Documentation
@@ -7,6 +13,11 @@ Pangolin is a self-hosted tunneled reverse proxy management server with identity
- [Installation Instructions](https://docs.fossorial.io/Getting%20Started/quick-install) - [Installation Instructions](https://docs.fossorial.io/Getting%20Started/quick-install)
- [Full Documentation](https://docs.fossorial.io) - [Full Documentation](https://docs.fossorial.io)
### Authors and Maintainers
- [Milo Schwartz](https://github.com/miloschwartz)
- [Owen Schwartz](https://github.com/oschwartz10612)
## Preview ## Preview
<img src="public/screenshots/sites.png" alt="Preview"/> <img src="public/screenshots/sites.png" alt="Preview"/>
@@ -96,7 +107,7 @@ Pangolin has a straightforward and simple dashboard UI:
3. **Connect Private Sites**: 3. **Connect Private Sites**:
- Install Newt or use another WireGuard client on 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** 4. **Configure Users & Roles**
- Define organizations and invite users. - Define organizations and invite users.
- Implement user- or role-based permissions to control resource access. - Implement user- or role-based permissions to control resource access.
@@ -123,4 +134,7 @@ Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license.
## Contributions ## 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,5 +1,6 @@
app: app:
base_url: https://proxy.example.com dashboard_url: http://localhost:3002
base_domain: localhost
log_level: info log_level: info
save_logs: false save_logs: false
@@ -8,42 +9,35 @@ server:
internal_port: 3001 internal_port: 3001
next_port: 3002 next_port: 3002
internal_hostname: pangolin internal_hostname: pangolin
secure_cookies: false secure_cookies: true
session_cookie_name: p_session session_cookie_name: p_session
resource_session_cookie_name: p_resource_session resource_session_cookie_name: p_resource_session
resource_access_token_param: p_token
traefik: traefik:
cert_resolver: letsencrypt cert_resolver: letsencrypt
http_entrypoint: web http_entrypoint: web
https_entrypoint: websecure https_entrypoint: websecure
prefer_wildcard_cert: true
gerbil: gerbil:
start_port: 51820 start_port: 51820
base_endpoint: proxy.example.com base_endpoint: localhost
use_subdomain: false block_size: 24
block_size: 16 site_block_size: 30
subnet_group: 10.0.0.0/8 subnet_group: 100.89.137.0/20
use_subdomain: true
rate_limits: rate_limits:
global: global:
window_minutes: 1 window_minutes: 1
max_requests: 100 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: users:
server_admin: server_admin:
email: admin@example.com email: admin@example.com
password: Password123! password: Password123!
flags: flags:
require_email_verification: true require_email_verification: false
disable_signup_without_invite: true disable_signup_without_invite: true
disable_user_create_org: true disable_user_create_org: true

0
config/db/.gitkeep Normal file
View File

0
config/logs/.gitkeep Normal file
View File

View File

@@ -0,0 +1,54 @@
http:
middlewares:
redirect-to-https:
redirectScheme:
scheme: https
permanent: true
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

@@ -0,0 +1,41 @@
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.2"
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"
http:
tls:
certResolver: "letsencrypt"
serversTransport:
insecureSkipVerify: true

View File

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

View File

@@ -1,8 +1,14 @@
all: build all: build
build: build:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o installer 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: clean:
rm installer rm bin/installer
rm bin/installer_linux_amd64
rm bin/installer_linux_arm64

View File

@@ -1,5 +1,6 @@
app: app:
base_url: https://{{.Domain}} dashboard_url: https://{{.DashboardDomain}}
base_domain: {{.BaseDomain}}
log_level: info log_level: info
save_logs: false save_logs: false
@@ -8,9 +9,15 @@ server:
internal_port: 3001 internal_port: 3001
next_port: 3002 next_port: 3002
internal_hostname: pangolin internal_hostname: pangolin
secure_cookies: false secure_cookies: true
session_cookie_name: p_session session_cookie_name: p_session
resource_session_cookie_name: p_resource_session resource_session_cookie_name: p_resource_session
resource_access_token_param: p_token
cors:
origins: ["https://{{.DashboardDomain}}"]
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
headers: ["X-CSRF-Token", "Content-Type"]
credentials: false
traefik: traefik:
cert_resolver: letsencrypt cert_resolver: letsencrypt
@@ -20,10 +27,11 @@ traefik:
gerbil: gerbil:
start_port: 51820 start_port: 51820
base_endpoint: {{.Domain}} base_endpoint: {{.DashboardDomain}}
use_subdomain: false use_subdomain: false
block_size: 16 block_size: 24
subnet_group: 10.0.0.0/8 site_block_size: 30
subnet_group: 100.89.137.0/20
rate_limits: rate_limits:
global: global:

View File

@@ -1,11 +1,8 @@
services: services:
pangolin: pangolin:
image: fosrl/pangolin:1.0.0-beta.1 image: fosrl/pangolin:{{.PangolinVersion}}
container_name: pangolin container_name: pangolin
restart: unless-stopped restart: unless-stopped
ports:
- 3001:3001
- 3000:3000
volumes: volumes:
- ./config:/app/config - ./config:/app/config
healthcheck: healthcheck:
@@ -14,8 +11,9 @@ services:
timeout: "3s" timeout: "3s"
retries: 5 retries: 5
{{if .InstallGerbil}}
gerbil: gerbil:
image: fosrl/gerbil:1.0.0-beta.1 image: fosrl/gerbil:{{.GerbilVersion}}
container_name: gerbil container_name: gerbil
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
@@ -35,12 +33,20 @@ services:
- 51820:51820/udp - 51820:51820/udp
- 443:443 # Port for traefik because of the network_mode - 443:443 # Port for traefik because of the network_mode
- 80:80 # Port for traefik because of the network_mode - 80:80 # Port for traefik because of the network_mode
{{end}}
traefik: traefik:
image: traefik:v3.1 image: traefik:v3.1
container_name: traefik container_name: traefik
restart: unless-stopped restart: unless-stopped
{{if .InstallGerbil}}
network_mode: service:gerbil # Ports appear on the gerbil service network_mode: service:gerbil # Ports appear on the gerbil service
{{end}}
{{if not .InstallGerbil}}
ports:
- 443:443
- 80:80
{{end}}
depends_on: depends_on:
pangolin: pangolin:
condition: service_healthy condition: service_healthy

View File

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

View File

@@ -13,7 +13,7 @@ experimental:
plugins: plugins:
badger: badger:
moduleName: "github.com/fosrl/badger" moduleName: "github.com/fosrl/badger"
version: "v1.0.0-beta.1" version: "v1.0.0-beta.2"
log: log:
level: "INFO" level: "INFO"

View File

@@ -1,3 +1,8 @@
module installer module installer
go 1.23.0 go 1.23.0
require (
golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.28.0 // indirect
)

View File

@@ -0,0 +1,4 @@
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=

View File

@@ -10,26 +10,38 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
"syscall"
"text/template" "text/template"
"unicode" "unicode"
"golang.org/x/term"
) )
func loadVersions(config *Config) {
config.PangolinVersion = "1.0.0-beta.7"
config.GerbilVersion = "1.0.0-beta.2"
}
//go:embed fs/* //go:embed fs/*
var configFiles embed.FS var configFiles embed.FS
type Config struct { type Config struct {
Domain string `yaml:"domain"` PangolinVersion string
LetsEncryptEmail string `yaml:"letsEncryptEmail"` GerbilVersion string
AdminUserEmail string `yaml:"adminUserEmail"` BaseDomain string
AdminUserPassword string `yaml:"adminUserPassword"` DashboardDomain string
DisableSignupWithoutInvite bool `yaml:"disableSignupWithoutInvite"` LetsEncryptEmail string
DisableUserCreateOrg bool `yaml:"disableUserCreateOrg"` AdminUserEmail string
EnableEmail bool `yaml:"enableEmail"` AdminUserPassword string
EmailSMTPHost string `yaml:"emailSMTPHost"` DisableSignupWithoutInvite bool
EmailSMTPPort int `yaml:"emailSMTPPort"` DisableUserCreateOrg bool
EmailSMTPUser string `yaml:"emailSMTPUser"` EnableEmail bool
EmailSMTPPass string `yaml:"emailSMTPPass"` EmailSMTPHost string
EmailNoReply string `yaml:"emailNoReply"` EmailSMTPPort int
EmailSMTPUser string
EmailSMTPPass string
EmailNoReply string
InstallGerbil bool
} }
func main() { func main() {
@@ -44,10 +56,16 @@ func main() {
// check if there is already a config file // check if there is already a config file
if _, err := os.Stat("config/config.yml"); err != nil { if _, err := os.Stat("config/config.yml"); err != nil {
config := collectUserInput(reader) config := collectUserInput(reader)
createConfigFiles(config)
loadVersions(&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 !isDockerInstalled() && runtime.GOOS == "linux" {
if shouldInstallDocker() { if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
installDocker() installDocker()
} }
} }
@@ -78,6 +96,24 @@ func readString(reader *bufio.Reader, prompt string, defaultValue string) string
return input 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 readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool { func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
defaultStr := "no" defaultStr := "no"
if defaultValue { if defaultValue {
@@ -102,23 +138,32 @@ func collectUserInput(reader *bufio.Reader) Config {
// Basic configuration // Basic configuration
fmt.Println("\n=== 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", "") config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "")
config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunned connections", true)
// Admin user configuration // Admin user configuration
fmt.Println("\n=== 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 { for {
config.AdminUserPassword = readString(reader, "Enter admin user password", "") pass1 := readPassword("Create admin user password")
if valid, message := validatePassword(config.AdminUserPassword); valid { pass2 := readPassword("Confirm admin user password")
break
if pass1 != pass2 {
fmt.Println("Passwords do not match")
} else { } else {
fmt.Println("Invalid password:", message) config.AdminUserPassword = pass1
fmt.Println("Password requirements:") if valid, message := validatePassword(config.AdminUserPassword); valid {
fmt.Println("- At least one uppercase English letter") break
fmt.Println("- At least one lowercase English letter") } else {
fmt.Println("- At least one digit") fmt.Println("Invalid password:", message)
fmt.Println("- At least one special character") fmt.Println("Password requirements:")
fmt.Println("- At least one uppercase English letter")
fmt.Println("- At least one lowercase English letter")
fmt.Println("- At least one digit")
fmt.Println("- At least one special character")
}
} }
} }
@@ -140,10 +185,14 @@ func collectUserInput(reader *bufio.Reader) Config {
} }
// Validate required fields // Validate required fields
if config.Domain == "" { if config.BaseDomain == "" {
fmt.Println("Error: Domain name is required") fmt.Println("Error: Domain name is required")
os.Exit(1) os.Exit(1)
} }
if config.DashboardDomain == "" {
fmt.Println("Error: Dashboard Domain name is required")
os.Exit(1)
}
if config.LetsEncryptEmail == "" { if config.LetsEncryptEmail == "" {
fmt.Println("Error: Let's Encrypt email is required") fmt.Println("Error: Let's Encrypt email is required")
os.Exit(1) os.Exit(1)
@@ -269,19 +318,30 @@ func createConfigFiles(config Config) error {
return fmt.Errorf("error walking config files: %v", err) return fmt.Errorf("error walking config files: %v", err)
} }
// move the docker-compose.yml file to the root directory // get the current directory
os.Rename("config/docker-compose.yml", "docker-compose.yml") dir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %v", err)
}
sourcePath := filepath.Join(dir, "config/docker-compose.yml")
destPath := filepath.Join(dir, "docker-compose.yml")
// Check if source file exists
if _, err := os.Stat(sourcePath); err != nil {
return fmt.Errorf("source docker-compose.yml not found: %v", err)
}
// Try to move the file
err = os.Rename(sourcePath, destPath)
if err != nil {
return fmt.Errorf("failed to move docker-compose.yml from %s to %s: %v",
sourcePath, destPath, err)
}
return nil return nil
} }
func shouldInstallDocker() bool {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Would you like to install Docker? (yes/no): ")
response, _ := reader.ReadString('\n')
return strings.ToLower(strings.TrimSpace(response)) == "yes"
}
func installDocker() error { func installDocker() error {
// Detect Linux distribution // Detect Linux distribution
cmd := exec.Command("cat", "/etc/os-release") cmd := exec.Command("cat", "/etc/os-release")
@@ -289,39 +349,66 @@ func installDocker() error {
if err != nil { if err != nil {
return fmt.Errorf("failed to detect Linux distribution: %v", err) return fmt.Errorf("failed to detect Linux distribution: %v", err)
} }
osRelease := string(output) 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 { switch {
case strings.Contains(osRelease, "ID=ubuntu") || strings.Contains(osRelease, "ID=debian"): case strings.Contains(osRelease, "ID=ubuntu"):
installCmd = exec.Command("bash", "-c", ` installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
apt-get update && apt-get update &&
apt-get install -y apt-transport-https ca-certificates curl software-properties-common && 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 && 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 && 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 update &&
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin 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"): case strings.Contains(osRelease, "ID=fedora"):
installCmd = exec.Command("bash", "-c", ` installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
dnf -y install dnf-plugins-core && dnf -y install dnf-plugins-core &&
dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo && 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 dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
`) `))
case strings.Contains(osRelease, "ID=opensuse") || strings.Contains(osRelease, "ID=\"opensuse-"): case strings.Contains(osRelease, "ID=opensuse") || strings.Contains(osRelease, "ID=\"opensuse-"):
installCmd = exec.Command("bash", "-c", ` installCmd = exec.Command("bash", "-c", `
zypper install -y docker docker-compose && zypper install -y docker docker-compose &&
systemctl enable docker systemctl enable docker
`) `)
case strings.Contains(osRelease, "ID=rhel") || strings.Contains(osRelease, "ID=\"rhel"): 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 remove -y runc &&
dnf -y install yum-utils && dnf -y install yum-utils &&
dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo && 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 && dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin &&
systemctl enable docker systemctl enable docker
`) `))
case strings.Contains(osRelease, "ID=amzn"): case strings.Contains(osRelease, "ID=amzn"):
installCmd = exec.Command("bash", "-c", ` installCmd = exec.Command("bash", "-c", `
yum update -y && yum update -y &&
@@ -332,7 +419,6 @@ func installDocker() error {
default: default:
return fmt.Errorf("unsupported Linux distribution") return fmt.Errorf("unsupported Linux distribution")
} }
installCmd.Stdout = os.Stdout installCmd.Stdout = os.Stdout
installCmd.Stderr = os.Stderr installCmd.Stderr = os.Stderr
return installCmd.Run() return installCmd.Run()

View File

@@ -1,6 +1,6 @@
{ {
"name": "@fosrl/pangolin", "name": "@fosrl/pangolin",
"version": "1.0.0-beta.1", "version": "1.0.0-beta.6",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI", "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
@@ -26,6 +26,7 @@
"@oslojs/encoding": "1.1.0", "@oslojs/encoding": "1.1.0",
"@radix-ui/react-avatar": "1.1.2", "@radix-ui/react-avatar": "1.1.2",
"@radix-ui/react-checkbox": "1.1.3", "@radix-ui/react-checkbox": "1.1.3",
"@radix-ui/react-collapsible": "1.1.2",
"@radix-ui/react-dialog": "1.1.4", "@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-dropdown-menu": "2.1.4", "@radix-ui/react-dropdown-menu": "2.1.4",
"@radix-ui/react-icons": "1.3.2", "@radix-ui/react-icons": "1.3.2",

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

@@ -20,23 +20,32 @@ const externalPort = config.getRawConfig().server.external_port;
export function createApiServer() { export function createApiServer() {
const apiServer = express(); const apiServer = express();
// Middleware setup if (config.getRawConfig().server.trust_proxy) {
apiServer.set("trust proxy", 1); apiServer.set("trust proxy", 1);
if (dev) { }
apiServer.use(
cors({
origin: `http://localhost:${config.getRawConfig().server.next_port}`,
credentials: true
})
);
} else {
const corsOptions = {
origin: config.getRawConfig().app.base_url,
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
allowedHeaders: ["Content-Type", "X-CSRF-Token"]
};
apiServer.use(cors(corsOptions)); const corsConfig = config.getRawConfig().server.cors;
const options = {
...(corsConfig?.origins
? { origin: corsConfig.origins }
: {
origin: (origin: any, callback: any) => {
callback(null, true);
}
}),
...(corsConfig?.methods && { methods: corsConfig.methods }),
...(corsConfig?.allowed_headers && {
allowedHeaders: corsConfig.allowed_headers
}),
credentials: !(corsConfig?.credentials === false)
};
logger.debug("Using CORS options", options);
apiServer.use(cors(options));
if (!dev) {
apiServer.use(helmet()); apiServer.use(helmet());
apiServer.use(csrfProtectionMiddleware); apiServer.use(csrfProtectionMiddleware);
} }
@@ -47,7 +56,8 @@ export function createApiServer() {
if (!dev) { if (!dev) {
apiServer.use( apiServer.use(
rateLimitMiddleware({ rateLimitMiddleware({
windowMin: config.getRawConfig().rate_limits.global.window_minutes, windowMin:
config.getRawConfig().rate_limits.global.window_minutes,
max: config.getRawConfig().rate_limits.global.max_requests, max: config.getRawConfig().rate_limits.global.max_requests,
type: "IP_AND_PATH" type: "IP_AND_PATH"
}) })

View File

@@ -0,0 +1,45 @@
import db from "@server/db";
import { and, eq } from "drizzle-orm";
import { roleResources, userResources } from "@server/db/schema";
export async function canUserAccessResource({
userId,
resourceId,
roleId
}: {
userId: string;
resourceId: number;
roleId: number;
}): Promise<boolean> {
const roleResourceAccess = await db
.select()
.from(roleResources)
.where(
and(
eq(roleResources.resourceId, resourceId),
eq(roleResources.roleId, roleId)
)
)
.limit(1);
if (roleResourceAccess.length > 0) {
return true;
}
const userResourceAccess = await db
.select()
.from(userResources)
.where(
and(
eq(userResources.userId, userId),
eq(userResources.resourceId, resourceId)
)
)
.limit(1);
if (userResourceAccess.length > 0) {
return true;
}
return false;
}

View File

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

View File

@@ -1,6 +1,6 @@
import { import {
encodeBase32LowerCaseNoPadding, encodeBase32LowerCaseNoPadding,
encodeHexLowerCase, encodeHexLowerCase
} from "@oslojs/encoding"; } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2"; import { sha256 } from "@oslojs/crypto/sha2";
import { Session, sessions, User, users } from "@server/db/schema"; import { Session, sessions, User, users } from "@server/db/schema";
@@ -9,8 +9,10 @@ import { eq } from "drizzle-orm";
import config from "@server/lib/config"; import config from "@server/lib/config";
import type { RandomReader } from "@oslojs/crypto/random"; import type { RandomReader } from "@oslojs/crypto/random";
import { generateRandomString } from "@oslojs/crypto/random"; import { generateRandomString } from "@oslojs/crypto/random";
import logger from "@server/logger";
export const SESSION_COOKIE_NAME = config.getRawConfig().server.session_cookie_name; export const SESSION_COOKIE_NAME =
config.getRawConfig().server.session_cookie_name;
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30; export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies; export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
export const COOKIE_DOMAIN = "." + config.getBaseDomain(); export const COOKIE_DOMAIN = "." + config.getBaseDomain();
@@ -24,25 +26,25 @@ export function generateSessionToken(): string {
export async function createSession( export async function createSession(
token: string, token: string,
userId: string, userId: string
): Promise<Session> { ): Promise<Session> {
const sessionId = encodeHexLowerCase( const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token)), sha256(new TextEncoder().encode(token))
); );
const session: Session = { const session: Session = {
sessionId: sessionId, sessionId: sessionId,
userId, userId,
expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(), expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime()
}; };
await db.insert(sessions).values(session); await db.insert(sessions).values(session);
return session; return session;
} }
export async function validateSessionToken( export async function validateSessionToken(
token: string, token: string
): Promise<SessionValidationResult> { ): Promise<SessionValidationResult> {
const sessionId = encodeHexLowerCase( const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token)), sha256(new TextEncoder().encode(token))
); );
const result = await db const result = await db
.select({ user: users, session: sessions }) .select({ user: users, session: sessions })
@@ -61,12 +63,12 @@ export async function validateSessionToken(
} }
if (Date.now() >= session.expiresAt - SESSION_COOKIE_EXPIRES / 2) { if (Date.now() >= session.expiresAt - SESSION_COOKIE_EXPIRES / 2) {
session.expiresAt = new Date( session.expiresAt = new Date(
Date.now() + SESSION_COOKIE_EXPIRES, Date.now() + SESSION_COOKIE_EXPIRES
).getTime(); ).getTime();
await db await db
.update(sessions) .update(sessions)
.set({ .set({
expiresAt: session.expiresAt, expiresAt: session.expiresAt
}) })
.where(eq(sessions.sessionId, session.sessionId)); .where(eq(sessions.sessionId, session.sessionId));
} }
@@ -81,26 +83,38 @@ export async function invalidateAllSessions(userId: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.userId, userId)); await db.delete(sessions).where(eq(sessions.userId, userId));
} }
export function serializeSessionCookie(token: string): string { export function serializeSessionCookie(
if (SECURE_COOKIES) { token: string,
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; isSecure: boolean
): string {
if (isSecure) {
logger.debug("Setting cookie for secure origin");
if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
}
} else { } else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`; return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`;
} }
} }
export function createBlankSessionTokenCookie(): string { export function createBlankSessionTokenCookie(isSecure: boolean): string {
if (SECURE_COOKIES) { if (isSecure) {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
}
} else { } else {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`; return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/;`;
} }
} }
const random: RandomReader = { const random: RandomReader = {
read(bytes: Uint8Array): void { read(bytes: Uint8Array): void {
crypto.getRandomValues(bytes); crypto.getRandomValues(bytes);
}, }
}; };
export function generateId(length: number): string { export function generateId(length: number): string {

View File

@@ -0,0 +1,67 @@
import db from "@server/db";
import {
Resource,
ResourceAccessToken,
resourceAccessToken,
} from "@server/db/schema";
import { and, eq } from "drizzle-orm";
import { isWithinExpirationDate } from "oslo";
import { verifyPassword } from "./password";
export async function verifyResourceAccessToken({
resource,
accessTokenId,
accessToken
}: {
resource: Resource;
accessTokenId: string;
accessToken: string;
}): Promise<{
valid: boolean;
error?: string;
tokenItem?: ResourceAccessToken;
}> {
const [result] = await db
.select()
.from(resourceAccessToken)
.where(
and(
eq(resourceAccessToken.resourceId, resource.resourceId),
eq(resourceAccessToken.accessTokenId, accessTokenId)
)
)
.limit(1);
const tokenItem = result;
if (!tokenItem) {
return {
valid: false,
error: "Access token does not exist for resource"
};
}
const validCode = await verifyPassword(accessToken, tokenItem.tokenHash);
if (!validCode) {
return {
valid: false,
error: "Invalid access token"
};
}
if (
tokenItem.expiresAt &&
!isWithinExpirationDate(new Date(tokenItem.expiresAt))
) {
return {
valid: false,
error: "Access token has expired"
};
}
return {
valid: true,
tokenItem
};
}

View File

@@ -4,10 +4,13 @@ import * as schema from "@server/db/schema";
import path from "path"; import path from "path";
import fs from "fs/promises"; import fs from "fs/promises";
import { APP_PATH } from "@server/lib/consts"; import { APP_PATH } from "@server/lib/consts";
import { existsSync, mkdirSync } from "fs";
export const location = path.join(APP_PATH, "db", "db.sqlite"); export const location = path.join(APP_PATH, "db", "db.sqlite");
export const exists = await checkFileExists(location); export const exists = await checkFileExists(location);
bootstrapVolume();
const sqlite = new Database(location); const sqlite = new Database(location);
export const db = drizzle(sqlite, { schema }); export const db = drizzle(sqlite, { schema });
@@ -21,3 +24,29 @@ async function checkFileExists(filePath: string): Promise<boolean> {
return false; return false;
} }
} }
function bootstrapVolume() {
const appPath = APP_PATH;
const dbDir = path.join(appPath, "db");
const logsDir = path.join(appPath, "logs");
// check if the db directory exists and create it if it doesn't
if (!existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true });
}
// check if the logs directory exists and create it if it doesn't
if (!existsSync(logsDir)) {
mkdirSync(logsDir, { recursive: true });
}
// THIS IS FOR TRAEFIK; NOT REALLY NEEDED, BUT JUST IN CASE
const traefikDir = path.join(appPath, "traefik");
// check if the traefik directory exists and create it if it doesn't
if (!existsSync(traefikDir)) {
mkdirSync(traefikDir, { recursive: true });
}
}

View File

@@ -3,29 +3,74 @@ import yaml from "js-yaml";
import path from "path"; import path from "path";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { __DIRNAME, APP_PATH } from "@server/lib/consts"; import {
__DIRNAME,
APP_PATH,
configFilePath1,
configFilePath2
} from "@server/lib/consts";
import { loadAppVersion } from "@server/lib/loadAppVersion"; import { loadAppVersion } from "@server/lib/loadAppVersion";
import { passwordSchema } from "@server/auth/passwordSchema"; import { passwordSchema } from "@server/auth/passwordSchema";
import stoi from "./stoi";
const portSchema = z.number().positive().gt(0).lte(65535); const portSchema = z.number().positive().gt(0).lte(65535);
const hostnameSchema = z
.string()
.regex(
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$/
)
.or(z.literal("localhost"));
const environmentSchema = z.object({ const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
return process.env[envVar] ?? valFromYaml;
};
const configSchema = z.object({
app: z.object({ app: z.object({
base_url: z dashboard_url: z
.string() .string()
.url() .url()
.optional()
.transform(getEnvOrYaml("APP_DASHBOARDURL"))
.pipe(z.string().url())
.transform((url) => url.toLowerCase()), .transform((url) => url.toLowerCase()),
base_domain: hostnameSchema
.optional()
.transform(getEnvOrYaml("APP_BASEDOMAIN"))
.pipe(hostnameSchema),
log_level: z.enum(["debug", "info", "warn", "error"]), log_level: z.enum(["debug", "info", "warn", "error"]),
save_logs: z.boolean() save_logs: z.boolean()
}), }),
server: z.object({ server: z.object({
external_port: portSchema, external_port: portSchema
internal_port: portSchema, .optional()
next_port: portSchema, .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),
internal_hostname: z.string().transform((url) => url.toLowerCase()), internal_hostname: z.string().transform((url) => url.toLowerCase()),
secure_cookies: z.boolean(), secure_cookies: z.boolean(),
session_cookie_name: z.string(), session_cookie_name: z.string(),
resource_session_cookie_name: z.string() resource_session_cookie_name: z.string(),
resource_access_token_param: z.string(),
cors: z
.object({
origins: z.array(z.string()).optional(),
methods: z.array(z.string()).optional(),
allowed_headers: z.array(z.string()).optional(),
credentials: z.boolean().optional()
})
.optional(),
trust_proxy: z.boolean().optional().default(true)
}), }),
traefik: z.object({ traefik: z.object({
http_entrypoint: z.string(), http_entrypoint: z.string(),
@@ -34,11 +79,21 @@ const environmentSchema = z.object({
prefer_wildcard_cert: z.boolean().optional() prefer_wildcard_cert: z.boolean().optional()
}), }),
gerbil: z.object({ gerbil: z.object({
start_port: portSchema, start_port: portSchema
base_endpoint: z.string().transform((url) => url.toLowerCase()), .optional()
.transform(getEnvOrYaml("GERBIL_STARTPORT"))
.transform(stoi)
.pipe(portSchema),
base_endpoint: z
.string()
.optional()
.transform(getEnvOrYaml("GERBIL_BASEENDPOINT"))
.pipe(z.string())
.transform((url) => url.toLowerCase()),
use_subdomain: z.boolean(), use_subdomain: z.boolean(),
subnet_group: z.string(), 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({ rate_limits: z.object({
global: z.object({ global: z.object({
@@ -58,13 +113,21 @@ const environmentSchema = z.object({
smtp_port: portSchema, smtp_port: portSchema,
smtp_user: z.string(), smtp_user: z.string(),
smtp_pass: z.string(), smtp_pass: z.string(),
no_reply: z.string().email(), no_reply: z.string().email()
}) })
.optional(), .optional(),
users: z.object({ users: z.object({
server_admin: z.object({ server_admin: z.object({
email: z.string().email(), email: z
.string()
.email()
.optional()
.transform(getEnvOrYaml("USERS_SERVERADMIN_EMAIL"))
.pipe(z.string().email()),
password: passwordSchema password: passwordSchema
.optional()
.transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD"))
.pipe(passwordSchema)
}) })
}), }),
flags: z flags: z
@@ -77,12 +140,18 @@ const environmentSchema = z.object({
}); });
export class Config { export class Config {
private rawConfig!: z.infer<typeof environmentSchema>; private rawConfig!: z.infer<typeof configSchema>;
constructor() { constructor() {
this.loadConfig(); this.loadConfig();
if (process.env.GENERATE_TRAEFIK_CONFIG === "true") {
this.createTraefikConfig();
}
} }
public loadEnvironment() {}
public loadConfig() { public loadConfig() {
const loadConfig = (configPath: string) => { const loadConfig = (configPath: string) => {
try { try {
@@ -99,9 +168,6 @@ export class Config {
} }
}; };
const configFilePath1 = path.join(APP_PATH, "config.yml");
const configFilePath2 = path.join(APP_PATH, "config.yaml");
let environment: any; let environment: any;
if (fs.existsSync(configFilePath1)) { if (fs.existsSync(configFilePath1)) {
environment = loadConfig(configFilePath1); environment = loadConfig(configFilePath1);
@@ -126,6 +192,9 @@ export class Config {
); );
environment = loadConfig(configFilePath1); environment = loadConfig(configFilePath1);
} catch (error) { } 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) { if (error instanceof Error) {
throw new Error( throw new Error(
`Error creating configuration file from example: ${ `Error creating configuration file from example: ${
@@ -146,7 +215,7 @@ export class Config {
throw new Error("No configuration file found"); throw new Error("No configuration file found");
} }
const parsedConfig = environmentSchema.safeParse(environment); const parsedConfig = configSchema.safeParse(environment);
if (!parsedConfig.success) { if (!parsedConfig.success) {
const errors = fromError(parsedConfig.error); const errors = fromError(parsedConfig.error);
@@ -181,6 +250,8 @@ export class Config {
?.disable_user_create_org ?.disable_user_create_org
? "true" ? "true"
: "false"; : "false";
process.env.RESOURCE_ACCESS_TOKEN_PARAM =
parsedConfig.data.server.resource_access_token_param;
this.rawConfig = parsedConfig.data; this.rawConfig = parsedConfig.data;
} }
@@ -190,15 +261,73 @@ export class Config {
} }
public getBaseDomain(): string { public getBaseDomain(): string {
const newUrl = new URL(this.rawConfig.app.base_url); return this.rawConfig.app.base_domain;
const hostname = newUrl.hostname; }
const parts = hostname.split(".");
if (parts.length <= 2) { private createTraefikConfig() {
return parts.join("."); 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);
} }
return parts.slice(1).join(".");
} }
} }

View File

@@ -6,3 +6,6 @@ export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME); export const __DIRNAME = path.dirname(__FILENAME);
export const APP_PATH = path.join("config"); 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

@@ -4,7 +4,7 @@ import { resourceAccessToken, resources, userOrgs } from "@server/db/schema";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { canUserAccessResource } from "@server/lib/canUserAccessResource"; import { canUserAccessResource } from "@server/auth/canUserAccessResource";
export async function verifyAccessTokenAccess( export async function verifyAccessTokenAccess(
req: Request, req: Request,

View File

@@ -4,7 +4,7 @@ import { resources, targets, userOrgs } from "@server/db/schema";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { canUserAccessResource } from "../lib/canUserAccessResource"; import { canUserAccessResource } from "../auth/canUserAccessResource";
export async function verifyTargetAccess( export async function verifyTargetAccess(
req: Request, req: Request,

View File

@@ -120,7 +120,8 @@ export async function login(
const token = generateSessionToken(); const token = generateSessionToken();
await createSession(token, existingUser.userId); await createSession(token, existingUser.userId);
const cookie = serializeSessionCookie(token); const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(token, isSecure);
res.appendHeader("Set-Cookie", cookie); res.appendHeader("Set-Cookie", cookie);

View File

@@ -27,7 +27,8 @@ export async function logout(
try { try {
await invalidateSession(sessionId); await invalidateSession(sessionId);
res.setHeader("Set-Cookie", createBlankSessionTokenCookie()); const isSecure = req.protocol === "https";
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
return response<null>(res, { return response<null>(res, {
data: null, data: null,

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( await sendEmail(
ResetPasswordCode({ ResetPasswordCode({

View File

@@ -158,7 +158,8 @@ export async function signup(
const token = generateSessionToken(); const token = generateSessionToken();
await createSession(token, userId); await createSession(token, userId);
const cookie = serializeSessionCookie(token); const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(token, isSecure);
res.appendHeader("Set-Cookie", cookie); res.appendHeader("Set-Cookie", cookie);
if (config.getRawConfig().flags?.require_email_verification) { if (config.getRawConfig().flags?.require_email_verification) {

View File

@@ -7,6 +7,7 @@ import { response } from "@server/lib/response";
import { validateSessionToken } from "@server/auth/sessions/app"; import { validateSessionToken } from "@server/auth/sessions/app";
import db from "@server/db"; import db from "@server/db";
import { import {
ResourceAccessToken,
resourceAccessToken, resourceAccessToken,
resourcePassword, resourcePassword,
resourcePincode, resourcePincode,
@@ -17,9 +18,15 @@ import {
} from "@server/db/schema"; } from "@server/db/schema";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { validateResourceSessionToken } from "@server/auth/sessions/resource"; import {
createResourceSession,
serializeResourceSessionCookie,
validateResourceSessionToken
} from "@server/auth/sessions/resource";
import { Resource, roleResources, userResources } from "@server/db/schema"; import { Resource, roleResources, userResources } from "@server/db/schema";
import logger from "@server/logger"; import logger from "@server/logger";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import { generateSessionToken } from "@server/auth";
const verifyResourceSessionSchema = z.object({ const verifyResourceSessionSchema = z.object({
sessions: z.record(z.string()).optional(), sessions: z.record(z.string()).optional(),
@@ -28,6 +35,7 @@ const verifyResourceSessionSchema = z.object({
host: z.string(), host: z.string(),
path: z.string(), path: z.string(),
method: z.string(), method: z.string(),
accessToken: z.string().optional(),
tls: z.boolean() tls: z.boolean()
}); });
@@ -59,7 +67,8 @@ export async function verifyResourceSession(
} }
try { try {
const { sessions, host, originalRequestURL } = parsedBody.data; const { sessions, host, originalRequestURL, accessToken: token } =
parsedBody.data;
const [result] = await db const [result] = await db
.select() .select()
@@ -101,13 +110,43 @@ export async function verifyResourceSession(
return allowed(res); 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)}`;
// check for access token
let validAccessToken: ResourceAccessToken | undefined;
if (token) {
const [accessTokenId, accessToken] = token.split(".");
const { valid, error, tokenItem } = await verifyResourceAccessToken(
{
resource,
accessTokenId,
accessToken
}
);
if (error) {
logger.debug("Access token invalid: " + error);
}
if (valid && tokenItem) {
validAccessToken = tokenItem;
if (!sessions) {
return await createAccessTokenSession(
res,
resource,
tokenItem
);
}
}
}
if (!sessions) { if (!sessions) {
return notAllowed(res); return notAllowed(res);
} }
const sessionToken = sessions[config.getRawConfig().server.session_cookie_name]; const sessionToken =
sessions[config.getRawConfig().server.session_cookie_name];
// check for unified login // check for unified login
if (sso && sessionToken) { if (sso && sessionToken) {
@@ -172,6 +211,16 @@ export async function verifyResourceSession(
} }
} }
// At this point we have checked all sessions, but since the access token is valid, we should allow access
// and create a new session.
if (validAccessToken) {
return await createAccessTokenSession(
res,
resource,
validAccessToken
);
}
logger.debug("No more auth to check, resource not allowed"); logger.debug("No more auth to check, resource not allowed");
return notAllowed(res, redirectUrl); return notAllowed(res, redirectUrl);
} catch (e) { } catch (e) {
@@ -209,11 +258,41 @@ function allowed(res: Response) {
return response<VerifyUserResponse>(res, data); return response<VerifyUserResponse>(res, data);
} }
async function createAccessTokenSession(
res: Response,
resource: Resource,
tokenItem: ResourceAccessToken
) {
const token = generateSessionToken();
await createResourceSession({
resourceId: resource.resourceId,
token,
accessTokenId: tokenItem.accessTokenId,
sessionLength: tokenItem.sessionLength,
expiresAt: tokenItem.expiresAt,
doNotExtend: tokenItem.expiresAt ? true : false
});
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
res.appendHeader("Set-Cookie", cookie);
logger.debug("Access token is valid, creating new session")
return response<VerifyUserResponse>(res, {
data: { valid: true },
success: true,
error: false,
message: "Access allowed",
status: HttpCode.OK
});
}
async function isUserAllowedToAccessResource( async function isUserAllowedToAccessResource(
user: User, user: User,
resource: Resource resource: Resource
): Promise<boolean> { ): Promise<boolean> {
if (config.getRawConfig().flags?.require_email_verification && !user.emailVerified) { if (
config.getRawConfig().flags?.require_email_verification &&
!user.emailVerified
) {
return false; return false;
} }

View File

@@ -50,7 +50,9 @@ export async function getConfig(req: Request, res: Response, next: NextFunction)
let exitNode; let exitNode;
if (exitNodeQuery.length === 0) { if (exitNodeQuery.length === 0) {
const address = await getNextAvailableSubnet(); const address = await getNextAvailableSubnet();
const listenPort = await getNextAvailablePort(); // TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// const listenPort = await getNextAvailablePort();
const listenPort = config.getRawConfig().gerbil.start_port;
let subEndpoint = ""; let subEndpoint = "";
if (config.getRawConfig().gerbil.use_subdomain) { if (config.getRawConfig().gerbil.use_subdomain) {
subEndpoint = await getUniqueExitNodeEndpointName(); subEndpoint = await getUniqueExitNodeEndpointName();

View File

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

View File

@@ -14,9 +14,7 @@ import {
} from "@server/auth/sessions/resource"; } from "@server/auth/sessions/resource";
import config from "@server/lib/config"; import config from "@server/lib/config";
import logger from "@server/logger"; import logger from "@server/logger";
import { verify } from "@node-rs/argon2"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import { isWithinExpirationDate } from "oslo";
import { verifyPassword } from "@server/auth/password";
const authWithAccessTokenBodySchema = z const authWithAccessTokenBodySchema = z
.object({ .object({
@@ -69,58 +67,38 @@ export async function authWithAccessToken(
const { accessToken, accessTokenId } = parsedBody.data; const { accessToken, accessTokenId } = parsedBody.data;
try { try {
const [result] = await db const [resource] = await db
.select() .select()
.from(resourceAccessToken) .from(resources)
.where( .where(eq(resources.resourceId, resourceId))
and(
eq(resourceAccessToken.resourceId, resourceId),
eq(resourceAccessToken.accessTokenId, accessTokenId)
)
)
.leftJoin(
resources,
eq(resources.resourceId, resourceAccessToken.resourceId)
)
.limit(1); .limit(1);
const resource = result?.resources;
const tokenItem = result?.resourceAccessToken;
if (!tokenItem) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
createHttpError(
HttpCode.BAD_REQUEST,
"Access token does not exist for resource"
)
)
);
}
if (!resource) { if (!resource) {
return next( return next(
createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist") createHttpError(HttpCode.NOT_FOUND, "Resource not found")
); );
} }
const validCode = await verifyPassword(accessToken, tokenItem.tokenHash); const { valid, error, tokenItem } = await verifyResourceAccessToken({
resource,
accessTokenId,
accessToken
});
if (!validCode) { if (!valid) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token")
);
}
if (
tokenItem.expiresAt &&
!isWithinExpirationDate(new Date(tokenItem.expiresAt))
) {
return next( return next(
createHttpError( createHttpError(
HttpCode.UNAUTHORIZED, HttpCode.UNAUTHORIZED,
"Access token has expired" error || "Invalid access token"
)
);
}
if (!tokenItem || !resource) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"Access token does not exist for resource"
) )
); );
} }

View File

@@ -109,15 +109,12 @@ export async function authWithPincode(
return next( return next(
createHttpError( createHttpError(
HttpCode.UNAUTHORIZED, HttpCode.UNAUTHORIZED,
createHttpError( "Resource has no pincode protection"
HttpCode.BAD_REQUEST,
"Resource has no pincode protection"
)
) )
); );
} }
const validPincode = verifyPassword( const validPincode = await verifyPassword(
pincode, pincode,
definedPincode.pincodeHash definedPincode.pincodeHash
); );

View File

@@ -24,7 +24,7 @@ const createSiteParamsSchema = z
const createSiteSchema = z const createSiteSchema = z
.object({ .object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
exitNodeId: z.number().int().positive(), exitNodeId: z.number().int().positive().optional(),
// subdomain: z // subdomain: z
// .string() // .string()
// .min(1) // .min(1)
@@ -32,7 +32,7 @@ const createSiteSchema = z
// .transform((val) => val.toLowerCase()) // .transform((val) => val.toLowerCase())
// .optional(), // .optional(),
pubKey: z.string().optional(), pubKey: z.string().optional(),
subnet: z.string(), subnet: z.string().optional(),
newtId: z.string().optional(), newtId: z.string().optional(),
secret: z.string().optional(), secret: z.string().optional(),
type: z.string() type: z.string()
@@ -82,28 +82,46 @@ export async function createSite(
const niceId = await getUniqueSiteName(orgId); const niceId = await getUniqueSiteName(orgId);
let payload: any = {
orgId,
exitNodeId,
name,
niceId,
subnet,
type
};
if (pubKey && type == "wireguard") {
// we dont add the pubKey for newts because the newt will generate it
payload = {
...payload,
pubKey
};
}
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
const [newSite] = await trx let newSite: Site;
.insert(sites)
.values(payload) if (exitNodeId) {
.returning(); // we are creating a site with an exit node (tunneled)
if (!subnet) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Subnet is required for tunneled sites"
)
);
}
[newSite] = await trx
.insert(sites)
.values({
orgId,
exitNodeId,
name,
niceId,
subnet,
type,
...(pubKey && type == "wireguard" && { pubKey })
})
.returning();
} else {
// we are creating a site with no tunneling
[newSite] = await trx
.insert(sites)
.values({
orgId,
name,
niceId,
type,
subnet: "0.0.0.0/0"
})
.returning();
}
const adminRole = await trx const adminRole = await trx
.select() .select()
@@ -149,6 +167,16 @@ export async function createSite(
) )
); );
} }
if (!exitNodeId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Exit node ID is required for wireguard sites"
)
);
}
await addPeer(exitNodeId, { await addPeer(exitNodeId, {
publicKey: pubKey, publicKey: pubKey,
allowedIps: [] allowedIps: []

View File

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

View File

@@ -12,6 +12,34 @@ import { isIpInCidr } from "@server/lib/ip";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { addTargets } from "../newt/targets"; 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 const createTargetParamsSchema = z
.object({ .object({
resourceId: z resourceId: z
@@ -23,7 +51,7 @@ const createTargetParamsSchema = z
const createTargetSchema = z const createTargetSchema = z
.object({ .object({
ip: z.string().ip().or(z.literal('localhost')), ip: domainSchema,
method: z.string().min(1).max(10), method: z.string().min(1).max(10),
port: z.number().int().min(1).max(65535), port: z.number().int().min(1).max(65535),
protocol: z.string().optional(), protocol: z.string().optional(),
@@ -95,88 +123,100 @@ export async function createTarget(
); );
} }
// make sure the target is within the site subnet let newTarget: Target[] = [];
if ( if (site.type == "local") {
site.type == "wireguard" && newTarget = await db
!isIpInCidr(targetData.ip, site.subnet!) .insert(targets)
) { .values({
return next( resourceId,
createHttpError( protocol: "tcp", // hard code for now
HttpCode.BAD_REQUEST, ...targetData
`Target IP is not within the site subnet` })
) .returning();
); } else {
} // make sure the target is within the site subnet
if (
// Fetch resources for this site site.type == "wireguard" &&
const resourcesRes = await db.query.resources.findMany({ !isIpInCidr(targetData.ip, site.subnet!)
where: eq(resources.siteId, site.siteId) ) {
}); return next(
createHttpError(
// TODO: is this all inefficient? HttpCode.BAD_REQUEST,
// Fetch targets for all resources of this site `Target IP is not within the site subnet`
let targetIps: string[] = []; )
let targetInternalPorts: number[] = []; );
await Promise.all(
resourcesRes.map(async (resource) => {
const targetsRes = await db.query.targets.findMany({
where: eq(targets.resourceId, resource.resourceId)
});
targetsRes.forEach((target) => {
targetIps.push(`${target.ip}/32`);
if (target.internalPort) {
targetInternalPorts.push(target.internalPort);
}
});
})
);
let internalPort!: number;
// pick a port
for (let i = 40000; i < 65535; i++) {
if (!targetInternalPorts.includes(i)) {
internalPort = i;
break;
} }
}
if (!internalPort) { // Fetch resources for this site
return next( const resourcesRes = await db.query.resources.findMany({
createHttpError( where: eq(resources.siteId, site.siteId)
HttpCode.BAD_REQUEST, });
`No available internal port`
) // TODO: is this all inefficient?
// Fetch targets for all resources of this site
let targetIps: string[] = [];
let targetInternalPorts: number[] = [];
await Promise.all(
resourcesRes.map(async (resource) => {
const targetsRes = await db.query.targets.findMany({
where: eq(targets.resourceId, resource.resourceId)
});
targetsRes.forEach((target) => {
targetIps.push(`${target.ip}/32`);
if (target.internalPort) {
targetInternalPorts.push(target.internalPort);
}
});
})
); );
}
const newTarget = await db let internalPort!: number;
.insert(targets) // pick a port
.values({ for (let i = 40000; i < 65535; i++) {
resourceId, if (!targetInternalPorts.includes(i)) {
protocol: "tcp", // hard code for now internalPort = i;
internalPort, break;
...targetData }
}) }
.returning();
// add the new target to the targetIps array if (!internalPort) {
targetIps.push(`${targetData.ip}/32`); return next(
createHttpError(
HttpCode.BAD_REQUEST,
`No available internal port`
)
);
}
if (site.pubKey) { newTarget = await db
if (site.type == "wireguard") { .insert(targets)
await addPeer(site.exitNodeId!, { .values({
publicKey: site.pubKey, resourceId,
allowedIps: targetIps.flat() protocol: "tcp", // hard code for now
}); internalPort,
} else if (site.type == "newt") { ...targetData
// get the newt on the site by querying the newt table for siteId })
const [newt] = await db .returning();
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
addTargets(newt.newtId, newTarget); // add the new target to the targetIps array
targetIps.push(`${targetData.ip}/32`);
if (site.pubKey) {
if (site.type == "wireguard") {
await addPeer(site.exitNodeId!, {
publicKey: site.pubKey,
allowedIps: targetIps.flat()
});
} else if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
addTargets(newt.newtId, newTarget);
}
} }
} }

View File

@@ -11,6 +11,34 @@ import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers"; import { addPeer } from "../gerbil/peers";
import { addTargets } from "../newt/targets"; 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 const updateTargetParamsSchema = z
.object({ .object({
targetId: z.string().transform(Number).pipe(z.number().int().positive()) targetId: z.string().transform(Number).pipe(z.number().int().positive())
@@ -19,7 +47,7 @@ const updateTargetParamsSchema = z
const updateTargetBodySchema = z const updateTargetBodySchema = z
.object({ .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(), method: z.string().min(1).max(10).optional(),
port: z.number().int().min(1).max(65535).optional(), port: z.number().int().min(1).max(65535).optional(),
enabled: z.boolean().optional() enabled: z.boolean().optional()

View File

@@ -56,6 +56,7 @@ export async function traefikConfigProvider(
config.getRawConfig().server.resource_session_cookie_name, config.getRawConfig().server.resource_session_cookie_name,
userSessionCookieName: userSessionCookieName:
config.getRawConfig().server.session_cookie_name, config.getRawConfig().server.session_cookie_name,
accessTokenQueryParam: config.getRawConfig().server.resource_access_token_param,
}, },
}, },
}, },
@@ -150,6 +151,16 @@ export async function traefikConfigProvider(
], ],
}, },
}; };
} else if (site.type === "local") {
http.services![serviceName] = {
loadBalancer: {
servers: [
{
url: `${target.method}://${target.ip}:${target.port}`,
},
],
},
};
} }
} }

View File

@@ -72,6 +72,16 @@ export async function acceptInvite(
const { user, session } = await verifySession(req); 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) { if (user && user.email !== existingInvite.email) {
return next( return next(
createHttpError( 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) { if (doEmail) {
await sendEmail( await sendEmail(

View File

@@ -5,9 +5,9 @@ import { eq, ne } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
export async function copyInConfig() { export async function copyInConfig() {
// create a url from config.getRawConfig().app.base_url and get the hostname
const domain = config.getBaseDomain(); const domain = config.getBaseDomain();
const endpoint = config.getRawConfig().gerbil.base_endpoint; const endpoint = config.getRawConfig().gerbil.base_endpoint;
const listenPort = config.getRawConfig().gerbil.start_port;
// update the domain on all of the orgs where the domain is not equal to the new domain // 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 // TODO: eventually each org could have a unique domain that we do not want to overwrite, so this will be unnecessary
@@ -15,6 +15,8 @@ export async function copyInConfig() {
// TODO: eventually each exit node could have a different endpoint // TODO: eventually each exit node could have a different endpoint
await db.update(exitNodes).set({ endpoint }).where(ne(exitNodes.endpoint, 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 // update all resources fullDomain to use the new domain
await db.transaction(async (trx) => { await db.transaction(async (trx) => {

View File

@@ -4,16 +4,25 @@ import path from "path";
import semver from "semver"; import semver from "semver";
import { versionMigrations } from "@server/db/schema"; import { versionMigrations } from "@server/db/schema";
import { desc } from "drizzle-orm"; import { desc } from "drizzle-orm";
import { __DIRNAME } from "@server/lib/consts"; import { __DIRNAME, APP_PATH } from "@server/lib/consts";
import { loadAppVersion } from "@server/lib/loadAppVersion"; import { loadAppVersion } from "@server/lib/loadAppVersion";
import m1 from "./scripts/1.0.0-beta1"; import m1 from "./scripts/1.0.0-beta1";
import m2 from "./scripts/1.0.0-beta2";
import m3 from "./scripts/1.0.0-beta3";
import m4 from "./scripts/1.0.0-beta5";
import m5 from "./scripts/1.0.0-beta6";
import { existsSync, mkdirSync } from "fs";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
// Define the migration list with versions and their corresponding functions // Define the migration list with versions and their corresponding functions
const migrations = [ 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 },
{ version: "1.0.0-beta.5", run: m4 },
{ version: "1.0.0-beta.6", run: m5 }
// Add new migrations here as they are created // Add new migrations here as they are created
] as const; ] as const;

View File

@@ -1,7 +1,5 @@
import logger from "@server/logger";
export default async function migration() { 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 // 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

@@ -0,0 +1,101 @@
import { APP_PATH, configFilePath1, configFilePath2 } 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";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.5...");
// 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.server) {
throw new Error(`Invalid config file: server is missing.`);
}
// Update the config
rawConfig.server.resource_access_token_param = "p_token";
// Write the updated YAML back to the file
const updatedYaml = yaml.dump(rawConfig);
fs.writeFileSync(filePath, updatedYaml, "utf8");
// then try to update badger in traefik config
try {
const traefikPath = path.join(
APP_PATH,
"traefik",
"traefik_config.yml"
);
// read the traefik file
// look for the badger middleware
// set the version to v1.0.0-beta.2
/*
experimental:
plugins:
badger:
moduleName: "github.com/fosrl/badger"
version: "v1.0.0-beta.2"
*/
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-beta.2";
const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log(
"Updated the version of Badger in your Traefik configuration to v1.0.0-beta.2."
);
} 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("Done.");
}

View File

@@ -0,0 +1,52 @@
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.6...");
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);
// Validate the structure
if (!rawConfig.server) {
throw new Error(`Invalid config file: server is missing.`);
}
// Update the config
rawConfig.server.cors = {
origins: [rawConfig.app.dashboard_url],
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
headers: ["X-CSRF-Token", "Content-Type"],
credentials: false
};
// Write the updated YAML back to the file
const updatedYaml = yaml.dump(rawConfig);
fs.writeFileSync(filePath, updatedYaml, "utf8");
} catch (error) {
console.log("We were unable to add CORS to your config file. Please add it manually.")
console.error(error)
}
console.log("Done.");
}

View File

@@ -69,6 +69,8 @@ export async function setupServerAdmin() {
const userId = generateId(15); const userId = generateId(15);
await trx.update(users).set({ serverAdmin: false });
await db.insert(users).values({ await db.insert(users).values({
userId: userId, userId: userId,
email: email, email: email,

View File

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

View File

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

View File

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

View File

@@ -62,9 +62,38 @@ import {
SettingsSectionFooter SettingsSectionFooter
} from "@app/components/Settings"; } from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput"; 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({ const addTargetSchema = z.object({
ip: z.union([z.string().ip(), z.literal("localhost")]), ip: domainSchema,
method: z.string(), method: z.string(),
port: z.coerce.number().int().positive() port: z.coerce.number().int().positive()
// protocol: z.string(), // protocol: z.string(),
@@ -179,7 +208,7 @@ export default function ReverseProxyTargets(props: {
// make sure that the target IP is within the site subnet // make sure that the target IP is within the site subnet
const targetIp = data.ip; const targetIp = data.ip;
const subnet = site.subnet; const subnet = site.subnet;
if (targetIp === "localhost" || !isIPInSubnet(targetIp, subnet)) { if (!isIPInSubnet(targetIp, subnet)) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Invalid target IP", title: "Invalid target IP",
@@ -323,7 +352,7 @@ export default function ReverseProxyTargets(props: {
}, },
{ {
accessorKey: "ip", accessorKey: "ip",
header: "IP Address", header: "IP / Hostname",
cell: ({ row }) => ( cell: ({ row }) => (
<Input <Input
defaultValue={row.original.ip} defaultValue={row.original.ip}
@@ -500,11 +529,23 @@ export default function ReverseProxyTargets(props: {
name="ip" name="ip"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>IP Address</FormLabel> <FormLabel>IP / Hostname</FormLabel>
<FormControl> <FormControl>
<Input id="ip" {...field} /> <Input id="ip" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
{site?.type === "newt" ? (
<FormDescription>
This is the IP or hostname
of the target service on
your network.
</FormDescription>
) : site?.type === "wireguard" ? (
<FormDescription>
This is the IP of the
WireGuard peer.
</FormDescription>
) : null}
</FormItem> </FormItem>
)} )}
/> />
@@ -523,6 +564,19 @@ export default function ReverseProxyTargets(props: {
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
{site?.type === "newt" ? (
<FormDescription>
This is the port of the
target service on your
network.
</FormDescription>
) : site?.type === "wireguard" ? (
<FormDescription>
This is the port exposed on
an address on the WireGuard
network.
</FormDescription>
) : null}
</FormItem> </FormItem>
)} )}
/> />

View File

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

View File

@@ -57,14 +57,22 @@ import {
CommandItem, CommandItem,
CommandList CommandList
} from "@app/components/ui/command"; } from "@app/components/ui/command";
import { CheckIcon } from "lucide-react"; import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { register } from "module"; import { register } from "module";
import { Label } from "@app/components/ui/label"; import { Label } from "@app/components/ui/label";
import { Checkbox } from "@app/components/ui/checkbox"; import { Checkbox } from "@app/components/ui/checkbox";
import { GenerateAccessTokenResponse } from "@server/routers/accessToken"; import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
import { constructShareLink } from "@app/lib/shareLinks"; import {
constructDirectShareLink,
constructShareLink
} from "@app/lib/shareLinks";
import { ShareLinkRow } from "./ShareLinksTable"; import { ShareLinkRow } from "./ShareLinksTable";
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react"; import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from "@app/components/ui/collapsible";
type FormProps = { type FormProps = {
open: boolean; open: boolean;
@@ -75,6 +83,7 @@ type FormProps = {
const formSchema = z.object({ const formSchema = z.object({
resourceId: z.number({ message: "Please select a resource" }), resourceId: z.number({ message: "Please select a resource" }),
resourceName: z.string(), resourceName: z.string(),
resourceUrl: z.string(),
timeUnit: z.string(), timeUnit: z.string(),
timeValue: z.coerce.number().int().positive().min(1), timeValue: z.coerce.number().int().positive().min(1),
title: z.string().optional() title: z.string().optional()
@@ -88,14 +97,18 @@ export default function CreateShareLinkForm({
const { toast } = useToast(); const { toast } = useToast();
const { org } = useOrgContext(); const { org } = useOrgContext();
const api = createApiClient(useEnvContext()); const { env } = useEnvContext();
const api = createApiClient({ env });
const [link, setLink] = useState<string | null>(null); const [link, setLink] = useState<string | null>(null);
const [directLink, setDirectLink] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [neverExpire, setNeverExpire] = useState(false); const [neverExpire, setNeverExpire] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [resources, setResources] = useState< const [resources, setResources] = useState<
{ resourceId: number; name: string }[] { resourceId: number; name: string; resourceUrl: string }[]
>([]); >([]);
const timeUnits = [ const timeUnits = [
@@ -139,7 +152,13 @@ export default function CreateShareLinkForm({
}); });
if (res?.status === 200) { if (res?.status === 200) {
setResources(res.data.data.resources); setResources(
res.data.data.resources.map((r) => ({
resourceId: r.resourceId,
name: r.name,
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
}))
);
} }
} }
@@ -202,6 +221,13 @@ export default function CreateShareLinkForm({
token.accessToken token.accessToken
); );
setLink(link); setLink(link);
const directLink = constructDirectShareLink(
env.server.resourceAccessTokenParam,
values.resourceUrl,
token.accessTokenId,
token.accessToken
);
setDirectLink(directLink);
onCreated?.({ onCreated?.({
accessTokenId: token.accessTokenId, accessTokenId: token.accessTokenId,
resourceId: token.resourceId, resourceId: token.resourceId,
@@ -306,6 +332,10 @@ export default function CreateShareLinkForm({
"resourceName", "resourceName",
r.name r.name
); );
form.setValue(
"resourceUrl",
r.resourceUrl
);
}} }}
> >
<CheckIcon <CheckIcon
@@ -462,12 +492,62 @@ export default function CreateShareLinkForm({
<QRCodeCanvas value={link} size={200} /> <QRCodeCanvas value={link} size={200} />
</div> </div>
<div className="mx-auto"> <Collapsible
<CopyTextBox open={isOpen}
text={link} onOpenChange={setIsOpen}
wrapText={false} className="space-y-2"
/> >
</div> <div className="mx-auto">
<CopyTextBox
text={link}
wrapText={false}
/>
</div>
<div className="flex items-center justify-between space-x-4">
<CollapsibleTrigger asChild>
<Button
variant="text"
size="sm"
className="p-0 flex items-center justify-between w-full"
>
<h4 className="text-sm font-semibold">
See alternative share
links
</h4>
<div>
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">
Toggle
</span>
</div>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="space-y-2">
{directLink && (
<div className="space-y-2">
<div className="mx-auto">
<CopyTextBox
text={directLink}
wrapText={false}
/>
</div>
<p className="text-sm text-muted-foreground">
This link does not
require visiting in a
browser to complete the
redirect. It contains
the access token
directly in the URL,
which can be useful for
sharing with clients
that do not support
redirects.
</p>
</div>
)}
</CollapsibleContent>
</Collapsible>
</div> </div>
)} )}
</div> </div>

View File

@@ -24,7 +24,7 @@ import { useRouter } from "next/navigation";
// import CreateResourceForm from "./CreateResourceForm"; // import CreateResourceForm from "./CreateResourceForm";
import { useState } from "react"; import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { formatAxiosError } from "@app/lib/api";; import { formatAxiosError } from "@app/lib/api";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -109,15 +109,14 @@ export default function ShareLinksTable({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem> <DropdownMenuItem
<button onClick={() => {
onClick={() => deleteSharelink(
deleteSharelink( resourceRow.accessTokenId
resourceRow.accessTokenId );
) }}
} >
className="text-red-500" <button className="text-red-500">
>
Delete Delete
</button> </button>
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -49,7 +49,7 @@ const createSiteFormSchema = z.object({
.max(30, { .max(30, {
message: "Name must not be longer than 30 characters." message: "Name must not be longer than 30 characters."
}), }),
method: z.enum(["wireguard", "newt"]) method: z.enum(["wireguard", "newt", "local"])
}); });
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>; type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
@@ -79,17 +79,16 @@ export default function CreateSiteForm({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isChecked, setIsChecked] = useState(false); const [isChecked, setIsChecked] = useState(false);
const router = useRouter();
const [keypair, setKeypair] = useState<{ const [keypair, setKeypair] = useState<{
publicKey: string; publicKey: string;
privateKey: string; privateKey: string;
} | null>(null); } | null>(null);
const [siteDefaults, setSiteDefaults] = const [siteDefaults, setSiteDefaults] =
useState<PickSiteDefaultsResponse | null>(null); useState<PickSiteDefaultsResponse | null>(null);
const handleCheckboxChange = (checked: boolean) => { const handleCheckboxChange = (checked: boolean) => {
setChecked?.(checked); // setChecked?.(checked);
setIsChecked(checked); setIsChecked(checked);
}; };
@@ -98,6 +97,17 @@ export default function CreateSiteForm({
defaultValues defaultValues
}); });
const nameField = form.watch("name");
const methodField = form.watch("method");
useEffect(() => {
const nameIsValid = nameField?.length >= 2 && nameField?.length <= 30;
const isFormValid = methodField === "local" || isChecked;
// Only set checked to true if name is valid AND (method is local OR checkbox is checked)
setChecked?.(nameIsValid && isFormValid);
}, [nameField, methodField, isChecked, setChecked]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -114,11 +124,8 @@ export default function CreateSiteForm({
api.get(`/org/${orgId}/pick-site-defaults`) api.get(`/org/${orgId}/pick-site-defaults`)
.catch((e) => { .catch((e) => {
toast({ // update the default value of the form to be local method
variant: "destructive", form.setValue("method", "local");
title: "Error picking site defaults",
description: formatAxiosError(e)
});
}) })
.then((res) => { .then((res) => {
if (res && res.status === 200) { if (res && res.status === 200) {
@@ -130,24 +137,56 @@ export default function CreateSiteForm({
async function onSubmit(data: CreateSiteFormValues) { async function onSubmit(data: CreateSiteFormValues) {
setLoading?.(true); setLoading?.(true);
setIsLoading(true); setIsLoading(true);
if (!siteDefaults || !keypair) {
return;
}
let payload: CreateSiteBody = { let payload: CreateSiteBody = {
name: data.name, name: data.name,
subnet: siteDefaults.subnet,
exitNodeId: siteDefaults.exitNodeId,
pubKey: keypair.publicKey,
type: data.method type: data.method
}; };
if (data.method === "newt") {
payload.secret = siteDefaults.newtSecret; if (data.method == "wireguard") {
payload.newtId = siteDefaults.newtId; if (!keypair || !siteDefaults) {
toast({
variant: "destructive",
title: "Error creating site",
description: "Key pair or site defaults not found"
});
setLoading?.(false);
setIsLoading(false);
return;
}
payload = {
...payload,
subnet: siteDefaults.subnet,
exitNodeId: siteDefaults.exitNodeId,
pubKey: keypair.publicKey
};
} }
if (data.method === "newt") {
if (!siteDefaults) {
toast({
variant: "destructive",
title: "Error creating site",
description: "Site defaults not found"
});
setLoading?.(false);
setIsLoading(false);
return;
}
payload = {
...payload,
subnet: siteDefaults.subnet,
exitNodeId: siteDefaults.exitNodeId,
secret: siteDefaults.newtSecret,
newtId: siteDefaults.newtId
};
}
const res = await api const res = await api
.put< .put<AxiosResponse<CreateSiteResponse>>(
AxiosResponse<CreateSiteResponse> `/org/${orgId}/site/`,
>(`/org/${orgId}/site/`, payload) payload
)
.catch((e) => { .catch((e) => {
toast({ toast({
variant: "destructive", variant: "destructive",
@@ -157,18 +196,20 @@ export default function CreateSiteForm({
}); });
if (res && res.status === 201) { if (res && res.status === 201) {
const niceId = res.data.data.niceId;
// navigate to the site page
// router.push(`/${orgId}/settings/sites/${niceId}`);
const data = res.data.data; const data = res.data.data;
onCreate?.({ onCreate?.({
name: data.name, name: data.name,
id: data.siteId, id: data.siteId,
nice: data.niceId.toString(), nice: data.niceId.toString(),
mbIn: "0 MB", mbIn:
mbOut: "0 MB", data.type == "wireguard" || data.type == "newt"
? "0 MB"
: "--",
mbOut:
data.type == "wireguard" || data.type == "newt"
? "0 MB"
: "--",
orgId: orgId as string, orgId: orgId as string,
type: data.type as any, type: data.type as any,
online: false online: false
@@ -194,10 +235,10 @@ PersistentKeepalive = 5`
: ""; : "";
// am I at http or https? // am I at http or https?
let proto = "http:"; let proto = "https:";
if (typeof window !== "undefined") { // if (typeof window !== "undefined") {
proto = window.location.protocol; // proto = window.location.protocol;
} // }
const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${proto}//${siteDefaults?.endpoint}`; const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${proto}//${siteDefaults?.endpoint}`;
@@ -245,12 +286,21 @@ PersistentKeepalive = 5`
<SelectValue placeholder="Select method" /> <SelectValue placeholder="Select method" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="wireguard"> <SelectItem value="local">
WireGuard Local
</SelectItem> </SelectItem>
<SelectItem value="newt"> <SelectItem
value="newt"
disabled={!siteDefaults}
>
Newt Newt
</SelectItem> </SelectItem>
<SelectItem
value="wireguard"
disabled={!siteDefaults}
>
WireGuard
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>
@@ -264,50 +314,76 @@ PersistentKeepalive = 5`
<div className="w-full"> <div className="w-full">
{form.watch("method") === "wireguard" && !isLoading ? ( {form.watch("method") === "wireguard" && !isLoading ? (
<CopyTextBox text={wgConfig} /> <>
<CopyTextBox text={wgConfig} />
<span className="text-sm text-muted-foreground">
You will only be able to see the
configuration once.
</span>
</>
) : form.watch("method") === "wireguard" && ) : form.watch("method") === "wireguard" &&
isLoading ? ( isLoading ? (
<p>Loading WireGuard configuration...</p> <p>Loading WireGuard configuration...</p>
) : ( ) : form.watch("method") === "newt" ? (
<CopyTextBox text={newtConfig} wrapText={false} /> <>
)} <CopyTextBox
text={newtConfig}
wrapText={false}
/>
<span className="text-sm text-muted-foreground">
You will only be able to see the
configuration once.
</span>
</>
) : null}
</div> </div>
<span className="text-sm text-muted-foreground">
You will only be able to see the configuration once.
</span>
{form.watch("method") === "newt" && ( {form.watch("method") === "newt" && (
<> <Link
<br /> className="text-sm text-primary flex items-center gap-1"
<Link href="https://docs.fossorial.io/Newt/install"
className="text-sm text-primary flex items-center gap-1" target="_blank"
href="https://docs.fossorial.io/Newt/install" rel="noopener noreferrer"
target="_blank" >
rel="noopener noreferrer" <span>
> {" "}
<span> Learn how to install Newt on your system
{" "} </span>
Learn how to install Newt on your system <SquareArrowOutUpRight size={14} />
</span> </Link>
<SquareArrowOutUpRight size={14} />
</Link>
</>
)} )}
<div className="flex items-center space-x-2"> {form.watch("method") === "local" && (
<Checkbox <Link
id="terms" className="text-sm text-primary flex items-center gap-1"
checked={isChecked} href="https://docs.fossorial.io/Pangolin/without-tunneling"
onCheckedChange={handleCheckboxChange} target="_blank"
/> rel="noopener noreferrer"
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
> >
I have copied the config <span>
</label> {" "}
</div> Local sites do not tunnel, learn more
</span>
<SquareArrowOutUpRight size={14} />
</Link>
)}
{(form.watch("method") === "newt" ||
form.watch("method") === "wireguard") && (
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
checked={isChecked}
onCheckedChange={handleCheckboxChange}
/>
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I have copied the config
</label>
</div>
)}
</form> </form>
</Form> </Form>
</div> </div>

View File

@@ -23,7 +23,7 @@ import { useState } from "react";
import CreateSiteForm from "./CreateSiteForm"; import CreateSiteForm from "./CreateSiteForm";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";; import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import CreateSiteFormModal from "./CreateSiteModal"; import CreateSiteFormModal from "./CreateSiteModal";
@@ -146,21 +146,27 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
}, },
cell: ({ row }) => { cell: ({ row }) => {
const originalRow = row.original; const originalRow = row.original;
if (
if (originalRow.online) { originalRow.type == "newt" ||
return ( originalRow.type == "wireguard"
<span className="text-green-500 flex items-center space-x-2"> ) {
<div className="w-2 h-2 bg-green-500 rounded-full"></div> if (originalRow.online) {
<span>Online</span> return (
</span> <span className="text-green-500 flex items-center space-x-2">
); <div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Online</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Offline</span>
</span>
);
}
} else { } else {
return ( return <span>--</span>;
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Offline</span>
</span>
);
} }
} }
}, },
@@ -245,6 +251,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
</div> </div>
); );
} }
if (originalRow.type === "local") {
return (
<div className="flex items-center space-x-2">
<span>Local</span>
</div>
);
}
} }
}, },
{ {

View File

@@ -16,37 +16,50 @@ type SiteInfoCardProps = {};
export default function SiteInfoCard({}: SiteInfoCardProps) { export default function SiteInfoCard({}: SiteInfoCardProps) {
const { site, updateSite } = useSiteContext(); const { site, updateSite } = useSiteContext();
const getConnectionTypeString = (type: string) => {
if (type === "newt") {
return "Newt";
} else if (type === "wireguard") {
return "WireGuard";
} else if (type === "local") {
return "Local";
} else {
return "Unknown";
}
};
return ( return (
<Alert> <Alert>
<InfoIcon className="h-4 w-4" /> <InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">Site Information</AlertTitle> <AlertTitle className="font-semibold">Site Information</AlertTitle>
<AlertDescription className="mt-4"> <AlertDescription className="mt-4">
<InfoSections> <InfoSections>
<InfoSection> {(site.type == "newt" || site.type == "wireguard") && (
<InfoSectionTitle>Status</InfoSectionTitle> <>
<InfoSectionContent> <InfoSection>
{site.online ? ( <InfoSectionTitle>Status</InfoSectionTitle>
<div className="text-green-500 flex items-center space-x-2"> <InfoSectionContent>
<div className="w-2 h-2 bg-green-500 rounded-full"></div> {site.online ? (
<span>Online</span> <div className="text-green-500 flex items-center space-x-2">
</div> <div className="w-2 h-2 bg-green-500 rounded-full"></div>
) : ( <span>Online</span>
<div className="text-neutral-500 flex items-center space-x-2"> </div>
<div className="w-2 h-2 bg-gray-500 rounded-full"></div> ) : (
<span>Offline</span> <div className="text-neutral-500 flex items-center space-x-2">
</div> <div className="w-2 h-2 bg-gray-500 rounded-full"></div>
)} <span>Offline</span>
</InfoSectionContent> </div>
</InfoSection> )}
<Separator orientation="vertical" /> </InfoSectionContent>
</InfoSection>
<Separator orientation="vertical" />
</>
)}
<InfoSection> <InfoSection>
<InfoSectionTitle>Connection Type</InfoSectionTitle> <InfoSectionTitle>Connection Type</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{site.type === "newt" {getConnectionTypeString(site.type)}
? "Newt"
: site.type === "wireguard"
? "WireGuard"
: "Unknown"}
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
</InfoSections> </InfoSections>

View File

@@ -23,7 +23,10 @@ export default async function SitesPage(props: SitesPageProps) {
sites = res.data.data.sites; sites = res.data.data.sites;
} catch (e) {} } catch (e) {}
function formatSize(mb: number): string { function formatSize(mb: number, type: string): string {
if (type === "local") {
return "--"; // because we are not able to track the data use in a local site right now
}
if (mb >= 1024 * 1024) { if (mb >= 1024 * 1024) {
return `${(mb / (1024 * 1024)).toFixed(2)} TB`; return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
} else if (mb >= 1024) { } else if (mb >= 1024) {
@@ -38,8 +41,8 @@ export default async function SitesPage(props: SitesPageProps) {
name: site.name, name: site.name,
id: site.siteId, id: site.siteId,
nice: site.niceId.toString(), nice: site.niceId.toString(),
mbIn: formatSize(site.megabytesIn || 0), mbIn: formatSize(site.megabytesIn || 0, site.type),
mbOut: formatSize(site.megabytesOut || 0), mbOut: formatSize(site.megabytesOut || 0, site.type),
orgId: params.orgId, orgId: params.orgId,
type: site.type as any, type: site.type as any,
online: site.online online: site.online

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ export default function AccessToken({
redirectUrl redirectUrl
}: AccessTokenProps) { }: AccessTokenProps) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isValid, setIsValid] = useState(false);
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
@@ -49,6 +50,7 @@ export default function AccessToken({
}); });
if (res.data.data.session) { if (res.data.data.session) {
setIsValid(true);
window.location.href = redirectUrl; window.location.href = redirectUrl;
} }
} catch (e) { } catch (e) {
@@ -61,24 +63,47 @@ export default function AccessToken({
check(); check();
}, [accessTokenId, accessToken]); }, [accessTokenId, accessToken]);
function renderTitle() {
if (isValid) {
return "Access Granted";
} else {
return "Access URL Invalid";
}
}
function renderContent() {
if (isValid) {
return (
<div>
You have been granted access to this resource. Redirecting
you...
</div>
);
} else {
return (
<div>
This shared access URL is invalid. Please contact the
resource owner for a new URL.
<div className="text-center mt-4">
<Button>
<Link href="/">Go Home</Link>
</Button>
</div>
</div>
);
}
}
return loading ? ( return loading ? (
<div></div> <div></div>
) : ( ) : (
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader> <CardHeader>
<CardTitle className="text-center text-2xl font-bold"> <CardTitle className="text-center text-2xl font-bold">
Access URL Invalid {renderTitle()}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>{renderContent()}</CardContent>
This shared access URL is invalid. Please contact the resource
owner for a new URL.
<div className="text-center mt-4">
<Button>
<Link href="/">Go Home</Link>
</Button>
</div>
</CardContent>
</Card> </Card>
); );
} }

View File

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

View File

@@ -45,17 +45,26 @@ export default async function ResourceAuthPage(props: {
const user = await getUser({ skipCheckVerifyEmail: true }); const user = await getUser({ skipCheckVerifyEmail: true });
if (!authInfo) { if (!authInfo) {
{ // TODO: fix this
/* @ts-ignore */
} // TODO: fix this
return ( return (
<div className="w-full max-w-md"> <div className="w-full max-w-md">
{/* @ts-ignore */}
<ResourceNotFound /> <ResourceNotFound />
</div> </div>
); );
} }
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 = const hasAuth =
authInfo.password || authInfo.password ||

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,8 @@ import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider"; import EnvProvider from "@app/providers/EnvProvider";
import { Separator } from "@app/components/ui/separator"; import { Separator } from "@app/components/ui/separator";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { BookOpenText } from "lucide-react";
import Image from "next/image";
export const metadata: Metadata = { export const metadata: Metadata = {
title: `Dashboard - Pangolin`, title: `Dashboard - Pangolin`,
@@ -38,10 +40,10 @@ export default async function RootLayout({
<div className="flex-grow">{children}</div> <div className="flex-grow">{children}</div>
{/* Footer */} {/* Footer */}
<footer className="w-full mt-12 py-3 mb-6"> <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-4 space-x-4 text-sm text-neutral-400 select-none"> <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="whitespace-nowrap"> <div className="flex items-center space-x-2 whitespace-nowrap">
Pangolin <span>Pangolin</span>
</div> </div>
<Separator orientation="vertical" /> <Separator orientation="vertical" />
<div className="whitespace-nowrap"> <div className="whitespace-nowrap">
@@ -60,7 +62,7 @@ export default async function RootLayout({
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="currentColor" 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" /> <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> </svg>
@@ -70,10 +72,11 @@ export default async function RootLayout({
href="https://docs.fossorial.io/Pangolin/overview" href="https://docs.fossorial.io/Pangolin/overview"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label="GitHub" aria-label="Documentation"
className="flex items-center space-x-3 whitespace-nowrap" className="flex items-center space-x-3 whitespace-nowrap"
> >
<span>Docs</span> <span>Documentation</span>
<BookOpenText className="w-3 h-3" />
</a> </a>
{version && ( {version && (
<> <>

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ const buttonVariants = cva(
secondary: secondary:
"bg-secondary border border-input text-secondary-foreground hover:bg-secondary/80", "bg-secondary border border-input text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
text: "",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {

View File

@@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

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 : "/";
}

View File

@@ -6,7 +6,8 @@ export function pullEnv(): Env {
nextPort: process.env.NEXT_PORT as string, nextPort: process.env.NEXT_PORT as string,
externalPort: process.env.SERVER_EXTERNAL_PORT as string, externalPort: process.env.SERVER_EXTERNAL_PORT as string,
sessionCookieName: process.env.SESSION_COOKIE_NAME as string, sessionCookieName: process.env.SESSION_COOKIE_NAME as string,
resourceSessionCookieName: process.env.RESOURCE_SESSION_COOKIE_NAME as string resourceSessionCookieName: process.env.RESOURCE_SESSION_COOKIE_NAME as string,
resourceAccessTokenParam: process.env.RESOURCE_ACCESS_TOKEN_PARAM as string
}, },
app: { app: {
environment: process.env.ENVIRONMENT as string, environment: process.env.ENVIRONMENT as string,

View File

@@ -1,3 +1,5 @@
import { pullEnv } from "./pullEnv";
export function constructShareLink( export function constructShareLink(
resourceId: number, resourceId: number,
id: string, id: string,
@@ -5,3 +7,12 @@ export function constructShareLink(
) { ) {
return `${window.location.origin}/auth/resource/${resourceId}?token=${id}.${token}`; return `${window.location.origin}/auth/resource/${resourceId}?token=${id}.${token}`;
} }
export function constructDirectShareLink(
param: string,
resourceUrl: string,
id: string,
token: string
) {
return `${resourceUrl}?${param}=${id}.${token}`;
}

View File

@@ -8,6 +8,7 @@ export type Env = {
nextPort: string; nextPort: string;
sessionCookieName: string; sessionCookieName: string;
resourceSessionCookieName: string; resourceSessionCookieName: string;
resourceAccessTokenParam: string;
}, },
email: { email: {
emailEnabled: boolean; emailEnabled: boolean;