mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-29 06:08:15 +02:00
Merge pull request #39 from fosrl/dev
local sites and direct share links
This commit is contained in:
commit
302ac2e644
36 changed files with 915 additions and 326 deletions
|
@ -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)
|
- [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"/>
|
||||||
|
|
|
@ -12,6 +12,7 @@ server:
|
||||||
secure_cookies: false
|
secure_cookies: false
|
||||||
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
|
||||||
|
|
|
@ -9,9 +9,10 @@ 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
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
services:
|
services:
|
||||||
pangolin:
|
pangolin:
|
||||||
image: fosrl/pangolin:latest
|
image: fosrl/pangolin:{{.PangolinVersion}}
|
||||||
container_name: pangolin
|
container_name: pangolin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
|
@ -11,8 +11,9 @@ services:
|
||||||
timeout: "3s"
|
timeout: "3s"
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
{{if .InstallGerbil}}
|
||||||
gerbil:
|
gerbil:
|
||||||
image: fosrl/gerbil:latest
|
image: fosrl/gerbil:{{.GerbilVersion}}
|
||||||
container_name: gerbil
|
container_name: gerbil
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
|
@ -32,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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
@ -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=
|
|
@ -10,27 +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.5"
|
||||||
|
config.GerbilVersion = "1.0.0-beta.1"
|
||||||
|
}
|
||||||
|
|
||||||
//go:embed fs/*
|
//go:embed fs/*
|
||||||
var configFiles embed.FS
|
var configFiles embed.FS
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
BaseDomain string `yaml:"baseDomain"`
|
PangolinVersion string
|
||||||
DashboardDomain string `yaml:"dashboardUrl"`
|
GerbilVersion string
|
||||||
LetsEncryptEmail string `yaml:"letsEncryptEmail"`
|
BaseDomain string
|
||||||
AdminUserEmail string `yaml:"adminUserEmail"`
|
DashboardDomain string
|
||||||
AdminUserPassword string `yaml:"adminUserPassword"`
|
LetsEncryptEmail string
|
||||||
DisableSignupWithoutInvite bool `yaml:"disableSignupWithoutInvite"`
|
AdminUserEmail string
|
||||||
DisableUserCreateOrg bool `yaml:"disableUserCreateOrg"`
|
AdminUserPassword string
|
||||||
EnableEmail bool `yaml:"enableEmail"`
|
DisableSignupWithoutInvite bool
|
||||||
EmailSMTPHost string `yaml:"emailSMTPHost"`
|
DisableUserCreateOrg bool
|
||||||
EmailSMTPPort int `yaml:"emailSMTPPort"`
|
EnableEmail bool
|
||||||
EmailSMTPUser string `yaml:"emailSMTPUser"`
|
EmailSMTPHost string
|
||||||
EmailSMTPPass string `yaml:"emailSMTPPass"`
|
EmailSMTPPort int
|
||||||
EmailNoReply string `yaml:"emailNoReply"`
|
EmailSMTPUser string
|
||||||
|
EmailSMTPPass string
|
||||||
|
EmailNoReply string
|
||||||
|
InstallGerbil bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
@ -45,13 +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)
|
||||||
|
|
||||||
|
loadVersions(&config)
|
||||||
|
|
||||||
if err := createConfigFiles(config); err != nil {
|
if err := createConfigFiles(config); err != nil {
|
||||||
fmt.Printf("Error creating config files: %v\n", err)
|
fmt.Printf("Error creating config files: %v\n", err)
|
||||||
os.Exit(1)
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -82,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 {
|
||||||
|
@ -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.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.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.BaseDomain)
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -302,13 +342,6 @@ func createConfigFiles(config Config) error {
|
||||||
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")
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@fosrl/pangolin",
|
"name": "@fosrl/pangolin",
|
||||||
"version": "1.0.0-beta.4",
|
"version": "1.0.0-beta.5",
|
||||||
"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",
|
||||||
|
|
45
server/auth/canUserAccessResource.ts
Normal file
45
server/auth/canUserAccessResource.ts
Normal 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;
|
||||||
|
}
|
67
server/auth/verifyResourceAccessToken.ts
Normal file
67
server/auth/verifyResourceAccessToken.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -32,7 +32,8 @@ const environmentSchema = z.object({
|
||||||
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()
|
||||||
}),
|
}),
|
||||||
traefik: z.object({
|
traefik: z.object({
|
||||||
http_entrypoint: z.string(),
|
http_entrypoint: z.string(),
|
||||||
|
@ -186,6 +187,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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()
|
||||||
|
@ -103,11 +112,41 @@ export async function verifyResourceSession(
|
||||||
|
|
||||||
const redirectUrl = `${config.getRawConfig().app.dashboard_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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: []
|
||||||
|
|
|
@ -123,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,11 +4,13 @@ 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 m2 from "./scripts/1.0.0-beta2";
|
||||||
import m3 from "./scripts/1.0.0-beta3";
|
import m3 from "./scripts/1.0.0-beta3";
|
||||||
|
import m4 from "./scripts/1.0.0-beta5";
|
||||||
|
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
|
||||||
|
@ -17,7 +19,8 @@ import m3 from "./scripts/1.0.0-beta3";
|
||||||
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.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 }
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
42
server/setup/scripts/1.0.0-beta5.ts
Normal file
42
server/setup/scripts/1.0.0-beta5.ts
Normal 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.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");
|
||||||
|
|
||||||
|
console.log("Done.");
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,54 @@ 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,
|
||||||
|
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 +194,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
|
||||||
|
@ -245,12 +284,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 +312,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>
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,11 +45,10 @@ 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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
11
src/components/ui/collapsible.tsx
Normal file
11
src/components/ui/collapsible.tsx
Normal 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 }
|
|
@ -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,
|
||||||
|
|
|
@ -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}`;
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue