Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 46 additions & 17 deletions backend/lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ import fs from "node:fs";
import NodeRSA from "node-rsa";
import { global as logger } from "../logger.js";

const keysFile = '/data/keys.json';
const mysqlEngine = 'mysql2';
const postgresEngine = 'pg';
const sqliteClientName = 'better-sqlite3';
const keysFile = "/data/keys.json";
const mysqlEngine = "mysql2";
const postgresEngine = "pg";
const sqliteClientName = "better-sqlite3";

// Not used for new setups anymore but may exist in legacy setups
const legacySqliteClientName = 'sqlite3';
const legacySqliteClientName = "sqlite3";

let instance = null;

const toBool = (v) => /^(1|true|yes|on)$/i.test((v || "").trim());

// 1. Load from config file first (not recommended anymore)
// 2. Use config env variables next
const configure = () => {
Expand Down Expand Up @@ -40,14 +42,18 @@ const configure = () => {
}
}

const toBool = (v) => /^(1|true|yes|on)$/i.test((v || '').trim());

const envMysqlHost = process.env.DB_MYSQL_HOST || null;
const envMysqlUser = process.env.DB_MYSQL_USER || null;
const envMysqlName = process.env.DB_MYSQL_NAME || null;
const envMysqlSSL = toBool(process.env.DB_MYSQL_SSL);
const envMysqlSSLRejectUnauthorized = process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED === undefined ? true : toBool(process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED);
const envMysqlSSLVerifyIdentity = process.env.DB_MYSQL_SSL_VERIFY_IDENTITY === undefined ? true : toBool(process.env.DB_MYSQL_SSL_VERIFY_IDENTITY);
const envMysqlHost = process.env.DB_MYSQL_HOST || null;
const envMysqlUser = process.env.DB_MYSQL_USER || null;
const envMysqlName = process.env.DB_MYSQL_NAME || null;
const envMysqlSSL = toBool(process.env.DB_MYSQL_SSL);
const envMysqlSSLRejectUnauthorized =
process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED === undefined
? true
: toBool(process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED);
const envMysqlSSLVerifyIdentity =
process.env.DB_MYSQL_SSL_VERIFY_IDENTITY === undefined
? true
: toBool(process.env.DB_MYSQL_SSL_VERIFY_IDENTITY);
if (envMysqlHost && envMysqlUser && envMysqlName) {
// we have enough mysql creds to go with mysql
logger.info("Using MySQL configuration");
Expand All @@ -58,8 +64,10 @@ const configure = () => {
port: process.env.DB_MYSQL_PORT || 3306,
user: envMysqlUser,
password: process.env.DB_MYSQL_PASSWORD,
name: envMysqlName,
ssl: envMysqlSSL ? { rejectUnauthorized: envMysqlSSLRejectUnauthorized, verifyIdentity: envMysqlSSLVerifyIdentity } : false,
name: envMysqlName,
ssl: envMysqlSSL
? { rejectUnauthorized: envMysqlSSLRejectUnauthorized, verifyIdentity: envMysqlSSLVerifyIdentity }
: false,
},
keys: getKeys(),
};
Expand Down Expand Up @@ -222,7 +230,7 @@ const isDebugMode = () => !!process.env.DEBUG;
*
* @returns {boolean}
*/
const isCI = () => process.env.CI === 'true' && process.env.DEBUG === 'true';
const isCI = () => process.env.CI === "true" && process.env.DEBUG === "true";

/**
* Returns a public key
Expand All @@ -249,6 +257,14 @@ const getPrivateKey = () => {
*/
const useLetsencryptStaging = () => !!process.env.LE_STAGING;

/**
* Whether new hosts should default to the secure options in the UI
* (block exploits, websockets, force ssl, http/2)
*
* @returns {boolean}
*/
const isSecureDefaults = () => toBool(process.env.NPM_SECURE_DEFAULTS);

/**
* @returns {string|null}
*/
Expand All @@ -259,4 +275,17 @@ const useLetsencryptServer = () => {
return null;
};

