Compare commits

...

38 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
48 changed files with 1359 additions and 392 deletions

View File

@@ -27,6 +27,8 @@ COPY --from=builder /app/dist ./dist
COPY --from=builder /app/init ./dist/init
COPY config/config.example.yml ./dist/config.example.yml
COPY config/traefik/traefik_config.example.yml ./dist/traefik_config.example.yml
COPY config/traefik/dynamic_config.example.yml ./dist/dynamic_config.example.yml
COPY server/db/names.json ./dist/names.json
COPY public ./public

View File

@@ -13,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)
- [Full Documentation](https://docs.fossorial.io)
### Authors and Maintainers
- [Milo Schwartz](https://github.com/miloschwartz)
- [Owen Schwartz](https://github.com/oschwartz10612)
## Preview
<img src="public/screenshots/sites.png" alt="Preview"/>

View File

@@ -1,17 +1,18 @@
app:
dashboard_url: http://localhost
dashboard_url: http://localhost:3002
base_domain: localhost
log_level: debug
log_level: info
save_logs: false
server:
external_port: 3000
internal_port: 3001
next_port: 3002
internal_hostname: localhost
secure_cookies: false
internal_hostname: pangolin
secure_cookies: true
session_cookie_name: p_session
resource_session_cookie_name: p_resource_session
resource_access_token_param: p_token
traefik:
cert_resolver: letsencrypt
@@ -38,3 +39,5 @@ users:
flags:
require_email_verification: false
disable_signup_without_invite: true
disable_user_create_org: true

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

@@ -9,9 +9,15 @@ server:
internal_port: 3001
next_port: 3002
internal_hostname: pangolin
secure_cookies: false
secure_cookies: true
session_cookie_name: p_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:
cert_resolver: letsencrypt

View File

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

View File

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

View File

@@ -1,3 +1,8 @@
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,27 +10,38 @@ import (
"path/filepath"
"runtime"
"strings"
"syscall"
"text/template"
"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/*
var configFiles embed.FS
type Config struct {
BaseDomain string `yaml:"baseDomain"`
DashboardDomain string `yaml:"dashboardUrl"`
LetsEncryptEmail string `yaml:"letsEncryptEmail"`
AdminUserEmail string `yaml:"adminUserEmail"`
AdminUserPassword string `yaml:"adminUserPassword"`
DisableSignupWithoutInvite bool `yaml:"disableSignupWithoutInvite"`
DisableUserCreateOrg bool `yaml:"disableUserCreateOrg"`
EnableEmail bool `yaml:"enableEmail"`
EmailSMTPHost string `yaml:"emailSMTPHost"`
EmailSMTPPort int `yaml:"emailSMTPPort"`
EmailSMTPUser string `yaml:"emailSMTPUser"`
EmailSMTPPass string `yaml:"emailSMTPPass"`
EmailNoReply string `yaml:"emailNoReply"`
PangolinVersion string
GerbilVersion string
BaseDomain string
DashboardDomain string
LetsEncryptEmail string
AdminUserEmail string
AdminUserPassword string
DisableSignupWithoutInvite bool
DisableUserCreateOrg bool
EnableEmail bool
EmailSMTPHost string
EmailSMTPPort int
EmailSMTPUser string
EmailSMTPPass string
EmailNoReply string
InstallGerbil bool
}
func main() {
@@ -45,13 +56,16 @@ func main() {
// check if there is already a config file
if _, err := os.Stat("config/config.yml"); err != nil {
config := collectUserInput(reader)
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 shouldInstallDocker() {
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
installDocker()
}
}
@@ -82,6 +96,24 @@ func readString(reader *bufio.Reader, prompt string, defaultValue string) string
return input
}
func readPassword(prompt string) string {
fmt.Print(prompt + ": ")
// Read password without echo
password, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println() // Add a newline since ReadPassword doesn't add one
if err != nil {
return ""
}
input := strings.TrimSpace(string(password))
if input == "" {
return readPassword(prompt)
}
return input
}
func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
defaultStr := "no"
if defaultValue {
@@ -109,21 +141,29 @@ func collectUserInput(reader *bufio.Reader) Config {
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.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunned connections", true)
// Admin user configuration
fmt.Println("\n=== Admin User Configuration ===")
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain)
for {
config.AdminUserPassword = readString(reader, "Enter admin user password", "")
if valid, message := validatePassword(config.AdminUserPassword); valid {
break
pass1 := readPassword("Create admin user password")
pass2 := readPassword("Confirm admin user password")
if pass1 != pass2 {
fmt.Println("Passwords do not match")
} else {
fmt.Println("Invalid password:", message)
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")
config.AdminUserPassword = pass1
if valid, message := validatePassword(config.AdminUserPassword); valid {
break
} else {
fmt.Println("Invalid password:", message)
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")
}
}
}
@@ -302,13 +342,6 @@ func createConfigFiles(config Config) error {
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 {
// Detect Linux distribution
cmd := exec.Command("cat", "/etc/os-release")

View File

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

View File

@@ -20,23 +20,32 @@ const externalPort = config.getRawConfig().server.external_port;
export function createApiServer() {
const apiServer = express();
// Middleware setup
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.dashboard_url,
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
allowedHeaders: ["Content-Type", "X-CSRF-Token"]
};
if (config.getRawConfig().server.trust_proxy) {
apiServer.set("trust proxy", 1);
}
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(csrfProtectionMiddleware);
}
@@ -47,7 +56,8 @@ export function createApiServer() {
if (!dev) {
apiServer.use(
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,
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

@@ -1,6 +1,6 @@
import {
encodeBase32LowerCaseNoPadding,
encodeHexLowerCase,
encodeHexLowerCase
} from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
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 type { RandomReader } 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 SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
export const COOKIE_DOMAIN = "." + config.getBaseDomain();
@@ -24,25 +26,25 @@ export function generateSessionToken(): string {
export async function createSession(
token: string,
userId: string,
userId: string
): Promise<Session> {
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token)),
sha256(new TextEncoder().encode(token))
);
const session: Session = {
sessionId: sessionId,
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);
return session;
}
export async function validateSessionToken(
token: string,
token: string
): Promise<SessionValidationResult> {
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token)),
sha256(new TextEncoder().encode(token))
);
const result = await db
.select({ user: users, session: sessions })
@@ -61,12 +63,12 @@ export async function validateSessionToken(
}
if (Date.now() >= session.expiresAt - SESSION_COOKIE_EXPIRES / 2) {
session.expiresAt = new Date(
Date.now() + SESSION_COOKIE_EXPIRES,
Date.now() + SESSION_COOKIE_EXPIRES
).getTime();
await db
.update(sessions)
.set({
expiresAt: session.expiresAt,
expiresAt: session.expiresAt
})
.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));
}
export function serializeSessionCookie(token: string): string {
if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
export function serializeSessionCookie(
token: string,
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 {
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 {
if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
export function createBlankSessionTokenCookie(isSecure: boolean): string {
if (isSecure) {
if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
}
} 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 = {
read(bytes: Uint8Array): void {
crypto.getRandomValues(bytes);
},
}
};
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 fs from "fs/promises";
import { APP_PATH } from "@server/lib/consts";
import { existsSync, mkdirSync } from "fs";
export const location = path.join(APP_PATH, "db", "db.sqlite");
export const exists = await checkFileExists(location);
bootstrapVolume();
const sqlite = new Database(location);
export const db = drizzle(sqlite, { schema });
@@ -21,3 +24,29 @@ async function checkFileExists(filePath: string): Promise<boolean> {
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,36 +3,74 @@ import yaml from "js-yaml";
import path from "path";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { __DIRNAME, APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
import {
__DIRNAME,
APP_PATH,
configFilePath1,
configFilePath2
} from "@server/lib/consts";
import { loadAppVersion } from "@server/lib/loadAppVersion";
import { passwordSchema } from "@server/auth/passwordSchema";
import stoi from "./stoi";
const portSchema = z.number().positive().gt(0).lte(65535);
const hostnameSchema = z
.string()
.regex(
/^(?!-)[a-zA-Z0-9-]{1,63}(?<!-)(\.[a-zA-Z]{2,})*$/,
"Invalid hostname. Must be a valid hostname like 'localhost' or 'test.example.com'."
);
/^(?:(?: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({
dashboard_url: z
.string()
.url()
.optional()
.transform(getEnvOrYaml("APP_DASHBOARDURL"))
.pipe(z.string().url())
.transform((url) => url.toLowerCase()),
base_domain: hostnameSchema,
base_domain: hostnameSchema
.optional()
.transform(getEnvOrYaml("APP_BASEDOMAIN"))
.pipe(hostnameSchema),
log_level: z.enum(["debug", "info", "warn", "error"]),
save_logs: z.boolean()
}),
server: z.object({
external_port: portSchema,
internal_port: portSchema,
next_port: portSchema,
external_port: portSchema
.optional()
.transform(getEnvOrYaml("SERVER_EXTERNALPORT"))
.transform(stoi)
.pipe(portSchema),
internal_port: portSchema
.optional()
.transform(getEnvOrYaml("SERVER_INTERNALPORT"))
.transform(stoi)
.pipe(portSchema),
next_port: portSchema
.optional()
.transform(getEnvOrYaml("SERVER_NEXTPORT"))
.transform(stoi)
.pipe(portSchema),
internal_hostname: z.string().transform((url) => url.toLowerCase()),
secure_cookies: z.boolean(),
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({
http_entrypoint: z.string(),
@@ -41,8 +79,17 @@ const environmentSchema = z.object({
prefer_wildcard_cert: z.boolean().optional()
}),
gerbil: z.object({
start_port: portSchema,
base_endpoint: z.string().transform((url) => url.toLowerCase()),
start_port: portSchema
.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(),
subnet_group: z.string(),
block_size: z.number().positive().gt(0),
@@ -71,8 +118,16 @@ const environmentSchema = z.object({
.optional(),
users: 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
.optional()
.transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD"))
.pipe(passwordSchema)
})
}),
flags: z
@@ -85,12 +140,18 @@ const environmentSchema = z.object({
});
export class Config {
private rawConfig!: z.infer<typeof environmentSchema>;
private rawConfig!: z.infer<typeof configSchema>;
constructor() {
this.loadConfig();
if (process.env.GENERATE_TRAEFIK_CONFIG === "true") {
this.createTraefikConfig();
}
}
public loadEnvironment() {}
public loadConfig() {
const loadConfig = (configPath: string) => {
try {
@@ -131,6 +192,9 @@ export class Config {
);
environment = loadConfig(configFilePath1);
} catch (error) {
console.log(
"See the docs for information about what to include in the configuration file: https://docs.fossorial.io/Pangolin/Configuration/config"
);
if (error instanceof Error) {
throw new Error(
`Error creating configuration file from example: ${
@@ -151,7 +215,7 @@ export class Config {
throw new Error("No configuration file found");
}
const parsedConfig = environmentSchema.safeParse(environment);
const parsedConfig = configSchema.safeParse(environment);
if (!parsedConfig.success) {
const errors = fromError(parsedConfig.error);
@@ -186,6 +250,8 @@ export class Config {
?.disable_user_create_org
? "true"
: "false";
process.env.RESOURCE_ACCESS_TOKEN_PARAM =
parsedConfig.data.server.resource_access_token_param;
this.rawConfig = parsedConfig.data;
}
@@ -197,6 +263,72 @@ export class Config {
public getBaseDomain(): string {
return this.rawConfig.app.base_domain;
}
private createTraefikConfig() {
try {
// check if traefik_config.yml and dynamic_config.yml exists in APP_PATH/traefik
const defaultTraefikConfigPath = path.join(
__DIRNAME,
"traefik_config.example.yml"
);
const defaultDynamicConfigPath = path.join(
__DIRNAME,
"dynamic_config.example.yml"
);
const traefikPath = path.join(APP_PATH, "traefik");
if (!fs.existsSync(traefikPath)) {
return;
}
// load default configs
let traefikConfig = fs.readFileSync(
defaultTraefikConfigPath,
"utf8"
);
let dynamicConfig = fs.readFileSync(
defaultDynamicConfigPath,
"utf8"
);
traefikConfig = traefikConfig
.split("{{.LetsEncryptEmail}}")
.join(this.rawConfig.users.server_admin.email);
traefikConfig = traefikConfig
.split("{{.INTERNAL_PORT}}")
.join(this.rawConfig.server.internal_port.toString());
dynamicConfig = dynamicConfig
.split("{{.DashboardDomain}}")
.join(new URL(this.rawConfig.app.dashboard_url).hostname);
dynamicConfig = dynamicConfig
.split("{{.NEXT_PORT}}")
.join(this.rawConfig.server.next_port.toString());
dynamicConfig = dynamicConfig
.split("{{.EXTERNAL_PORT}}")
.join(this.rawConfig.server.external_port.toString());
// write thiese to the traefik directory
const traefikConfigPath = path.join(
traefikPath,
"traefik_config.yml"
);
const dynamicConfigPath = path.join(
traefikPath,
"dynamic_config.yml"
);
fs.writeFileSync(traefikConfigPath, traefikConfig, "utf8");
fs.writeFileSync(dynamicConfigPath, dynamicConfig, "utf8");
console.log("Traefik configuration files created");
} catch (e) {
console.log(
"Failed to generate the Traefik configuration files. Please create them manually."
);
console.error(e);
}
}
}
export const config = new Config();

View File

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

View File

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

View File

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

View File

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

View File

@@ -158,7 +158,8 @@ export async function signup(
const token = generateSessionToken();
await createSession(token, userId);
const cookie = serializeSessionCookie(token);
const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(token, isSecure);
res.appendHeader("Set-Cookie", cookie);
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 db from "@server/db";
import {
ResourceAccessToken,
resourceAccessToken,
resourcePassword,
resourcePincode,
@@ -17,9 +18,15 @@ import {
} from "@server/db/schema";
import { and, eq } from "drizzle-orm";
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 logger from "@server/logger";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import { generateSessionToken } from "@server/auth";
const verifyResourceSessionSchema = z.object({
sessions: z.record(z.string()).optional(),
@@ -28,6 +35,7 @@ const verifyResourceSessionSchema = z.object({
host: z.string(),
path: z.string(),
method: z.string(),
accessToken: z.string().optional(),
tls: z.boolean()
});
@@ -59,7 +67,8 @@ export async function verifyResourceSession(
}
try {
const { sessions, host, originalRequestURL } = parsedBody.data;
const { sessions, host, originalRequestURL, accessToken: token } =
parsedBody.data;
const [result] = await db
.select()
@@ -103,11 +112,41 @@ export async function verifyResourceSession(
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) {
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
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");
return notAllowed(res, redirectUrl);
} catch (e) {
@@ -209,11 +258,41 @@ function allowed(res: Response) {
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(
user: User,
resource: Resource
): Promise<boolean> {
if (config.getRawConfig().flags?.require_email_verification && !user.emailVerified) {
if (
config.getRawConfig().flags?.require_email_verification &&
!user.emailVerified
) {
return false;
}

View File

@@ -50,7 +50,9 @@ export async function getConfig(req: Request, res: Response, next: NextFunction)
let exitNode;
if (exitNodeQuery.length === 0) {
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 = "";
if (config.getRawConfig().gerbil.use_subdomain) {
subEndpoint = await getUniqueExitNodeEndpointName();

View File

@@ -14,9 +14,7 @@ import {
} from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import logger from "@server/logger";
import { verify } from "@node-rs/argon2";
import { isWithinExpirationDate } from "oslo";
import { verifyPassword } from "@server/auth/password";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
const authWithAccessTokenBodySchema = z
.object({
@@ -69,58 +67,38 @@ export async function authWithAccessToken(
const { accessToken, accessTokenId } = parsedBody.data;
try {
const [result] = await db
const [resource] = await db
.select()
.from(resourceAccessToken)
.where(
and(
eq(resourceAccessToken.resourceId, resourceId),
eq(resourceAccessToken.accessTokenId, accessTokenId)
)
)
.leftJoin(
resources,
eq(resources.resourceId, resourceAccessToken.resourceId)
)
.from(resources)
.where(eq(resources.resourceId, resourceId))
.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) {
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) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token")
);
}
if (
tokenItem.expiresAt &&
!isWithinExpirationDate(new Date(tokenItem.expiresAt))
) {
if (!valid) {
return next(
createHttpError(
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(
createHttpError(
HttpCode.UNAUTHORIZED,
createHttpError(
HttpCode.BAD_REQUEST,
"Resource has no pincode protection"
)
"Resource has no pincode protection"
)
);
}
const validPincode = verifyPassword(
const validPincode = await verifyPassword(
pincode,
definedPincode.pincodeHash
);

View File

@@ -24,7 +24,7 @@ const createSiteParamsSchema = z
const createSiteSchema = z
.object({
name: z.string().min(1).max(255),
exitNodeId: z.number().int().positive(),
exitNodeId: z.number().int().positive().optional(),
// subdomain: z
// .string()
// .min(1)
@@ -32,7 +32,7 @@ const createSiteSchema = z
// .transform((val) => val.toLowerCase())
// .optional(),
pubKey: z.string().optional(),
subnet: z.string(),
subnet: z.string().optional(),
newtId: z.string().optional(),
secret: z.string().optional(),
type: z.string()
@@ -82,28 +82,46 @@ export async function createSite(
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) => {
const [newSite] = await trx
.insert(sites)
.values(payload)
.returning();
let newSite: Site;
if (exitNodeId) {
// 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
.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, {
publicKey: pubKey,
allowedIps: []

View File

@@ -123,88 +123,100 @@ export async function createTarget(
);
}
// make sure the target is within the site subnet
if (
site.type == "wireguard" &&
!isIpInCidr(targetData.ip, site.subnet!)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Target IP is not within the site subnet`
)
);
}
// Fetch resources for this site
const resourcesRes = await db.query.resources.findMany({
where: eq(resources.siteId, site.siteId)
});
// 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);
}
});
})
);
let internalPort!: number;
// pick a port
for (let i = 40000; i < 65535; i++) {
if (!targetInternalPorts.includes(i)) {
internalPort = i;
break;
let newTarget: Target[] = [];
if (site.type == "local") {
newTarget = await db
.insert(targets)
.values({
resourceId,
protocol: "tcp", // hard code for now
...targetData
})
.returning();
} else {
// make sure the target is within the site subnet
if (
site.type == "wireguard" &&
!isIpInCidr(targetData.ip, site.subnet!)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Target IP is not within the site subnet`
)
);
}
}
if (!internalPort) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`No available internal port`
)
// Fetch resources for this site
const resourcesRes = await db.query.resources.findMany({
where: eq(resources.siteId, site.siteId)
});
// 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
.insert(targets)
.values({
resourceId,
protocol: "tcp", // hard code for now
internalPort,
...targetData
})
.returning();
let internalPort!: number;
// pick a port
for (let i = 40000; i < 65535; i++) {
if (!targetInternalPorts.includes(i)) {
internalPort = i;
break;
}
}
// add the new target to the targetIps array
targetIps.push(`${targetData.ip}/32`);
if (!internalPort) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`No available internal port`
)
);
}
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);
newTarget = await db
.insert(targets)
.values({
resourceId,
protocol: "tcp", // hard code for now
internalPort,
...targetData
})
.returning();
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

@@ -56,6 +56,7 @@ export async function traefikConfigProvider(
config.getRawConfig().server.resource_session_cookie_name,
userSessionCookieName:
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

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

View File

@@ -4,11 +4,14 @@ import path from "path";
import semver from "semver";
import { versionMigrations } from "@server/db/schema";
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 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
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -17,7 +20,9 @@ import m3 from "./scripts/1.0.0-beta3";
const migrations = [
{ 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.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
] as const;

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);
await trx.update(users).set({ serverAdmin: false });
await db.insert(users).values({
userId: userId,
email: email,

View File

@@ -57,14 +57,22 @@ import {
CommandItem,
CommandList
} from "@app/components/ui/command";
import { CheckIcon } from "lucide-react";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { register } from "module";
import { Label } from "@app/components/ui/label";
import { Checkbox } from "@app/components/ui/checkbox";
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
import { constructShareLink } from "@app/lib/shareLinks";
import {
constructDirectShareLink,
constructShareLink
} from "@app/lib/shareLinks";
import { ShareLinkRow } from "./ShareLinksTable";
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from "@app/components/ui/collapsible";
type FormProps = {
open: boolean;
@@ -75,6 +83,7 @@ type FormProps = {
const formSchema = z.object({
resourceId: z.number({ message: "Please select a resource" }),
resourceName: z.string(),
resourceUrl: z.string(),
timeUnit: z.string(),
timeValue: z.coerce.number().int().positive().min(1),
title: z.string().optional()
@@ -88,14 +97,18 @@ export default function CreateShareLinkForm({
const { toast } = useToast();
const { org } = useOrgContext();
const api = createApiClient(useEnvContext());
const { env } = useEnvContext();
const api = createApiClient({ env });
const [link, setLink] = useState<string | null>(null);
const [directLink, setDirectLink] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [neverExpire, setNeverExpire] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [resources, setResources] = useState<
{ resourceId: number; name: string }[]
{ resourceId: number; name: string; resourceUrl: string }[]
>([]);
const timeUnits = [
@@ -139,7 +152,13 @@ export default function CreateShareLinkForm({
});
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
);
setLink(link);
const directLink = constructDirectShareLink(
env.server.resourceAccessTokenParam,
values.resourceUrl,
token.accessTokenId,
token.accessToken
);
setDirectLink(directLink);
onCreated?.({
accessTokenId: token.accessTokenId,
resourceId: token.resourceId,
@@ -306,6 +332,10 @@ export default function CreateShareLinkForm({
"resourceName",
r.name
);
form.setValue(
"resourceUrl",
r.resourceUrl
);
}}
>
<CheckIcon
@@ -462,12 +492,62 @@ export default function CreateShareLinkForm({
<QRCodeCanvas value={link} size={200} />
</div>
<div className="mx-auto">
<CopyTextBox
text={link}
wrapText={false}
/>
</div>
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className="space-y-2"
>
<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>

View File

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

View File

@@ -49,7 +49,7 @@ const createSiteFormSchema = z.object({
.max(30, {
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>;
@@ -79,17 +79,16 @@ export default function CreateSiteForm({
const [isLoading, setIsLoading] = useState(false);
const [isChecked, setIsChecked] = useState(false);
const router = useRouter();
const [keypair, setKeypair] = useState<{
publicKey: string;
privateKey: string;
} | null>(null);
const [siteDefaults, setSiteDefaults] =
useState<PickSiteDefaultsResponse | null>(null);
const handleCheckboxChange = (checked: boolean) => {
setChecked?.(checked);
// setChecked?.(checked);
setIsChecked(checked);
};
@@ -98,6 +97,17 @@ export default function CreateSiteForm({
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(() => {
if (!open) return;
@@ -114,11 +124,8 @@ export default function CreateSiteForm({
api.get(`/org/${orgId}/pick-site-defaults`)
.catch((e) => {
toast({
variant: "destructive",
title: "Error picking site defaults",
description: formatAxiosError(e)
});
// update the default value of the form to be local method
form.setValue("method", "local");
})
.then((res) => {
if (res && res.status === 200) {
@@ -130,24 +137,56 @@ export default function CreateSiteForm({
async function onSubmit(data: CreateSiteFormValues) {
setLoading?.(true);
setIsLoading(true);
if (!siteDefaults || !keypair) {
return;
}
let payload: CreateSiteBody = {
name: data.name,
subnet: siteDefaults.subnet,
exitNodeId: siteDefaults.exitNodeId,
pubKey: keypair.publicKey,
type: data.method
};
if (data.method === "newt") {
payload.secret = siteDefaults.newtSecret;
payload.newtId = siteDefaults.newtId;
if (data.method == "wireguard") {
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
.put<
AxiosResponse<CreateSiteResponse>
>(`/org/${orgId}/site/`, payload)
.put<AxiosResponse<CreateSiteResponse>>(
`/org/${orgId}/site/`,
payload
)
.catch((e) => {
toast({
variant: "destructive",
@@ -157,18 +196,20 @@ export default function CreateSiteForm({
});
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;
onCreate?.({
name: data.name,
id: data.siteId,
nice: data.niceId.toString(),
mbIn: "0 MB",
mbOut: "0 MB",
mbIn:
data.type == "wireguard" || data.type == "newt"
? "0 MB"
: "--",
mbOut:
data.type == "wireguard" || data.type == "newt"
? "0 MB"
: "--",
orgId: orgId as string,
type: data.type as any,
online: false
@@ -194,10 +235,10 @@ PersistentKeepalive = 5`
: "";
// am I at http or https?
let proto = "http:";
if (typeof window !== "undefined") {
proto = window.location.protocol;
}
let proto = "https:";
// if (typeof window !== "undefined") {
// proto = window.location.protocol;
// }
const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${proto}//${siteDefaults?.endpoint}`;
@@ -245,12 +286,21 @@ PersistentKeepalive = 5`
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="wireguard">
WireGuard
<SelectItem value="local">
Local
</SelectItem>
<SelectItem value="newt">
<SelectItem
value="newt"
disabled={!siteDefaults}
>
Newt
</SelectItem>
<SelectItem
value="wireguard"
disabled={!siteDefaults}
>
WireGuard
</SelectItem>
</SelectContent>
</Select>
</FormControl>
@@ -264,50 +314,76 @@ PersistentKeepalive = 5`
<div className="w-full">
{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" &&
isLoading ? (
<p>Loading WireGuard configuration...</p>
) : (
<CopyTextBox text={newtConfig} wrapText={false} />
)}
) : form.watch("method") === "newt" ? (
<>
<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>
<span className="text-sm text-muted-foreground">
You will only be able to see the configuration once.
</span>
{form.watch("method") === "newt" && (
<>
<br />
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.fossorial.io/Newt/install"
target="_blank"
rel="noopener noreferrer"
>
<span>
{" "}
Learn how to install Newt on your system
</span>
<SquareArrowOutUpRight size={14} />
</Link>
</>
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.fossorial.io/Newt/install"
target="_blank"
rel="noopener noreferrer"
>
<span>
{" "}
Learn how to install Newt on your system
</span>
<SquareArrowOutUpRight size={14} />
</Link>
)}
<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"
{form.watch("method") === "local" && (
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.fossorial.io/Pangolin/without-tunneling"
target="_blank"
rel="noopener noreferrer"
>
I have copied the config
</label>
</div>
<span>
{" "}
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>
</div>

View File

@@ -23,7 +23,7 @@ import { useState } from "react";
import CreateSiteForm from "./CreateSiteForm";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
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 { useEnvContext } from "@app/hooks/useEnvContext";
import CreateSiteFormModal from "./CreateSiteModal";
@@ -146,21 +146,27 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.online) {
return (
<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>
);
if (
originalRow.type == "newt" ||
originalRow.type == "wireguard"
) {
if (originalRow.online) {
return (
<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 {
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>
);
return <span>--</span>;
}
}
},
@@ -245,6 +251,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
</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) {
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 (
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">Site Information</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections>
<InfoSection>
<InfoSectionTitle>Status</InfoSectionTitle>
<InfoSectionContent>
{site.online ? (
<div 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>
</div>
) : (
<div 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>
</div>
)}
</InfoSectionContent>
</InfoSection>
<Separator orientation="vertical" />
{(site.type == "newt" || site.type == "wireguard") && (
<>
<InfoSection>
<InfoSectionTitle>Status</InfoSectionTitle>
<InfoSectionContent>
{site.online ? (
<div 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>
</div>
) : (
<div 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>
</div>
)}
</InfoSectionContent>
</InfoSection>
<Separator orientation="vertical" />
</>
)}
<InfoSection>
<InfoSectionTitle>Connection Type</InfoSectionTitle>
<InfoSectionContent>
{site.type === "newt"
? "Newt"
: site.type === "wireguard"
? "WireGuard"
: "Unknown"}
{getConnectionTypeString(site.type)}
</InfoSectionContent>
</InfoSection>
</InfoSections>

View File

@@ -23,7 +23,10 @@ export default async function SitesPage(props: SitesPageProps) {
sites = res.data.data.sites;
} 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) {
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
} else if (mb >= 1024) {
@@ -38,8 +41,8 @@ export default async function SitesPage(props: SitesPageProps) {
name: site.name,
id: site.siteId,
nice: site.niceId.toString(),
mbIn: formatSize(site.megabytesIn || 0),
mbOut: formatSize(site.megabytesOut || 0),
mbIn: formatSize(site.megabytesIn || 0, site.type),
mbOut: formatSize(site.megabytesOut || 0, site.type),
orgId: params.orgId,
type: site.type as any,
online: site.online

View File

@@ -30,6 +30,7 @@ export default function AccessToken({
redirectUrl
}: AccessTokenProps) {
const [loading, setLoading] = useState(true);
const [isValid, setIsValid] = useState(false);
const api = createApiClient(useEnvContext());
@@ -49,6 +50,7 @@ export default function AccessToken({
});
if (res.data.data.session) {
setIsValid(true);
window.location.href = redirectUrl;
}
} catch (e) {
@@ -61,24 +63,47 @@ export default function AccessToken({
check();
}, [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 ? (
<div></div>
) : (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center text-2xl font-bold">
Access URL Invalid
{renderTitle()}
</CardTitle>
</CardHeader>
<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>
<CardContent>{renderContent()}</CardContent>
</Card>
);
}

View File

@@ -45,11 +45,10 @@ export default async function ResourceAuthPage(props: {
const user = await getUser({ skipCheckVerifyEmail: true });
if (!authInfo) {
{
/* @ts-ignore */
} // TODO: fix this
// TODO: fix this
return (
<div className="w-full max-w-md">
{/* @ts-ignore */}
<ResourceNotFound />
</div>
);

View File

@@ -19,6 +19,7 @@ const buttonVariants = cva(
secondary:
"bg-secondary border border-input text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
text: "",
link: "text-primary underline-offset-4 hover:underline",
},
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 }

View File

@@ -6,7 +6,8 @@ export function pullEnv(): Env {
nextPort: process.env.NEXT_PORT as string,
externalPort: process.env.SERVER_EXTERNAL_PORT as string,
sessionCookieName: process.env.SESSION_COOKIE_NAME as string,
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: {
environment: process.env.ENVIRONMENT as string,

View File

@@ -1,3 +1,5 @@
import { pullEnv } from "./pullEnv";
export function constructShareLink(
resourceId: number,
id: string,
@@ -5,3 +7,12 @@ export function constructShareLink(
) {
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;
sessionCookieName: string;
resourceSessionCookieName: string;
resourceAccessTokenParam: string;
},
email: {
emailEnabled: boolean;