diff --git a/README.md b/README.md
index a1829db4..657e7994 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/config/config.example.yml b/config/config.example.yml
index 827a2c49..69a0e06e 100644
--- a/config/config.example.yml
+++ b/config/config.example.yml
@@ -12,6 +12,7 @@ server:
secure_cookies: false
session_cookie_name: p_session
resource_session_cookie_name: p_resource_session
+ resource_access_token_param: p_token
traefik:
cert_resolver: letsencrypt
diff --git a/install/fs/config.yml b/install/fs/config.yml
index 21a8c0ff..985b8b62 100644
--- a/install/fs/config.yml
+++ b/install/fs/config.yml
@@ -9,9 +9,10 @@ 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
traefik:
cert_resolver: letsencrypt
diff --git a/install/fs/docker-compose.yml b/install/fs/docker-compose.yml
index 47fd82f8..ab6528d0 100644
--- a/install/fs/docker-compose.yml
+++ b/install/fs/docker-compose.yml
@@ -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
diff --git a/install/fs/traefik/traefik_config.yml b/install/fs/traefik/traefik_config.yml
index c83cc8c4..de104a2f 100644
--- a/install/fs/traefik/traefik_config.yml
+++ b/install/fs/traefik/traefik_config.yml
@@ -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"
diff --git a/install/go.mod b/install/go.mod
index 3de61fa9..85cf49e4 100644
--- a/install/go.mod
+++ b/install/go.mod
@@ -1,3 +1,8 @@
module installer
-go 1.23.0
\ No newline at end of file
+go 1.23.0
+
+require (
+ golang.org/x/sys v0.29.0 // indirect
+ golang.org/x/term v0.28.0 // indirect
+)
diff --git a/install/go.sum b/install/go.sum
index e69de29b..f05f63b4 100644
--- a/install/go.sum
+++ b/install/go.sum
@@ -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=
diff --git a/install/main.go b/install/main.go
index 480ff934..ae598033 100644
--- a/install/main.go
+++ b/install/main.go
@@ -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.5"
+ config.GerbilVersion = "1.0.0-beta.1"
+}
+
//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")
diff --git a/package.json b/package.json
index 14e87d68..5b1b25b0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@fosrl/pangolin",
- "version": "1.0.0-beta.4",
+ "version": "1.0.0-beta.5",
"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",
diff --git a/server/auth/canUserAccessResource.ts b/server/auth/canUserAccessResource.ts
new file mode 100644
index 00000000..bdafaa0d
--- /dev/null
+++ b/server/auth/canUserAccessResource.ts
@@ -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 {
+ 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;
+}
diff --git a/server/auth/verifyResourceAccessToken.ts b/server/auth/verifyResourceAccessToken.ts
new file mode 100644
index 00000000..ce74952b
--- /dev/null
+++ b/server/auth/verifyResourceAccessToken.ts
@@ -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
+ };
+}
diff --git a/server/db/index.ts b/server/db/index.ts
index 12b6ed10..5dc1360a 100644
--- a/server/db/index.ts
+++ b/server/db/index.ts
@@ -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 {
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 });
+ }
+}
diff --git a/server/lib/config.ts b/server/lib/config.ts
index 203a6441..d480892b 100644
--- a/server/lib/config.ts
+++ b/server/lib/config.ts
@@ -32,7 +32,8 @@ const environmentSchema = z.object({
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()
}),
traefik: z.object({
http_entrypoint: z.string(),
@@ -186,6 +187,7 @@ 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;
}
diff --git a/server/middlewares/verifyAccessTokenAccess.ts b/server/middlewares/verifyAccessTokenAccess.ts
index 2854c46c..3b7e3121 100644
--- a/server/middlewares/verifyAccessTokenAccess.ts
+++ b/server/middlewares/verifyAccessTokenAccess.ts
@@ -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,
diff --git a/server/middlewares/verifyTargetAccess.ts b/server/middlewares/verifyTargetAccess.ts
index 003e5e8d..865e7e5e 100644
--- a/server/middlewares/verifyTargetAccess.ts
+++ b/server/middlewares/verifyTargetAccess.ts
@@ -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,
diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts
index 459219c5..0593e3b4 100644
--- a/server/routers/badger/verifySession.ts
+++ b/server/routers/badger/verifySession.ts
@@ -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(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(res, {
+ data: { valid: true },
+ success: true,
+ error: false,
+ message: "Access allowed",
+ status: HttpCode.OK
+ });
+}
+
async function isUserAllowedToAccessResource(
user: User,
resource: Resource
): Promise {
- if (config.getRawConfig().flags?.require_email_verification && !user.emailVerified) {
+ if (
+ config.getRawConfig().flags?.require_email_verification &&
+ !user.emailVerified
+ ) {
return false;
}
diff --git a/server/routers/resource/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts
index fdc4b254..a4340f77 100644
--- a/server/routers/resource/authWithAccessToken.ts
+++ b/server/routers/resource/authWithAccessToken.ts
@@ -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"
)
);
}
diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts
index 1151feac..9a5e6f46 100644
--- a/server/routers/site/createSite.ts
+++ b/server/routers/site/createSite.ts
@@ -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: []
diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts
index e7ae3aca..1376ab0a 100644
--- a/server/routers/target/createTarget.ts
+++ b/server/routers/target/createTarget.ts
@@ -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);
+ }
}
}
diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts
index 708fa413..fb775ac4 100644
--- a/server/routers/traefik/getTraefikConfig.ts
+++ b/server/routers/traefik/getTraefikConfig.ts
@@ -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}`,
+ },
+ ],
+ },
+ };
}
}
diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts
index c0fe6216..4e0d77ca 100644
--- a/server/setup/migrations.ts
+++ b/server/setup/migrations.ts
@@ -4,11 +4,13 @@ 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 { existsSync, mkdirSync } from "fs";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -17,7 +19,8 @@ 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 }
// Add new migrations here as they are created
] as const;
diff --git a/server/setup/scripts/1.0.0-beta5.ts b/server/setup/scripts/1.0.0-beta5.ts
new file mode 100644
index 00000000..1fe6db49
--- /dev/null
+++ b/server/setup/scripts/1.0.0-beta5.ts
@@ -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.");
+}
diff --git a/server/setup/setupServerAdmin.ts b/server/setup/setupServerAdmin.ts
index 54d38338..70faff86 100644
--- a/server/setup/setupServerAdmin.ts
+++ b/server/setup/setupServerAdmin.ts
@@ -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,
diff --git a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx
index 64c4d319..1bbf6e78 100644
--- a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx
+++ b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx
@@ -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(null);
+ const [directLink, setDirectLink] = useState(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
+ );
}}
>
-
-
-
+
+
+
+
+
+
+
+
+
+
+ {directLink && (
+
+
+
+
+
+ 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.
+
+
+ )}
+
+
)}
diff --git a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx
index 505a85c5..451bec9f 100644
--- a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx
+++ b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx
@@ -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({
-
-