export { isCI, configHas, configGet, isSqlite, isMysql, isPostgres, isDebugMode, getPrivateKey, getPublicKey, useLetsencryptStaging, useLetsencryptServer };
export {
isCI,
configHas,
configGet,
isSqlite,
isMysql,
isPostgres,
isDebugMode,
isSecureDefaults,
getPrivateKey,
getPublicKey,
useLetsencryptStaging,
useLetsencryptServer,
};
3 changes: 2 additions & 1 deletion backend/routes/main.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import express from "express";
import { isCI } from "../lib/config.js";
import { isCI, isSecureDefaults } from "../lib/config.js";
import errs from "../lib/error.js";
import logRequest from "../lib/express/log-request.js";
import pjson from "../package.json" with { type: "json" };
Expand Down Expand Up @@ -38,6 +38,7 @@ router.get("/", async (_, res /*, next*/) => {
res.status(200).send({
status: "OK",
setup,
secure_defaults: isSecureDefaults(),
version: {
major: Number.parseInt(version.shift(), 10),
minor: Number.parseInt(version.shift(), 10),
Expand Down
5 changes: 5 additions & 0 deletions backend/schema/components/health-object.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
"description": "Whether the initial setup has been completed",
"example": true
},
"secure_defaults": {
"type": "boolean",
"description": "Whether new hosts should default to the secure options in the UI",
"example": false
},
"version": {
"type": "object",
"description": "The version object",
Expand Down
1 change: 1 addition & 0 deletions backend/schema/paths/get.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"value": {
"status": "OK",
"setup": true,
"secure_defaults": false,
"version": {
"major": 2,
"minor": 1,
Expand Down
1 change: 1 addition & 0 deletions docker/docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ services:
DEBUG: "true"
DEVELOPMENT: "true"
LE_STAGING: "true"
NPM_SECURE_DEFAULTS: "true"
# db:
# DB_MYSQL_HOST: 'db'
# DB_MYSQL_PORT: '3306'
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/backend/responseTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface HealthResponse {
status: string;
version: AppVersion;
setup: boolean;
secureDefaults?: boolean;
}

export interface TokenResponse {
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/components/Form/SSLCertificateField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ interface Props {
required?: boolean;
allowNew?: boolean;
forHttp?: boolean; // the sslForced, http2Support, hstsEnabled, hstsSubdomains fields
secureDefaults?: boolean; // enable sslForced + http2Support when a cert is first selected
}
export function SSLCertificateField({
name = "certificateId",
Expand All @@ -41,14 +42,17 @@ export function SSLCertificateField({
required,
allowNew,
forHttp = true,
secureDefaults = false,
}: Props) {
const { locale } = useLocaleState();
const { isLoading, isError, error, data } = useCertificates();
const { values, setFieldValue } = useFormikContext();
const { values, setFieldValue, setFieldTouched } = useFormikContext();
const v: any = values || {};

const handleChange = (newValue: any, _actionMeta: ActionMeta<CertOption>) => {
setFieldValue(name, newValue?.value);
// A manual selection (including "None") must never be overridden by auto-matching
setFieldTouched(name, true, false);
const {
sslForced,
http2Support,
Expand All @@ -65,6 +69,10 @@ export function SSLCertificateField({
hstsEnabled && setFieldValue("hstsEnabled", false);
hstsSubdomains && setFieldValue("hstsSubdomains", false);
}
if (forHttp && newValue?.value && !v[name] && secureDefaults) {
setFieldValue("sslForced", true);
setFieldValue("http2Support", true);
}
if (newValue?.value !== "new") {
dnsChallenge && setFieldValue("dnsChallenge", undefined);
dnsProvider && setFieldValue("dnsProvider", undefined);
Expand Down Expand Up @@ -116,7 +124,7 @@ export function SSLCertificateField({
<Select
className="react-select-container"
classNamePrefix="react-select"
defaultValue={options.find((o) => o.value === field.value) || options[0]}
value={options.find((o) => o.value === field.value) || options[0]}
options={options}
components={{ Option }}
styles={{
Expand Down
45 changes: 39 additions & 6 deletions frontend/src/modals/ProxyHostModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { IconSettings } from "@tabler/icons-react";
import cn from "classnames";
import EasyModal, { type InnerModalProps } from "ez-modal-react";
import { Field, Form, Formik } from "formik";
import { type ReactNode, useState } from "react";
import { type ReactNode, useRef, useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import {
Expand All @@ -16,8 +16,9 @@ import {
SSLCertificateField,
SSLOptionsFields,
} from "src/components";
import { useProxyHost, useSetProxyHost, useUser } from "src/hooks";
import { useCertificates, useHealth, useProxyHost, useSetProxyHost, useUser } from "src/hooks";
import { T } from "src/locale";
import { matchCertificate } from "src/modules/CertificateMatch";
import { MANAGE, PROXY_HOSTS } from "src/modules/Permissions";
import { validateNumber, validateString } from "src/modules/Validations";
import { showObjectSuccess } from "src/notifications";
Expand All @@ -32,7 +33,13 @@ interface Props extends InnerModalProps {
const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
const { data: currentUser, isLoading: userIsLoading, error: userError } = useUser("me");
const { data, isLoading, error } = useProxyHost(id);
const { data: certificates } = useCertificates();
const { data: health } = useHealth();
// NPM_SECURE_DEFAULTS only ever changes NEW hosts, never edits of existing ones
const secureDefaults = (health?.secureDefaults && id === "new") || false;
const { mutate: setProxyHost } = useSetProxyHost();
// certificateId set by domain auto-matching (as opposed to picked by the user)
const autoPickedCertId = useRef(0);
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);

Expand Down Expand Up @@ -78,8 +85,9 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
forwardPort: data?.forwardPort || undefined,
accessListId: data?.accessListId || 0,
cachingEnabled: data?.cachingEnabled || false,
blockExploits: data?.blockExploits || false,
allowWebsocketUpgrade: data?.allowWebsocketUpgrade || false,
blockExploits: id === "new" ? secureDefaults : data?.blockExploits || false,
allowWebsocketUpgrade:
id === "new" ? secureDefaults : data?.allowWebsocketUpgrade || false,
// Locations tab
locations: data?.locations || [],
// SSL tab
Expand All @@ -96,7 +104,7 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
}
onSubmit={onSubmit}
>
{() => (
{({ values, setFieldValue, touched }: any) => (
<Form>
<Modal.Header closeButton>
<Modal.Title>
Expand Down Expand Up @@ -163,7 +171,31 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
<div className="card-body">
<div className="tab-content">
<div className="tab-pane active show" id="tab-details" role="tabpanel">
<DomainNamesField isWildcardPermitted dnsProviderWildcardSupported />
<DomainNamesField
isWildcardPermitted
dnsProviderWildcardSupported
onChange={(domains) => {
// Auto-pick a certificate covering the typed domains — only
// on new hosts, and never override the user's own choice
// (touched covers an explicit "None", which is falsy 0)
if (id !== "new" || touched.certificateId) return;
const current = values.certificateId;
if (current && current !== autoPickedCertId.current)
return;
const match = matchCertificate(
certificates || [],
domains,
);
const nextId = match?.id || 0;
if (nextId === current) return;
setFieldValue("certificateId", nextId);
autoPickedCertId.current = nextId;
if (secureDefaults) {
setFieldValue("sslForced", !!match);
setFieldValue("http2Support", !!match);
}
}}
/>
<div className="row">
<div className="col-md-3">
<Field name="forwardScheme">
Expand Down Expand Up @@ -339,6 +371,7 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
name="certificateId"
label="ssl-certificate"
allowNew
secureDefaults={secureDefaults}
/>
<SSLOptionsFields color="bg-lime" forProxyHost={true} />
</div>
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/modules/CertificateMatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { Certificate } from "src/api/backend";
import { matchCertificate } from "./CertificateMatch";

const cert = (id: number, ...domainNames: string[]) => ({ id, domainNames }) as Certificate;

describe("matchCertificate", () => {
const wildcard = cert(1, "*.grasfer.app");
const exact = cert(2, "pbs.grasfer.app");
const other = cert(3, "example.com", "www.example.com");

it("matches a wildcard cert one label deep", () => {
expect(matchCertificate([wildcard, other], ["whatever.grasfer.app"])?.id).toEqual(1);
});

it("prefers an exact match over a wildcard", () => {
expect(matchCertificate([wildcard, exact], ["pbs.grasfer.app"])?.id).toEqual(2);
expect(matchCertificate([exact, wildcard], ["pbs.grasfer.app"])?.id).toEqual(2);
});

it("does not match a wildcard two labels deep", () => {
expect(matchCertificate([wildcard], ["a.b.grasfer.app"])).toBeUndefined();
});

it("does not match the bare wildcard base domain", () => {
expect(matchCertificate([wildcard], ["grasfer.app"])).toBeUndefined();
});

it("requires the cert to cover every typed domain", () => {
// covering only one of two aliases would break TLS for the other
expect(matchCertificate([other], ["nope.org", "www.example.com"])).toBeUndefined();
expect(matchCertificate([wildcard], ["a.grasfer.app", "b.example.net"])).toBeUndefined();
});

it("matches when all domains are covered", () => {
expect(matchCertificate([other], ["example.com", "www.example.com"])?.id).toEqual(3);
expect(matchCertificate([wildcard], ["a.grasfer.app", "b.grasfer.app"])?.id).toEqual(1);
});

it("prefers a cert covering all domains exactly over a wildcard covering all", () => {
const both = cert(4, "a.grasfer.app", "b.grasfer.app");
expect(matchCertificate([wildcard, both], ["a.grasfer.app", "b.grasfer.app"])?.id).toEqual(4);
});

it("is case insensitive", () => {
expect(matchCertificate([other], ["WWW.Example.COM"])?.id).toEqual(3);
});

it("returns undefined when nothing matches or input is empty", () => {
expect(matchCertificate([wildcard, exact, other], ["unrelated.net"])).toBeUndefined();
expect(matchCertificate([], ["whatever.grasfer.app"])).toBeUndefined();
expect(matchCertificate([wildcard], [])).toBeUndefined();
});
});
Loading