Compare commits

...

15 Commits

Author SHA1 Message Date
wmantly 3686a5ddb8 Merge pull request #182 from theta42/release/1.5.0
Release 1.5.0
2026-07-27 14:18:27 -04:00
wmantly ce013d7e31 Release 1.5.0: adopt @simpleworkjs/frontend messages/modal/validate
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:16:47 -04:00
wmantly f6bc38eef2 Merge pull request #181 from theta42/modernize/simpleworkjs-frontend
Adopt @simpleworkjs/frontend messages/modal/validate modules
2026-07-27 14:10:13 -04:00
wmantly 0a659428dd Adopt @simpleworkjs/frontend's messages/modal/validate modules
Same swap as sso-manager-node/jump-host: vendored app.util.actionMessage/
actionConfirm replaced by @simpleworkjs/frontend's app.messages.action/
confirm (real HTML-escaping, toast fallback); vendored val.js replaced by
the package's app.validate.js.

proxy's host/target/hostname validation rules (mirrored from the backend's
utils/hostname_validate.js — wildcard DNS patterns, not something other
apps need) move to public/js/app.js, registered via $.validateSettings,
since they're proxy-specific and don't belong in the shared package's
generic rule set (eq/user/password/ip).

app.api/app.auth/app.pubsub/app.socket in app-base.js are untouched, same
reasoning as sso-manager-node's PR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:07:14 -04:00
wmantly e770bbb41f Merge pull request #180 from theta42/docs/screenshots-refresh
docs: refresh proxy screenshots for the unified UI
2026-07-26 16:31:50 -04:00
wmantly 90619dd4ff docs: refresh proxy screenshots for the unified UI
hosts.png, host-auth-sso.png and host-auth-basic.png still showed the
pre-unification nav; replaced with the current shared shell.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 16:28:44 -04:00
wmantly 75045b4b33 Merge pull request #178 from theta42/feature/ui-unification
Release 1.4.0: unified front-end UI shell
2026-07-26 00:30:08 -04:00
wmantly 2e959f7c48 Release 1.4.0: unified front-end UI shell across the theta42 apps
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:21:51 -04:00
wmantly fcc09d1e4b logInRedirect: keep the query string on the legacy /login/<path> form
The OIDC provider sends an unauthenticated authorize request through
/login/oauth/authorize?client_id=…&state=…; dropping the query there
loses the whole authorization request. The ?redirect= form is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:55:08 -04:00
wmantly a83a5fd39a app.api.delete: accept the (url, data, callback) form formAJAX uses; defer the login-card reveal to DOM ready
formAJAX always passes the serialized form as the second argument, so a
DELETE-method form (the host/DNS delete buttons) landed its callback in
the data slot and never ran.

The login page's "reveal the card once we know you're logged out" branch
touched an element further down the same page, which threw when
isLoggedIn answered before the parser got there (it always did without a
stored token). It now runs on DOM ready.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:15:07 -04:00
wmantly 3a307c1563 Unify the front-end UI shell across the theta42 apps
views/top.ejs, views/bottom.ejs and public/lib/js/app-base.js are now
byte-identical across sso-manager-node, proxy and jump-host. Everything
per-app moved into utils/ui.js, exposed to every render as `ui` via
app.locals (nav items + their group gates, footer repo/docs/ToS links,
favicon, profile/logout targets, update-banner on/off + label).

Client framework changes:
- One gating model everywhere: app-base.js reveals .group-required-<cn>
  for each of the current user user/me groups. sso-manager-node sends LDAP
  DNs in memberOf, the OIDC clients send CNs in groups; both normalise to
  CNs, and the clients isAdmin flag becomes a synthetic `admin` group, so
  proxy nav-admin items are now group-required-admin.
- user/me is fetched once per page load and cached (app.auth.loadUser);
  nav, forceLogin and group-required elements all read that one promise.
- isLoggedIn is dual-mode (Promise + node-style callback), so the async
  and callback call styles both work from one shared top.ejs.
- forceLogin no longer uses $.holdReady (removed in jQuery 4): it redirects
  to /login?redirect=<path>, and still enforces required groups.
- logOut only clears the session; the caller decides where to go next.
- post/put/delete are dual-mode Promise/callback, which also removes the
  undefined `callback2` reference that threw on a non-function callback.

Dependencies: jquery ^4.0.0 and ejs ^3.1.10 in all three apps.

proxy specifics:
- .group-required base rule added to styles.css; the admin nav items lost
  their inline display:none in favour of it.
- The brand link points at / instead of #.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:57:34 -04:00
wmantly f055d4f567 Merge pull request #177 from theta42/release/v1.3.0
Release 1.3.0
2026-07-25 16:38:47 -04:00
wmantly f4efdfb957 Release 1.3.0: adopt shared @simpleworkjs/* packages; fix LDAP filter injection
Rewire onto the shared @simpleworkjs/oidc-client, /ldap, and /app-stack
packages (deleting the byte-identical local forks of the same code), close the
LDAP filter-injection in User.get by routing the username through escapeFilter
(RFC 4515), align model-redis ^1.6.0 and ldapts ^8.1.8, and unify build_info to
{buildVersion, buildHash, buildYear}. package-lock regenerated from the npm
registry (no file:/link:), so npm ci is clean in docker builds.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-25 15:53:30 -04:00
wmantly 60dbfe5b9b Merge pull request #176 from theta42/release/v1.2.2
Release v1.2.2: fix load-balancing crash (wrong lua-resty-balancer module)
2026-07-21 16:22:32 -04:00
wmantly ecdced16fc Release 1.2.2: fix load-balancing crash (wrong lua-resty-balancer module)
Every request to a host with additional load-balancing targets 500'd:
targetinfo.lua required 'resty.balancer.round_robin', which does not
exist in the lua-resty-balancer rock actually installed by the
Dockerfile/install.sh. That rock provides resty.roundrobin instead,
with a different constructor (roundrobin:new(nodes), not
:new() + :reinit(nodes)).

Verified end-to-end in a rebuilt image: requests to a load-balanced
host now return 200 and alternate across both backend targets, with
no Lua errors in the OpenResty log.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 16:21:03 -04:00
38 changed files with 742 additions and 1008 deletions
+48
View File
@@ -6,6 +6,54 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased] ## [Unreleased]
## [1.5.0] - 2026-07-27
### Changed
- **Adopted `@simpleworkjs/frontend`'s `app.messages`, `app.modal`, and `app.validate` modules**, replacing the vendored `app.util.actionMessage`/`actionConfirm` in `public/lib/js/app-base.js` and the vendored `public/lib/js/val.js`. Message content is now HTML-escaped, and `app.messages.action` falls back to a page-wide toast when there's no inline `.actionMessage` target. proxy's `host`/`target`/`hostname` wildcard-DNS validation rules (mirroring `utils/hostname_validate.js`) moved to `public/js/app.js`, registered via `$.validateSettings`, since they're proxy-specific and don't belong in the shared package's generic rule set. `app.api`/`app.auth`/`app.pubsub`/`app.socket` are untouched.
## [1.4.0] - 2026-07-26
### Changed
- **Unified the front-end UI shell across the three theta42 apps.** `views/top.ejs`, `views/bottom.ejs` and `public/lib/js/app-base.js` are now byte-identical in sso-manager-node, proxy and jump-host, so the apps look and behave the same and a shell change lands in one edit per repo instead of three divergent ones. Everything that differs between the apps moved into a new `nodejs/utils/ui.js`, exposed to every render as `ui` via `app.locals`: nav items and the groups that may see them, footer repo/license/docs/Terms links, favicon, the profile and post-logout targets, and whether the update banner exists at all.
- **One nav-gating model everywhere.** `app-base.js` reveals `.group-required-<cn>` elements for each group the current user is in, read from `GET /api/user/me`. sso-manager-node reports LDAP DNs in `memberOf` and the OIDC clients report CNs in `groups`; both normalise to CNs client-side, and the clients' effective-rights `isAdmin` flag is exposed as a synthetic `admin` group — so one gating model covers a group-based provider and boolean-admin clients without either app learning the other's response shape.
- **`GET /api/user/me` is fetched once per page load and cached** (`app.auth.loadUser`). The nav, per-view `forceLogin` and every group-gated element read that one promise instead of issuing their own request.
- `app.auth.isLoggedIn` is dual-mode: it returns a Promise **and** invokes an optional node-style callback, so the async and callback call styles both work against one shared `top.ejs`.
- `app.auth.forceLogin` no longer uses `$.holdReady` (removed in jQuery 4). An unauthenticated user is redirected to `/login?redirect=<path>`; group requirements are still enforced, and `logOut` now only clears the session, leaving the destination to the caller (`ui.logoutRedirect`).
- Dependency alignment across all three apps: `jquery` `^4.0.0` and `ejs` `^3.1.10`.
### Fixed
- **`app.api.delete` dropped its callback when called by `formAJAX`.** `formAJAX` always passes the serialized form as the second argument, so a DELETE-method form's callback landed in the data slot and never ran. `delete` now accepts both `(url, callback)` and `(url, data, callback)`.
- **`app.api.post`/`put` referenced an undefined `callback2`** and threw when handed a non-function callback. Both are now dual-mode Promise/callback.
- **The login page's "reveal the card once we know you're logged out" branch threw** (`Cannot read properties of null`) whenever the logged-in check answered before the parser reached that element — which it always did without a stored token. It now runs on DOM ready.
- **`logInRedirect` on the legacy `/login/<path>` form kept only the path.** The OIDC provider routes an unauthenticated authorization request through `/login/oauth/authorize?client_id=…&state=…`; dropping the query there loses the entire authorization request. The suffix form now preserves its query string.
### Added
- `.group-required { display: none }` in `public/css/styles.css`, the base rule the shared gating model reveals against.
- Admin-only nav items lost their inline `display: none` in favour of that class, and the brand link points at `/` instead of `#`.
### Verified
- Browser-verified against a full theta-env stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
## [1.3.0] - 2026-07-25
### Added
- Adopted the shared `@simpleworkjs/*` packages published under the simpleworkjs org, replacing this app's byte-identical forks of the same code so the theta42 apps share one codebase and API schema:
- `@simpleworkjs/oidc-client` — the OIDC client (session models, auth router, OIDC utils, safe-redirect, local-admin bootstrap). Deleted the local `utils/oidc.js`, `utils/safe_redirect.js`, `models/oidc_state.js`, `models/token.js`, `models/auth.js`, and `routes/auth.js`; `models/index.js` now wires the factory. The per-host SSO in `routes/host_auth.js` is unchanged but consumes the shared OIDC utils.
- `@simpleworkjs/ldap` — the ldapts client + RFC 4515/4514 escaping.
- `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `utils/build_info.js` and the static-modules loop in `routes/render.js` now use the shared helpers.
### Security
- **LDAP filter injection in `User.get`.** The user lookup built its search filter by interpolating `data.username` raw into `(&(objectClass=inetOrgPerson)(uid=<username>))`. A username containing `*`, `(`, `)`, `\`, or NUL could widen or alter the filter (e.g. `*` → match-all). The filter value is now passed through `escapeFilter` from `@simpleworkjs/ldap` (RFC 4515 escaping).
### Changed
- Dependency alignment: `model-redis` `^1.5``^1.6.0`, `ldapts` `^8.1.2``^8.1.8`. The four new `@simpleworkjs/*` deps resolve from the npm registry (`^1.0.0`); no `file:`/`link:` entries in the lockfile, so `npm ci` is clean in docker builds.
- `build_info` export shape changed from `{commit, version}` to `{buildVersion, buildHash, buildYear}` (the shared shape used by all three apps). The `/health` endpoint and footer now report `buildVersion`/`buildHash`.
## [1.2.2] - 2026-07-21
### Fixed
- Multi-target load balancing (added in 1.2.0) crashed every request to a load-balanced host: `ops/nginx_conf/targetinfo.lua` required a nonexistent `resty.balancer.round_robin` module. The `lua-resty-balancer` rock installed by the Dockerfile/`install.sh` doesn't provide that path — it provides `resty.roundrobin` (constructed as `roundrobin:new(nodes)`, not `:new()` + `:reinit(nodes)`). Fixed `targetinfo.lua` to use the real module and API; verified end-to-end that requests now round-robin across targets with no Lua errors.
## [1.2.1] - 2026-07-21 ## [1.2.1] - 2026-07-21
### Fixed ### Fixed
Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

After

Width:  |  Height:  |  Size: 310 KiB

+5
View File
@@ -69,6 +69,11 @@ app.use(express.json());
app.set('views', path.join(__dirname, 'views')); app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs'); app.set('view engine', 'ejs');
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
// Set as an app local so every res.render has it, including routes that don't
// spread the routers' `values` object.
app.locals.ui = require('./utils/ui');
// Per-host SSO endpoints. nginx routes /__proxy_auth/* on every proxied host to // Per-host SSO endpoints. nginx routes /__proxy_auth/* on every proxied host to
// the app (see ops/nginx_conf/proxy.conf); these run the OIDC flow and set the // the app (see ops/nginx_conf/proxy.conf); these run the OIDC flow and set the
// per-host session cookie. Mounted before the page router. // per-host session cookie. Mounted before the page router.
+1 -1
View File
@@ -1,6 +1,6 @@
'use strict'; 'use strict';
const {Auth} = require('../models/auth'); const {Auth} = require('../models');
async function auth(req, res, next){ async function auth(req, res, next){
try{ try{
-136
View File
@@ -1,136 +0,0 @@
'use strict';
const Table = require('../models');
const {User, AuthToken} = Table.models;
const {ApiToken} = require('./api_token');
/**
* Auth Model
*
* Handles authentication operations for the application.
* Manages user login, token validation, and logout processes.
*
* Dependencies:
* - User model: Validates user credentials
* - AuthToken model: Creates and manages authentication tokens
*
* All methods throw standardized login errors on failure to avoid
* leaking information about whether usernames exist or tokens are valid.
*/
class Auth{
/**
* Standardized error responses for authentication failures.
* Returns generic "Invalid Credentials" message for security.
*/
static errors = {
login: function(){
let error = new Error('LoginFailed');
error.name = 'LoginFailed';
error.message = `Invalid Credentials, login failed.`;
error.status = 401;
return error;
}
}
/**
* Authenticate user and create session token.
*
* @param {Object} data - Login credentials {username, password}
* @returns {Object} {user, token} - User object and auth token
* @throws {Error} Generic login error on any failure
*
* Flow:
* 1. Validate credentials via User.login()
* 2. Create new AuthToken for the user
* 3. Return both user data and token
*/
static async login(data){
try{
let user = await User.login(data);
// Backends may attach group membership to the user (LDAP); default
// to none for local/redis users.
let groups = Array.isArray(user.groups) ? user.groups : [];
let token = await AuthToken.create({username: user.username, groups});
return {user, token}
}catch(error){
console.log('login error', error);
throw this.errors.login();
}
}
/**
* Establish a session for an OIDC-authenticated identity: JIT-provision the
* local user (redis-backed) and mint an AuthToken carrying the SSO groups.
*
* @param {Object} identity - {username, groups} from utils/oidc claims
* @returns {Object} {user, token}
*/
static async oidcSession(identity){
let user = typeof User.upsertOidc === 'function'
? await User.upsertOidc(identity)
: await User.get(identity.username);
let token = await AuthToken.create({
username: user.username,
groups: identity.groups || [],
});
return {user, token};
}
/**
* Validate an authentication token.
*
* @param {string} token - Token string to validate
* @returns {Object} Token object if valid
* @throws {Error} Generic login error if token invalid or expired
*
* Checks:
* 1. Token exists in database
* 2. Token has not expired (via token.check())
*/
static async checkToken(token){
try{
token = await AuthToken.get(token);
if(token && token.check()) return token;
throw this.errors.login();
}catch(error){
console.log('check error', error);
throw this.errors.login();
}
}
/**
* Validate an `Authorization: Bearer prx_<id>_<secret>` API token.
*
* Returns the authenticated ApiToken record (with created_by + the
* mint-time groups snapshot); middleware/auth.js wraps it into the
* req.token shape the authz layer expects. Every failure collapses to the
* same generic login 401 — no leak of existence / wrong secret / expired.
*/
static async checkApiToken(raw){
try{
return await ApiToken.authenticate(raw);
}catch(error){
console.log('api-token check error', error);
throw this.errors.login();
}
}
/**
* Destroy an authentication token (logout).
*
* @param {string} data - Token string to destroy
* @returns {void}
*
* Removes token from database, invalidating the session.
*/
static async logout(data){
let token = await AuthToken.get(data);
await token.destroy();
}
}
module.exports = {Auth};
+23 -4
View File
@@ -1,18 +1,37 @@
'use strict'; 'use strict';
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const {setUpTable} = require('model-redis'); const {setUpTable} = require('model-redis');
const {createOidcClient, bootstrapLocalAdmin} = require('@simpleworkjs/oidc-client');
const Table = setUpTable(conf.redis); const Table = setUpTable(conf.redis);
module.exports = Table; module.exports = Table;
// App-local models. User + ApiToken register before the OIDC client factory
// below: Auth binds User, and checkApiToken wraps ApiToken.authenticate.
require('./user'); // User (redis-backed local + OIDC JIT)
const {ApiToken} = require('./api_token'); // ApiToken (Bearer PATs)
require('./dns_provider'); require('./dns_provider');
require('./dynamic_record'); require('./dynamic_record');
require('./host'); require('./host');
require('./token');
require('./user');
require('./local_group'); require('./local_group');
require('./permission'); require('./permission');
require('./oidc_state');
require('./sso_session'); require('./sso_session');
require('./api_token');
// Shared OIDC client (authorization-code + PKCE): session models (Token,
// AuthToken, OidcState), the Auth service, and the /login /logout /oidc/start
// /oidc/callback router — all created on this app's Table/redis. PAT validation
// is wired in (proxy accepts Bearer prx_<id>_<secret>); the package collapses
// every checkApiToken failure to a generic 401.
const oidcClient = createOidcClient({
Table,
checkApiToken: (raw) => ApiToken.authenticate(raw),
});
module.exports.Token = oidcClient.Token;
module.exports.AuthToken = oidcClient.AuthToken;
module.exports.OidcState = oidcClient.OidcState;
module.exports.Auth = oidcClient.Auth;
module.exports.authRouter = oidcClient.router;
// Idempotent anti-lockout local admin (was the IIFE in user_redis.js).
bootstrapLocalAdmin(Table.models.User, {defaultName: 'proxyadmin2'});
-31
View File
@@ -1,31 +0,0 @@
'use strict';
const Table = require('.');
/**
* OidcState
*
* Short-lived store for an in-flight OpenID Connect authorization request.
* Keyed by the random `state` value; holds the PKCE `code_verifier` and the
* post-login redirect target until the SSO calls us back.
*
* The record auto-expires via model-redis per-key TTL (static _ttl), so an
* abandoned login attempt leaves nothing behind and there is no cleanup job.
*/
class OidcState extends Table{
static _key = 'state';
// Auth round-trips are quick; 5 minutes is plenty and bounds replay.
static _ttl = 300;
static _keyMap = {
'created_on': {default: function(){return (new Date).getTime()}},
'state': {isRequired: true, type: 'string', min: 8, max: 500},
'codeVerifier': {isRequired: true, type: 'string', min: 8, max: 500},
'redirect': {default: '/', isRequired: false, type: 'string'},
}
}
OidcState.register();
module.exports = {OidcState};
-64
View File
@@ -1,64 +0,0 @@
'use strict';
const Table = require('.');
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)};
class Token extends Table{
static _key = 'token';
static _keyMap = {
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
'created_on': {default: function(){return (new Date).getTime()}},
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
'token': {default: UUID, type: 'string', min: 36, max: 36, isPrivate: true},
'is_valid': {default: true, type: 'boolean'},
}
constructor(...args){
super(...args);
}
async check(){
try{
return this.is_valid;
}catch(error){
return false
}
}
}
Token.register();
class AuthToken extends Token{
static _keyMap = {
...super._keyMap,
user: {model: 'User', rel: 'one', localKey: 'created_by'},
// Group memberships captured at login (OIDC `groups` claim or LDAP
// group membership), stored as a JSON string. Drives authorization for
// the life of the session without re-querying the IdP on every request.
groups: {default: '[]', isRequired: false, type: 'string'},
}
static async create(data){
data.created_by = data.username;
if(Array.isArray(data.groups)){
data.groups = JSON.stringify(data.groups);
}
return super.create(data)
}
// Parse the stored groups JSON back into an array, tolerating bad/missing
// data so authorization never crashes on a malformed token.
groupsArray(){
try{
let parsed = JSON.parse(this.groups);
return Array.isArray(parsed) ? parsed : [];
}catch(error){
return [];
}
}
}
AuthToken.register();
module.exports = {Token, AuthToken};
+4 -2
View File
@@ -1,8 +1,8 @@
'use strict'; 'use strict';
const { Client, Attribute, Change } = require('ldapts'); const { Client, Attribute, Change } = require('ldapts');
const {Token} = require('./token');
const conf = require('@simpleworkjs/conf').ldap; const conf = require('@simpleworkjs/conf').ldap;
const { escapeFilter } = require('@simpleworkjs/ldap');
// tlsOptions is optional and forwarded to ldapts so the proxy can bind to // tlsOptions is optional and forwarded to ldapts so the proxy can bind to
// ldaps:// with a self-signed or internal-CA cert. Set via conf/secrets.js or // ldaps:// with a self-signed or internal-CA cert. Set via conf/secrets.js or
@@ -107,7 +107,9 @@ User.get = async function(data){
await client.bind(conf.bindDN, conf.bindPassword); await client.bind(conf.bindDN, conf.bindPassword);
let filter = `(&${conf.userFilter}(${conf.userNameAttribute}=${data.username}))`; // Escape the interpolated username (RFC 4515) — previously raw, which
// let `*`/`(`/`)`/`\`/NUL in a username break or broaden the filter.
let filter = `(&${conf.userFilter}(${conf.userNameAttribute}=${escapeFilter(data.username)}))`;
const res = await client.search(conf.searchBase, { const res = await client.search(conf.searchBase, {
scope: 'sub', scope: 'sub',
-1
View File
@@ -2,7 +2,6 @@
const linuxUser = require('linux-sys-user').promise(); const linuxUser = require('linux-sys-user').promise();
const objValidate = require('../utils/object_validate'); const objValidate = require('../utils/object_validate');
const {Token} = require('./token');
const {promisify} = require('util'); const {promisify} = require('util');
const pam = require('authenticate-pam'); const pam = require('authenticate-pam');
const authenticate = promisify(pam.authenticate); const authenticate = promisify(pam.authenticate);
+3 -37
View File
@@ -3,7 +3,6 @@
const Table = require('.'); const Table = require('.');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const crypto = require('crypto'); const crypto = require('crypto');
const conf = require('@simpleworkjs/conf');
const saltRounds = 10; const saltRounds = 10;
class User extends Table{ class User extends Table{
@@ -86,39 +85,6 @@ class User extends Table{
User.register(); User.register();
(async function(){ // Anti-lockout local-admin bootstrap moved to @simpleworkjs/oidc-client
// Matches migrations/permission_bootstrap.js: the anti-lockout account is // (bootstrapLocalAdmin); invoked once from models/index.js after User is
// the first entry in conf.auth.adminUsers (default 'proxyadmin2'), NOT a // registered. See the package lib/bootstrap.js for the original logic.
// hardcoded name -- otherwise an operator who customizes adminUsers ends
// up with a bootstrap account that has no admin permissions.
var defaultUser = (conf.auth && conf.auth.adminUsers && conf.auth.adminUsers[0]) || 'proxyadmin2';
// Optional: an orchestrator (e.g. theta-env's setup.sh) can set
// auth.localAdminPass in proxy-secrets.js to a generated password so this
// bootstrap account isn't left at a well-known default. Only used on first
// creation -- once the account exists this is never read again, so it's
// safe to leave set. If unset, a random password is generated and printed
// once; save it from the log or set auth.localAdminPass explicitly.
var defaultPass = (conf.auth && conf.auth.localAdminPass);
if (!defaultPass) {
defaultPass = crypto.randomBytes(16).toString('hex');
console.warn(`====================================================================`);
console.warn(`Bootstrap admin "${defaultUser}" created with random password:`);
console.warn(`${defaultPass}`);
console.warn(`Set auth.localAdminPass in your secrets file to make this deterministic.`);
console.warn(`====================================================================`);
}
try{
let user = await User.get(defaultUser);
}catch(error){
try{
let user = await User.create({
username:defaultUser,
password: defaultPass,
created_by: defaultUser
});
console.log(defaultUser, 'created');
}catch(error){
console.error(error)
}
}
})();
+130 -11
View File
@@ -1,23 +1,27 @@
{ {
"name": "proxy-api", "name": "proxy-api",
"version": "1.2.1", "version": "1.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxy-api", "name": "proxy-api",
"version": "1.2.1", "version": "1.4.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/frontend": "^0.2.5",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/oidc-client": "^1.0.0",
"acme-client": "^5.4.0", "acme-client": "^5.4.0",
"axios": "^1.13.5", "axios": "^1.13.5",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"compression": "^1.8.1", "compression": "^1.8.1",
"ejs": "^6.0.1", "ejs": "^3.1.10",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"extend": "^3.0.2", "extend": "^3.0.2",
@@ -26,7 +30,7 @@
"ldapts": "^8.1.8", "ldapts": "^8.1.8",
"linux-sys-user": "^1.2.0", "linux-sys-user": "^1.2.0",
"marked": "^9.1.6", "marked": "^9.1.6",
"model-redis": "^1.5.0", "model-redis": "^1.6.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"mustache": "^4.2.0", "mustache": "^4.2.0",
"p2psub": "^0.2.0", "p2psub": "^0.2.0",
@@ -281,6 +285,18 @@
"@redis/client": "^6.1.0" "@redis/client": "^6.1.0"
} }
}, },
"node_modules/@simpleworkjs/app-stack": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/app-stack/-/app-stack-1.0.0.tgz",
"integrity": "sha512-Hg/mouA87WruKeZqhqtJgAaLabjHY8Z9POO6U+DB7sGGDhy1jgZXT31hyxLUDV+InByOPhz48NIkGiWNwoesXQ==",
"license": "MIT",
"dependencies": {
"express": "^5.2.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/conf": { "node_modules/@simpleworkjs/conf": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
@@ -293,6 +309,41 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/@simpleworkjs/frontend": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.5.tgz",
"integrity": "sha512-PxR7UVPv3gRpdF0WsuAZplF1vYvKsEJQevVPhz9d72U+69vP/OH3tlaAXjtO/apMHfhT1viOPw2gMVOrPSxYZw==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/ldap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz",
"integrity": "sha512-saDmwk+KJ6kIWj9/MF37d+BM9KQisy6DsI9umyt1FWNyx6+wnEEat/1RUTwXKBd4IKJK+zPT5lC/B6gfa2CuAA==",
"license": "MIT",
"dependencies": {
"ldapts": "^8.1.8"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/oidc-client": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/oidc-client/-/oidc-client-1.0.0.tgz",
"integrity": "sha512-AzxIaE32p4yKDlp0mWvZp1wmXi8tMFckcPwMiQyZrDgEC0IybGhbjppPa+vNGx1AoVLp64vRL/zR3yXb/19NPg==",
"license": "MIT",
"dependencies": {
"@simpleworkjs/conf": "^1.2.0",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@socket.io/component-emitter": { "node_modules/@socket.io/component-emitter": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
@@ -395,6 +446,12 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -791,15 +848,18 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/ejs": { "node_modules/ejs": {
"version": "6.0.1", "version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
"integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==", "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": {
"jake": "^10.8.5"
},
"bin": { "bin": {
"ejs": "bin/cli.js" "ejs": "bin/cli.js"
}, },
"engines": { "engines": {
"node": ">=0.12.18" "node": ">=0.10.0"
} }
}, },
"node_modules/encodeurl": { "node_modules/encodeurl": {
@@ -1011,6 +1071,42 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/filelist": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
"integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.0.1"
}
},
"node_modules/filelist/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/filelist/node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/filelist/node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -1387,6 +1483,23 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/jake": {
"version": "10.9.4",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
"license": "Apache-2.0",
"dependencies": {
"async": "^3.2.6",
"filelist": "^1.0.4",
"picocolors": "^1.1.1"
},
"bin": {
"jake": "bin/cli.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jq-repeat": { "node_modules/jq-repeat": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.0.tgz", "resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.0.tgz",
@@ -1508,9 +1621,9 @@
} }
}, },
"node_modules/model-redis": { "node_modules/model-redis": {
"version": "1.5.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/model-redis/-/model-redis-1.5.0.tgz", "resolved": "https://registry.npmjs.org/model-redis/-/model-redis-1.6.0.tgz",
"integrity": "sha512-eVXQQN+k3cR5aJBvPQurgr8WXYpAvVoLu6ydMWenSOJZBDVcHqeNqUCp8n5lYrxv6iZ8PlF0WQzZFmbJ1zP6/A==", "integrity": "sha512-QinykZ0H9vhyj0qY9NnNd1m1YcBXBXuc0viENJuEyTwplhOWT0cpjpGxObqoD1ysb8AJkrBkJB9tAf4EZbISWA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"redis": "^6.1.0" "redis": "^6.1.0"
@@ -1693,6 +1806,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+7 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxy-api", "name": "proxy-api",
"version": "1.2.1", "version": "1.5.0",
"author": [ "author": [
{ {
"name": "William Mantly", "name": "William Mantly",
@@ -21,13 +21,17 @@
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/frontend": "^0.2.5",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/oidc-client": "^1.0.0",
"acme-client": "^5.4.0", "acme-client": "^5.4.0",
"axios": "^1.13.5", "axios": "^1.13.5",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"compression": "^1.8.1", "compression": "^1.8.1",
"ejs": "^6.0.1", "ejs": "^3.1.10",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"extend": "^3.0.2", "extend": "^3.0.2",
@@ -36,7 +40,7 @@
"ldapts": "^8.1.8", "ldapts": "^8.1.8",
"linux-sys-user": "^1.2.0", "linux-sys-user": "^1.2.0",
"marked": "^9.1.6", "marked": "^9.1.6",
"model-redis": "^1.5.0", "model-redis": "^1.6.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"mustache": "^4.2.0", "mustache": "^4.2.0",
"p2psub": "^0.2.0", "p2psub": "^0.2.0",
+4
View File
@@ -18,3 +18,7 @@ body {
.card-title{ .card-title{
font-weight: bold; font-weight: bold;
} }
.group-required{
display: none;
}
+69
View File
@@ -91,3 +91,72 @@ app.apiToken = (function(app){
return {list, get, add, update, remove, rotate}; return {list, get, add, update, remove, rotate};
})(app); })(app);
// Host / target validation, mirrored from the backend (utils/hostname_validate.js):
// a bare hostname or IPv4 address, no protocol / "/" / ":" / whitespace. The
// incoming host may be a wildcard ("*.example.com"); the target may not.
// Proxy-specific, so it's registered here (via @simpleworkjs/frontend's
// $.validateSettings) rather than in the shared package's generic rule set.
(function(){
var LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
// Either one bare label (Docker service names, /etc/hosts entries) or a
// dotted hostname with an alphabetic TLD.
var HOSTNAME = /^(?=.{1,253}$)(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/i;
var FORBIDDEN = /[\s/:]/;
function isIPv4( value ) {
var parts = value.split( '.' );
if ( parts.length !== 4 ) return false;
return parts.every( function( p ) {
return /^(0|[1-9]\d{0,2})$/.test( p ) && Number( p ) <= 255;
});
}
// Incoming-host pattern: labels may be normal, "*" (one fragment), or "**"
// (any number of fragments, incl. a bare "**" global catch-all).
function isHostPattern( value ) {
if ( value.length > 253 ) return false;
return value.split( '.' ).every( function( l ) {
return l === '*' || l === '**' || LABEL.test( l );
});
}
function forbidden( value ) {
return FORBIDDEN.test( value ) || value.includes( '://' );
}
// Incoming host: IPv4 or a wildcard host pattern.
function checkHost( value ) {
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
if ( isIPv4( value ) || isHostPattern( value ) ) return;
return "Enter a valid host or wildcard (*, **)";
}
// Downstream target: IPv4 or a strict hostname, no wildcard.
function checkTarget( value ) {
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
if ( isIPv4( value ) || HOSTNAME.test( value ) ) return;
return "Enter a valid hostname or IP";
}
$.validateSettings({
rule:{
// Incoming host name — hostname, IPv4, or wildcard pattern (*, **).
host: function( value ) {
return checkHost( value );
},
// Downstream target — hostname or IPv4, no wildcard.
target: function( value ) {
return checkTarget( value );
},
// Back-compat alias (no wildcard).
hostname: function( value ) {
return checkTarget( value );
},
}
});
})();
+261 -104
View File
@@ -1,3 +1,12 @@
// Shared client framework for the theta42 apps.
//
// This file is byte-identical across sso-manager-node, proxy and jump-host —
// per-app behaviour comes from the server (the `ui` locals in views/top.ejs and
// the /api/user/me response), never from edits to this file. Edit all three
// copies together.
//
// jQuery 4 safe: no $.isFunction, no $.holdReady.
var app = {}; var app = {};
app.pubsub = (function(){ app.pubsub = (function(){
@@ -45,7 +54,7 @@ app.pubsub = (function(){
app.socket = (function(app){ app.socket = (function(app){
// $.getScript('/socket.io/socket.io.js') // $.getScript('/socket.io/socket.io.js')
// <script type="text/javascript" src="/socket.io/socket.io.js"></script> // <script type="text/javascript" src="/socket.io/socket.io.js"></script>
var socket; var socket;
$(document).ready(function(){ $(document).ready(function(){
socket = io({ socket = io({
@@ -75,10 +84,26 @@ app.socket = (function(app){
app.api = (function(app){ app.api = (function(app){
var baseURL = '/api/' var baseURL = '/api/'
function post(url, data, callback){ // post/put/delete are dual-mode: pass a callback for the node-style
if(typeof callback !== 'function') callback = callback2; // (error, data, status) form, or omit it to get a Promise that resolves
// with the parsed body and rejects with the error body. get/options return
// the jqXHR, which is itself thenable, so `await app.api.get(...)` works.
function body(method, url, data, callback){
if(typeof callback !== 'function'){
return new Promise(function(resolve, reject){
$.ajax({
type: method,
url: baseURL+url,
headers: { 'auth-token': app.auth.getToken() },
data: JSON.stringify(data),
contentType: 'application/json; charset=utf-8',
dataType: 'json',
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
});
}
return $.ajax({ return $.ajax({
type: 'POST', type: method,
url: baseURL+url, url: baseURL+url,
headers:{ headers:{
'auth-token': app.auth.getToken() 'auth-token': app.auth.getToken()
@@ -87,40 +112,44 @@ app.api = (function(app){
contentType: "application/json; charset=utf-8", contentType: "application/json; charset=utf-8",
dataType: "json", dataType: "json",
complete: function(res, text){ complete: function(res, text){
callback ? callback( callback(
text !== 'success' ? res.statusText : null, text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText), JSON.parse(res.responseText),
res.status res.status
) : function(){} );
} }
}); });
} }
function post(url, data, callback){
return body('POST', url, data, callback);
}
function put(url, data, callback){ function put(url, data, callback){
if(typeof callback !== 'function') callback = callback2; return body('PUT', url, data, callback);
return $.ajax({
type: 'PUT',
url: baseURL+url,
headers:{
'auth-token': app.auth.getToken()
},
data: JSON.stringify(data),
contentType: "application/json; charset=utf-8",
dataType: "json",
complete: function(res, text){
callback ? callback(
text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText),
res.status
) : function(){}
}
});
} }
function remove(url, callback, callback2){ // Called both as (url, callback) and — from formAJAX, which always passes
if(typeof callback !== 'function') callback = callback2; // the serialized form as the second argument — as (url, data, callback).
// No request body is sent either way.
function remove(url, data, callback){
if(typeof data === 'function'){
callback = data;
data = undefined;
}
if(typeof callback !== 'function'){
return new Promise(function(resolve, reject){
$.ajax({
type: 'DELETE',
url: baseURL+url,
headers: { 'auth-token': app.auth.getToken() },
contentType: 'application/json; charset=utf-8',
dataType: 'json',
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
});
}
return $.ajax({ return $.ajax({
type: 'delete', type: 'DELETE',
url: baseURL+url, url: baseURL+url,
headers:{ headers:{
'auth-token': app.auth.getToken() 'auth-token': app.auth.getToken()
@@ -128,11 +157,11 @@ app.api = (function(app){
contentType: "application/json; charset=utf-8", contentType: "application/json; charset=utf-8",
dataType: "json", dataType: "json",
complete: function(res, text){ complete: function(res, text){
callback ? callback( callback(
text !== 'success' ? res.statusText : null, text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText), JSON.parse(res.responseText),
res.status res.status
) : function(){} );
} }
}); });
} }
@@ -179,7 +208,11 @@ app.api = (function(app){
})(app) })(app)
app.auth = (function(app){ app.auth = (function(app){
var user = {} // One in-flight/cached GET /api/user/me per page load. Every gating
// decision (nav items, per-view forceLogin, group-required elements) reads
// this same promise instead of re-fetching.
var userPromise = null;
function setToken(token){ function setToken(token){
localStorage.setItem('APIToken', token); localStorage.setItem('APIToken', token);
} }
@@ -188,18 +221,95 @@ app.auth = (function(app){
return localStorage.getItem('APIToken'); return localStorage.getItem('APIToken');
} }
function isLoggedIn(callback){ async function getUser(){
if(getToken()){ try{
return app.api.get('user/me', function(error, data){ return await app.api.get('user/me');
// data now carries effective rights (isAdmin, global, domains). }catch(error){
if(!error) app.auth.user = app.auth.perms = data; if(error && error.status === 401) return null;
return callback(error, data); throw error;
});
}else{
callback(null, false);
} }
} }
// Cached current user, or false when there's no token at all. Callers that
// need a fresh copy (after a login or a profile change) pass force.
function loadUser(force){
if(force || !userPromise){
userPromise = getToken() ? getUser() : Promise.resolve(null);
userPromise = userPromise.then(function(user){
app.auth.user = app.auth.perms = user || null;
return user;
});
}
return userPromise;
}
// The apps report group membership two ways: sso-manager-node returns LDAP
// DNs in `memberOf`, the OIDC clients return plain CNs in `groups`. Both
// normalise to a list of CNs. `isAdmin` (the clients' effective-rights flag)
// is exposed as a synthetic `admin` group so one gating model covers both.
function groupCNs(user){
var raw = (user && (user.memberOf || user.groups)) || [];
if(!Array.isArray(raw)) raw = [raw];
var names = raw.map(function(group){
return String(group).split(',')[0].replace(/^cn=/i, '');
});
if(user && user.isAdmin && names.indexOf('admin') === -1) names.push('admin');
return names;
}
async function memberOf(groupNameToFind, user){
user = user || await loadUser();
if(!user) return false;
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind];
return groupCNs(user).some(function(group){
return groupNameToFind.includes(group);
});
}
// True when the logged-in user is a global admin (per user/me). Sync — only
// meaningful once isLoggedIn/forceLogin has resolved.
function isAdmin(){
return !!(app.auth.perms && app.auth.perms.isAdmin);
}
// Dual-mode: returns a Promise resolving to the user (or false), and calls
// an optional node-style callback with the same result.
function isLoggedIn(callback){
var promise = loadUser().then(function(user){
return user || false;
});
if(typeof callback === 'function'){
promise.then(function(user){
callback(null, user);
}, function(error){
callback(error, false);
});
}
return promise;
}
function logIn(args, callback){
app.api.post('auth/login', args, function(error, data){
if(data.login){
setToken(data.token);
}
loadUser(true);
callback(error, !!data.token);
});
}
// Clears the session only — the caller decides where to go next (the nav's
// Log Out button uses ui.logoutRedirect).
function logOut(callback){
localStorage.removeItem('APIToken');
userPromise = null;
app.auth.user = app.auth.perms = null;
if(typeof callback === 'function') callback();
}
// Constrain a redirect target to a same-origin absolute path. Rejects // Constrain a redirect target to a same-origin absolute path. Rejects
// absolute URLs (open redirect), protocol-relative "//host" and "/\host", // absolute URLs (open redirect), protocol-relative "//host" and "/\host",
// and non-path schemes like "javascript:" (XSS). Falls back to "/". // and non-path schemes like "javascript:" (XSS). Falls back to "/".
@@ -230,46 +340,68 @@ app.auth = (function(app){
return true; return true;
} }
// True when the logged-in user is a global admin (per user/me). // Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or
function isAdmin(){ // unauthorised user is kept off the page by a redirect / an error panel
return !!(app.auth.perms && app.auth.perms.isAdmin); // rather than by pausing document ready.
} //
// `requiredGroups` is a group CN or an OR-list of them; the synthetic
function logIn(args, callback){ // `admin` group covers the OIDC clients' isAdmin flag.
app.api.post('auth/login', args, function(error, data){ async function forceLogin(requiredGroups){
if(data.login){ var user = await loadUser();
setToken(data.token);
} if(!user){
callback(error, !!data.token); logOut(function(){});
}); location.replace('/login?redirect=' + encodeURIComponent(
} location.pathname + location.search
));
function logOut(callback){ return false;
localStorage.removeItem('APIToken'); }
callback();
} if(user.onboardingRequired && location.pathname !== '/onboarding'){
location.replace('/onboarding');
function forceLogin(){ return false;
// jQuery 4 removed $.holdReady; rely on the redirect below to keep an }
// unauthenticated user off the page instead of pausing document ready.
app.auth.isLoggedIn(function(error, isLoggedIn){ if(requiredGroups && !await memberOf(requiredGroups, user)){
if(error || !isLoggedIn){ app.messages.action(
app.auth.logOut(function(){}) `<h1>
location.replace(`/login${location.href.replace(location.origin, '')}`); <i class="fa-solid fa-triangle-exclamation"></i>
} <b>You do not have permission to be here.</b>
}); <i class="fa-solid fa-triangle-exclamation"></i>
</h1>`,
$('#spa-shell'),
'danger',
);
throw new Error("User does not have permission");
}
return user;
} }
// Where to go after a successful login: the ?redirect= query param, or the
// legacy /login/<path> suffix form, constrained to a same-origin path. The
// suffix form keeps its query string — /login/oauth/authorize?client_id=…
// is how the OIDC provider sends an unauthenticated user through login.
function logInRedirect(){ function logInRedirect(){
window.location.href = safeInternalPath(location.href.replace(location.origin+'/login', '') || '/') var params = new URLSearchParams(location.search);
var target = params.get('redirect')
|| location.href.replace(location.origin + '/login', '')
|| '/';
window.location.href = safeInternalPath(target);
} }
return { return {
getToken: getToken, getToken: getToken,
setToken: setToken, setToken: setToken,
isLoggedIn: isLoggedIn, getUser: getUser,
consumeTokenFragment: consumeTokenFragment, loadUser: loadUser,
groupCNs: groupCNs,
memberOf: memberOf,
isAdmin: isAdmin, isAdmin: isAdmin,
isLoggedIn: isLoggedIn,
safeInternalPath: safeInternalPath,
consumeTokenFragment: consumeTokenFragment,
user: null,
perms: null, perms: null,
logIn: logIn, logIn: logIn,
logOut: logOut, logOut: logOut,
@@ -279,6 +411,11 @@ app.auth = (function(app){
})(app); })(app);
// Back-compat alias for views that awaited the cached user directly.
Object.defineProperty(app.auth, 'asyncUser', {
get: function(){ return app.auth.loadUser(); },
});
app.user = (function(app){ app.user = (function(app){
function list(callback){ function list(callback){
app.api.get('user/?detail=true', function(error, data){ app.api.get('user/?detail=true', function(error, data){
@@ -308,6 +445,8 @@ app.user = (function(app){
})(app); })(app);
// Local (app-managed) permissions and groups. Only the OIDC-client apps serve
// these endpoints; the calls are inert elsewhere.
app.permission = (function(app){ app.permission = (function(app){
function list(callback){ function list(callback){
app.api.get('permission/', function(error, data){ app.api.get('permission/', function(error, data){
@@ -381,29 +520,15 @@ app.util = (function(app){
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
}; };
function actionMessage(message, $target, type, callback){ // escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
message = message || ''; // app.util.escapeHtml and app.messages.action/confirm.
$target = $target.closest('div.card').find('.actionMessage'); function escapeHtml(s){
type = type || 'info'; return String(s == null ? '' : s)
callback = callback || function(){}; .replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
if($target.html() === message) return; .replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
if($target.html()){ .replace(/'/g, '&#39;');
$target.slideUp('fast', function(){
$target.html('')
$target.removeClass (function(index, className){
return (className.match (/(^|\s)bg-\S+/g) || []).join(' ');
});
if(message) return actionMessage(message, $target, type, callback);
$target.hide()
})
}else{
if(type) $target.addClass('bg-' + type);
message = '<span class="align-middle">' + message + '</span><button class="action-close btn btn-sm btn-outline-dark float-end"><i class="fa-solid fa-xmark"></i></button>'
$target.html(message).slideDown('fast');
}
setTimeout(callback,10)
} }
$.fn.serializeObject = function() { $.fn.serializeObject = function() {
@@ -462,11 +587,41 @@ app.util = (function(app){
return { return {
downloadFile: downloadFile, downloadFile: downloadFile,
getUrlParameter: getUrlParameter, getUrlParameter: getUrlParameter,
actionMessage: actionMessage escapeHtml: escapeHtml,
} }
})(app); })(app);
$( document ).ready(function(){ // Reveal every .group-required-<cn> element the current user's groups entitle
// them to. Elements carrying .group-required start hidden (styles.css), so a
// user who is in no groups — or who isn't logged in — simply never sees them.
app.auth.applyGroupVisibility = function(user){
var groups = app.auth.groupCNs(user);
if(!groups.length) return;
var style = document.getElementById('group-required-rules');
if(!style){
style = document.createElement('style');
style.id = 'group-required-rules';
document.head.appendChild(style);
}
for(var group of groups){
try{
style.sheet.insertRule(
`.group-required-${CSS.escape(group)} { display: revert !important; }`,
style.sheet.cssRules.length
);
}catch(error){
// A group whose CN isn't a usable CSS identifier just gates nothing.
}
}
};
$( document ).ready(async function(){
// Show content the user's groups entitle them to.
app.auth.applyGroupVisibility(await app.auth.loadUser());
$('div.row').fadeIn('slow'); //show the page $('div.row').fadeIn('slow'); //show the page
//panel button's //panel button's
@@ -487,9 +642,9 @@ $( document ).ready(function(){
$(this).closest('.card').slideUp('fast'); $(this).closest('.card').slideUp('fast');
}); });
$('.actionMessage').on('click', 'button.action-close', function(event){ // action-close click handling is wired by @simpleworkjs/frontend's
app.util.actionMessage(null, $(this)); // app.messages.js (delegated on document, so it also covers messages
}); // rendered after this ready handler runs).
setInterval(()=>{ setInterval(()=>{
$('.momentFromNow').each((idx, el)=>{ $('.momentFromNow').each((idx, el)=>{
@@ -520,18 +675,20 @@ function formAJAX(btn){
var method = ($form.attr('method') || 'post').toLowerCase(); var method = ($form.attr('method') || 'post').toLowerCase();
if($form.validate && !$form.validate()){ if($form.validate && !$form.validate()){
app.util.actionMessage('Please fix the form errors.', $form, 'danger'); app.messages.action('Please fix the form errors.', $form, 'danger')
return false; return false;
} }
app.util.actionMessage( app.messages.action(
'<div class="spinner-border" role="status"><span class="sr-only">Loading...</span></div>', `<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>`,
$form, $form,
'info' 'info'
); );
app.api[method]($form.attr('action'), formData, function(error, data){ app.api[method]($form.attr('action'), formData, function(error, data){
app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
$form.validateClear(); $form.validateClear();
if(!error){ if(!error){
$form.trigger("reset"); $form.trigger("reset");
@@ -539,7 +696,7 @@ function formAJAX(btn){
}else{ }else{
console.log('formAJAX res error', error, data) console.log('formAJAX res error', error, data)
if(data && data.name === 'ObjectValidateError'){ if(data && data.name === 'ObjectValidateError'){
app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table
} }
if(data && data.keys){ if(data && data.keys){
console.log('form key errors', data.keys) console.log('form key errors', data.keys)
-201
View File
@@ -1,201 +0,0 @@
( function( $ ) {
var settings = {
rule: {
eq: function(value, options){
var compare = $('[name=' + options + ']').val();
if ( value != compare ) {
return "Miss-match";
}
}
},
};
$.fn.validate = function(event) {
// let thisSettings = $.extend(true, settings, settingsObj);
let hasErrors = false;
if(this.is('[validate]')) return this.validateField(event);
if(!this.attr('isValid')){
console.log('adding reset event')
this.on('reset', function(){
$(this).attr('isValid', false);
$(this).validateClear();
})
}
this.find('[validate]').each(function(){
if(!$(this).validateField()) hasErrors = true;
});
this.attr('isValid', !hasErrors);
if(hasErrors && event) event.preventDefault();
return !hasErrors;
};
$.fn.validateClear = function(){
$(this).find('input').each(function(){
$(this).removeClass('is-invalid');
$(this).removeClass('is-valid');
})
}
$.fn.validateField = function(){
var attr = this.attr('validate').split(':'); //array of params
var rule = attr[0];
var options = attr[1];
var value = this.val(); //link to input value
var message;
if(this.prop('disabled')) return true;
//checks if field is required, and length
if(!isNaN(options) && value.length < options){
message = `Must be ${options} characters`;
}
//checks if empty to stop processing
if(!isNaN(options) && value.length === 0) {
}else if(rule in settings.rule){
message = settings.rule[rule].apply(this, [value, options]);
}
this.validateMessage(message)
return !message;
}
$.fn.validateMessage = function(message){
if(message && message !== true){
this.closest('.form-group').find('b.invalid-feedback').html(message);
this.addClass('is-invalid');
}else{
this.removeClass('is-invalid');
this.addClass('is-valid');
}
return this;
};
jQuery.extend({
validateSettings: function( settingsObj ) {
$.extend( true, settings, settingsObj );
},
validateInit: function( ettingsObj ) {
$( '[action]' ).on( 'submit', function ( event, settingsObj ){
$( this ).validate( settingsObj, event );
});
}
});
}( jQuery ));
// Host / target validation, mirrored from the backend (utils/hostname_validate.js):
// a bare hostname or IPv4 address, no protocol / "/" / ":" / whitespace. The
// incoming host may be a wildcard ("*.example.com"); the target may not.
(function(){
var LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
// Either one bare label (Docker service names, /etc/hosts entries) or a
// dotted hostname with an alphabetic TLD.
var HOSTNAME = /^(?=.{1,253}$)(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/i;
var FORBIDDEN = /[\s/:]/;
function isIPv4( value ) {
var parts = value.split( '.' );
if ( parts.length !== 4 ) return false;
return parts.every( function( p ) {
return /^(0|[1-9]\d{0,2})$/.test( p ) && Number( p ) <= 255;
});
}
// Incoming-host pattern: labels may be normal, "*" (one fragment), or "**"
// (any number of fragments, incl. a bare "**" global catch-all).
function isHostPattern( value ) {
if ( value.length > 253 ) return false;
return value.split( '.' ).every( function( l ) {
return l === '*' || l === '**' || LABEL.test( l );
});
}
function forbidden( value ) {
return FORBIDDEN.test( value ) || value.includes( '://' );
}
// Incoming host: IPv4 or a wildcard host pattern.
function checkHost( value ) {
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
if ( isIPv4( value ) || isHostPattern( value ) ) return;
return "Enter a valid host or wildcard (*, **)";
}
// Downstream target: IPv4 or a strict hostname, no wildcard.
function checkTarget( value ) {
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
if ( isIPv4( value ) || HOSTNAME.test( value ) ) return;
return "Enter a valid hostname or IP";
}
$.validateSettings({
rule:{
ip: function( value ) {
value = value.split( '.' );
if ( value.length != 4 ) {
return "Malformed IP";
}
$.each( value, function( key, value ) {
if( value > 255 || value < 0 ) {
return "Malformed IP";
}
});
},
// Incoming host name — hostname, IPv4, or wildcard pattern (*, **).
host: function( value ) {
return checkHost( value );
},
// Downstream target — hostname or IPv4, no wildcard.
target: function( value ) {
return checkTarget( value );
},
// Back-compat alias (no wildcard).
hostname: function( value ) {
return checkTarget( value );
},
user: function( value ) {
var reg = /^[a-z0-9\_\-\@\.]{1,32}$/;
if ( reg.test( value ) === false ) {
return "Invalid";
}
},
// Mirrors utils/password_policy.js: >= 8 chars, and either 12+ chars
// or at least 3 of {lowercase, uppercase, number, symbol}.
password: function( value ) {
if ( typeof value !== 'string' || value.length < 8 ) {
return "Password must be at least 8 characters";
}
if ( value.length >= 12 ) return;
var classes = 0;
if ( /[a-z]/.test( value ) ) classes++;
if ( /[A-Z]/.test( value ) ) classes++;
if ( /[0-9]/.test( value ) ) classes++;
if ( /[^A-Za-z0-9]/.test( value ) ) classes++;
if ( classes < 3 ) {
return "Use 3 of: lowercase, uppercase, number, symbol (or 12+ chars)";
}
}
}
});
})();
+1 -1
View File
@@ -6,7 +6,7 @@ const middleware = require('../middleware/auth');
const authz = require('../middleware/authz'); const authz = require('../middleware/authz');
// API routes for authentication. // API routes for authentication.
router.use('/auth', require('./auth')); router.use('/auth', require('../models').authRouter);
// API routes for working with users. All endpoints need to be have valid user. // API routes for working with users. All endpoints need to be have valid user.
// User management is admin-only; the router allows self-service exceptions // User management is admin-only; the router allows self-service exceptions
-111
View File
@@ -1,111 +0,0 @@
'use strict';
const router = require('express').Router();
const { rateLimit } = require('express-rate-limit');
const conf = require('@simpleworkjs/conf');
const { Auth } = require('../models/auth');
const { OidcState } = require('../models/oidc_state');
const oidc = require('../utils/oidc');
const { safeInternalPath } = require('../utils/safe_redirect');
// Throttle unauthenticated auth endpoints (credential login + the OIDC
// handshake) to blunt brute-force / callback abuse. Keyed per IP.
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 60, // 60 attempts per IP per window
standardHeaders: true,
legacyHeaders: false,
message: {name: 'TooManyRequests', message: 'Too many attempts, please try again later.'},
});
router.post('/login', authLimiter, async function(req, res, next){
try{
let auth = await Auth.login(req.body);
return res.json({
login: true,
token: auth.token.token,
message:`${req.body.username} logged in!`,
});
}catch(error){
next(error);
}
});
router.all('/logout', async function(req, res, next){
try{
if(req.user){
await req.user.logout();
}
res.json({message: 'Bye'})
}catch(error){
next(error);
}
});
/**
* OIDC login start: create a PKCE + state challenge, persist it (auto-expiring
* via OidcState TTL), and redirect the browser to the SSO authorize endpoint.
*/
router.get('/oidc/start', authLimiter, async function(req, res, next){
try{
if(!conf.oidc || !conf.oidc.enabled){
let error = new Error('OidcDisabled');
error.status = 404;
error.message = 'OIDC login is not enabled.';
throw error;
}
let {state, codeVerifier, codeChallenge} = oidc.createAuthRequest();
await OidcState.create({
state,
codeVerifier,
// Sanitize now so a hostile ?redirect= can't be stored and later
// reflected into the login page's navigation.
redirect: safeInternalPath(req.query.redirect || '/'),
});
return res.redirect(oidc.buildAuthUrl(state, codeChallenge));
}catch(error){
next(error);
}
});
/**
* OIDC callback: validate state (consuming the one-time record), exchange the
* code for tokens, read identity from userinfo, establish a session, and hand
* the app token back to the browser via a URL fragment for the login page to
* store in localStorage.
*/
router.get('/oidc/callback', authLimiter, async function(req, res, next){
try{
let {code, state} = req.query;
if(!code || !state){
let error = new Error('OidcCallbackInvalid');
error.status = 400;
error.message = 'Missing code or state.';
throw error;
}
// get() throws if the state is unknown or has expired — this both binds
// the callback to our request and bounds replay.
let saved = await OidcState.get(state);
await saved.remove();
let tokens = await oidc.exchangeCode(code, saved.codeVerifier);
let claims = await oidc.fetchUserInfo(tokens.access_token);
let identity = oidc.claimsToIdentity(claims);
let {token} = await Auth.oidcSession(identity);
let redirect = safeInternalPath(saved.redirect || '/');
return res.redirect(
`/login#token=${encodeURIComponent(token.token)}&redirect=${encodeURIComponent(redirect)}`
);
}catch(error){
next(error);
}
});
module.exports = router;
+3 -2
View File
@@ -3,7 +3,8 @@
/** /**
* Per-host SSO endpoints (#57), served under /__proxy_auth on EVERY proxied host * Per-host SSO endpoints (#57), served under /__proxy_auth on EVERY proxied host
* (nginx routes that path here; see ops/nginx_conf/proxy.conf). These run the * (nginx routes that path here; see ops/nginx_conf/proxy.conf). These run the
* OIDC authorization-code flow (reusing utils/oidc.js and conf.oidc) and, on a * OIDC authorization-code flow (reusing @simpleworkjs/oidc-client's pure oidc
* utils and conf.oidc) and, on a
* successful + authorized login, mint a Redis-backed SsoSession and set the * successful + authorized login, mint a Redis-backed SsoSession and set the
* `__proxy_sso` cookie for the host. OpenResty then gates the host on that * `__proxy_sso` cookie for the host. OpenResty then gates the host on that
* session (ops/nginx_conf/hostfeatures.lua). * session (ops/nginx_conf/hostfeatures.lua).
@@ -15,7 +16,7 @@
const router = require('express').Router(); const router = require('express').Router();
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const oidc = require('../utils/oidc'); const {oidc} = require('@simpleworkjs/oidc-client');
const {Host} = require('../models').models; const {Host} = require('../models').models;
const {HostSsoState, SsoSession} = require('../models/sso_session'); const {HostSsoState, SsoSession} = require('../models/sso_session');
const {identityAllowed} = require('../utils/host_sso'); const {identityAllowed} = require('../utils/host_sso');
+7 -14
View File
@@ -5,6 +5,7 @@ const express = require('express');
const router = require('express').Router(); const router = require('express').Router();
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const buildInfo = require('../utils/build_info'); const buildInfo = require('../utils/build_info');
const { mountStaticModules } = require('@simpleworkjs/app-stack');
const values ={ const values ={
title: conf.environment !== 'production' ? `dev` : '', title: conf.environment !== 'production' ? `dev` : '',
@@ -15,24 +16,16 @@ const values ={
} }
// List of front end node modules to be served // List of front end node modules to be served
const frontEndModules = ['bootstrap', 'mustache', 'jquery', '@fortawesome',
'moment', '@popper', 'jq-repeat',
];
// Server front end modules
// https://stackoverflow.com/a/55700773/3140931
// Vendor libraries only change when package versions are bumped (a rebuild), // Vendor libraries only change when package versions are bumped (a rebuild),
// so they're safe to cache aggressively; ETag/Last-Modified (on by default) // so they're safe to cache aggressively; ETag/Last-Modified (on by default)
// still cover that rare case with a cheap 304 instead of a stale asset. // still cover that rare case with a cheap 304 instead of a stale asset. The
frontEndModules.forEach(dep => { // app's own JS/CSS/img from public/ gets a shorter maxAge since it changes on
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'})) // every deploy and isn't cache-busted/fingerprinted.
mountStaticModules(router, {
root: path.join(__dirname, '..'),
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat', '@simpleworkjs/frontend'],
}); });
// Have express server static content( images, CSS, browser JS) from the public
// local folder. Shorter maxAge than /static-modules since this is the app's
// own JS/CSS, which changes on every deploy and isn't cache-busted/fingerprinted.
router.use('/static', express.static(path.join(__dirname, '../public'), {maxAge: '1h'}))
router.get('/', (req, res) => { router.get('/', (req, res) => {
res.redirect(301, '/hosts'); res.redirect(301, '/hosts');
}); });
+1 -1
View File
@@ -4,7 +4,7 @@ const {describe, test} = require('node:test');
const assert = require('node:assert'); const assert = require('node:assert');
const crypto = require('crypto'); const crypto = require('crypto');
const oidc = require('../../utils/oidc'); const oidc = require('@simpleworkjs/oidc-client').oidc;
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
/** /**
+1 -1
View File
@@ -3,7 +3,7 @@
const {describe, test} = require('node:test'); const {describe, test} = require('node:test');
const assert = require('node:assert'); const assert = require('node:assert');
const {safeInternalPath} = require('../../utils/safe_redirect'); const {safeInternalPath} = require('@simpleworkjs/oidc-client');
/** /**
* safeInternalPath guards the OIDC post-login redirect against open-redirect * safeInternalPath guards the OIDC post-login redirect against open-redirect
+12 -25
View File
@@ -1,29 +1,16 @@
'use strict'; 'use strict';
const fs = require('fs'); // Unified build-info shape ({ buildVersion, buildHash, buildYear }) via the
// shared @simpleworkjs/app-stack. The baked commit file lives at nodejs/.build_commit
// (../ from here in utils/), matching the Dockerfile gitinfo stage; cwd is
// utils/ for the bare-metal git fallback.
const path = require('path'); const path = require('path');
const { execSync } = require('child_process'); const { createBuildInfo } = require('@simpleworkjs/app-stack');
const { version: buildVersion } = require('../package.json'); const { version } = require('../package.json');
// Docker builds bake the commit hash into ../.build_commit (see the gitinfo module.exports = createBuildInfo({
// stage in Dockerfile) -- the final image has no git binary and no .git version,
// directory, so `git rev-parse` below always fails there. Bare-metal/dev buildCommitPath: path.join(__dirname, '../.build_commit'),
// runs have no baked file, so they fall back to asking git directly. cwd: __dirname,
function readBuildHash() { });
try {
const baked = fs.readFileSync(path.join(__dirname, '../.build_commit'), 'utf8').trim();
if (baked) return baked;
} catch (_) {}
try {
return execSync('git rev-parse --short HEAD', { cwd: __dirname }).toString().trim();
} catch (_) {
return 'unknown';
}
}
module.exports = {
buildVersion,
buildHash: readBuildHash(),
buildYear: new Date().getFullYear(),
};
-127
View File
@@ -1,127 +0,0 @@
'use strict';
const crypto = require('crypto');
const conf = require('@simpleworkjs/conf');
/**
* Minimal OpenID Connect authorization-code + PKCE client.
*
* The SSO publishes no jwks_uri, so we do not verify ID-token signatures;
* instead we treat the flow as opaque and read identity from the userinfo
* endpoint (the access token is exchanged server-side over TLS). Uses Node's
* global fetch (Node 18+) and crypto — no external dependency.
*
* All endpoints and client config come from conf.oidc (+ clientSecret from
* secrets.js, deep-merged by @simpleworkjs/conf).
*/
const base64url = buf => buf.toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
// A high-entropy random string for `state` / PKCE verifier.
function randomToken(bytes = 32){
return base64url(crypto.randomBytes(bytes));
}
// PKCE S256 challenge derived from the verifier.
function codeChallengeS256(verifier){
return base64url(crypto.createHash('sha256').update(verifier).digest());
}
// Generate the {state, codeVerifier, codeChallenge} triple for a new login.
function createAuthRequest(){
let state = randomToken(32);
let codeVerifier = randomToken(32);
let codeChallenge = codeChallengeS256(codeVerifier);
return {state, codeVerifier, codeChallenge};
}
// Build the SSO authorize URL the browser is redirected to. `redirectUri`
// overrides conf.oidc.redirectUri (per-host SSO uses a per-host callback).
function buildAuthUrl(state, codeChallenge, redirectUri){
let o = conf.oidc;
let params = new URLSearchParams({
response_type: 'code',
client_id: o.clientId,
redirect_uri: redirectUri || o.redirectUri,
scope: (o.scopes || ['openid', 'profile', 'email', 'groups']).join(' '),
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return `${o.authorizationEndpoint}?${params.toString()}`;
}
// Exchange an authorization code for tokens at the token endpoint. `redirectUri`
// must match the one used in buildAuthUrl (per-host for per-host SSO).
async function exchangeCode(code, codeVerifier, redirectUri){
let o = conf.oidc;
let body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri || o.redirectUri,
client_id: o.clientId,
client_secret: o.clientSecret,
code_verifier: codeVerifier,
});
let res = await fetch(o.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: body.toString(),
});
if(!res.ok){
let text = await res.text().catch(() => '');
let error = new Error('OidcTokenExchangeFailed');
error.name = 'OidcTokenExchangeFailed';
error.message = `Token exchange failed (${res.status}): ${text}`;
error.status = 502;
throw error;
}
return res.json();
}
// Fetch the userinfo claims for an access token.
async function fetchUserInfo(accessToken){
let o = conf.oidc;
let res = await fetch(o.userinfoEndpoint, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json',
},
});
if(!res.ok){
let error = new Error('OidcUserInfoFailed');
error.name = 'OidcUserInfoFailed';
error.message = `Userinfo request failed (${res.status})`;
error.status = 502;
throw error;
}
return res.json();
}
// Pull the app username and group list out of userinfo claims per conf.
function claimsToIdentity(claims){
let o = conf.oidc;
let username = claims[o.usernameClaim || 'preferred_username'] || claims.sub;
let groups = claims[o.groupsClaim || 'groups'] || [];
if(!Array.isArray(groups)) groups = [groups].filter(Boolean);
return {username, groups, claims};
}
module.exports = {
randomToken,
codeChallengeS256,
createAuthRequest,
buildAuthUrl,
exchangeCode,
fetchUserInfo,
claimsToIdentity,
};
-23
View File
@@ -1,23 +0,0 @@
'use strict';
/**
* Constrain a post-login redirect target to a same-origin path.
*
* Rejects anything that could leave the site or execute script:
* - absolute URLs ("https://evil.com") -> not a "/" path
* - protocol-relative ("//evil.com", "/\\evil.com") -> host takeover
* - scheme targets ("javascript:...", "data:...") -> XSS
* Anything not a plain "/path" falls back to "/".
*
* The browser has its own copy of this in public/lib/js/app-base.js; keep the
* two in sync.
*/
function safeInternalPath(path){
if(typeof path !== 'string' || path.charAt(0) !== '/'
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
return '/';
}
return path;
}
module.exports = {safeInternalPath};
+45
View File
@@ -0,0 +1,45 @@
'use strict';
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
//
// Those two partials are byte-identical across sso-manager-node, proxy and
// jump-host — everything that differs between the apps lives here and is
// exposed to every render as `ui` via app.locals (see app.js). Keep the key set
// in sync across the three apps; a missing key is a render-time ReferenceError,
// not a silent fallback.
module.exports = {
// --- footer -------------------------------------------------------------
repoUrl: 'https://github.com/theta42/proxy',
licenseUrl: 'https://github.com/theta42/proxy/blob/master/LICENSE',
// In-app docs route (routes/docs.js). Apps without one point at the
// published docs site and set docsExternal.
docsUrl: '/docs',
docsExternal: false,
// Only sso-manager-node serves a Terms of Service page; null hides the link.
tosUrl: null,
// --- header / nav -------------------------------------------------------
faviconUrl: '/static/favicon.svg',
// Where the current-user chip links. null renders it as a plain span (for
// apps with no profile page).
profileUrl: '/profile',
// Where "Log Out" lands.
logoutRedirect: '/',
// Admin-only "a newer release is available" banner, backed by
// GET /api/update-check. Apps without that endpoint set false.
updateCheck: true,
updateLabel: 'the proxy',
// Nav items, in order. `groups` is an OR-list of group CNs that may see the
// item; an empty list means "always visible". Gating is done client-side by
// app-base.js, which reveals .group-required-<cn> for each group the user is
// in (plus the synthetic `admin` group when user/me reports isAdmin).
nav: [
{href: '/hosts', icon: 'fa-solid fa-network-wired', label: 'Hosts', groups: []},
{href: '/dns', icon: 'fa-solid fa-record-vinyl', label: 'DNS', groups: []},
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['admin']},
{href: '/permissions', icon: 'fa-solid fa-user-shield', label: 'Permissions', groups: ['admin']},
{href: '/groups', icon: 'fa-solid fa-users-gear', label: 'Groups', groups: ['admin']},
],
};
+29 -23
View File
@@ -1,24 +1,30 @@
</div> </div><!-- end spa-shell -->
<footer class="py-2 bg-dark text-light mt-4"> <!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2"> jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed via
<span class="d-flex align-items-center gap-2"> app.locals in app.js). Edit all three copies together. -->
<a href="https://theta42.com" target="_blank"> <footer class="py-2 bg-dark text-light mt-4">
<img width="64" src="/static/img/theta42.svg"/> <div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
</a> <span class="d-flex align-items-center gap-2">
&copy; <%- buildYear %> theta42 &middot; <a href="https://theta42.com" target="_blank">
<a href="https://github.com/theta42/proxy/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a> <img width="64" src="/static/img/theta42.svg"/>
</span> </a>
<span class="d-flex align-items-center gap-3"> &copy; <%- buildYear %> theta42 &middot;
<a href="/docs" class="text-light text-decoration-none"> <a href="<%- ui.licenseUrl %>" target="_blank" class="text-light">MIT License</a>
<i class="fa-solid fa-book"></i> Docs </span>
</a> <span class="d-flex align-items-center gap-3">
<a href="https://github.com/theta42/proxy" target="_blank" class="text-light text-decoration-none"> <a href="<%- ui.docsUrl %>"<%- ui.docsExternal ? ' target="_blank"' : '' %> class="text-light text-decoration-none">
<i class="fa-brands fa-github"></i> GitHub <i class="fa-solid fa-book"></i> Docs
</a> </a>
</span> <a href="<%- ui.repoUrl %>" target="_blank" class="text-light text-decoration-none">
<span>v<%- buildVersion %> (<%- buildHash %>)</span> <i class="fa-brands fa-github"></i> GitHub
</div> </a>
</footer> <% if(ui.tosUrl){ %>
</body> <a href="<%- ui.tosUrl %>" class="text-light text-decoration-none">Terms of Service</a>
</html> <% } %>
</span>
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
</div>
</footer>
</body>
</html>
+4 -4
View File
@@ -28,14 +28,14 @@
function removeGroup(name){ function removeGroup(name){
app.group.remove(name, function(error, data){ app.group.remove(name, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger'); if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger');
$.scope.LocalGroup.remove(name); $.scope.LocalGroup.remove(name);
}); });
} }
function removeMember(group, username){ function removeMember(group, username){
app.group.removeMember(group, username, function(error, data){ app.group.removeMember(group, username, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger'); if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger');
// websocket update echoes the new member list. // websocket update echoes the new member list.
}); });
} }
@@ -47,14 +47,14 @@
let username = ($input.val() || '').trim(); let username = ($input.val() || '').trim();
if(!username) return; if(!username) return;
app.group.addMember(group, username, function(error, data){ app.group.addMember(group, username, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger'); if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger');
$input.val(''); $input.val('');
}); });
} }
$(document).ready(function(){ $(document).ready(function(){
app.group.list(function(error, data){ app.group.list(function(error, data){
if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger'); if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger');
for(let g of data.results) $.scope.LocalGroup.push(g); for(let g of data.results) $.scope.LocalGroup.push(g);
}); });
+7 -7
View File
@@ -63,7 +63,7 @@
function hostPopulate(){ function hostPopulate(){
app.api.get('host?detail=1&provider=1', function(error, res){ app.api.get('host?detail=1&provider=1', function(error, res){
if(error) return app.util.actionMessage(error, $.scope.hosts.$this, 'danger'); if(error) return app.messages.action(error, $.scope.hosts.$this, 'danger');
for(let host of res.results){ for(let host of res.results){
$.scope.hosts.push(hostParseRow(host)); $.scope.hosts.push(hostParseRow(host));
@@ -152,9 +152,9 @@
let password = $pass.val(); let password = $pass.val();
if(!password) return; if(!password) return;
app.api.put('host/' + encodeURIComponent(host) + '/basicauth-user/' + encodeURIComponent(username), {password}, function(error, data){ app.api.put('host/' + encodeURIComponent(host) + '/basicauth-user/' + encodeURIComponent(username), {password}, function(error, data){
if(error) return app.util.actionMessage((data && data.message) || 'Failed to update password', $rows, 'danger'); if(error) return app.messages.action((data && data.message) || 'Failed to update password', $rows, 'danger');
$pass.val(''); $pass.val('');
app.util.actionMessage('Password updated for "' + username + '".', $rows, 'success'); app.messages.action('Password updated for "' + username + '".', $rows, 'success');
}); });
}); });
// No confirm step, matching this form's existing "Delete" button // No confirm step, matching this form's existing "Delete" button
@@ -163,7 +163,7 @@
let $del = $('<button type="button" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-trash"></i></button>'); let $del = $('<button type="button" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-trash"></i></button>');
$del.on('click', function(){ $del.on('click', function(){
app.api.delete('host/' + encodeURIComponent(host) + '/basicauth-user/' + encodeURIComponent(username), function(error, data){ app.api.delete('host/' + encodeURIComponent(host) + '/basicauth-user/' + encodeURIComponent(username), function(error, data){
if(error) return app.util.actionMessage((data && data.message) || 'Failed to delete user', $rows, 'danger'); if(error) return app.messages.action((data && data.message) || 'Failed to delete user', $rows, 'danger');
$tr.remove(); $tr.remove();
$('.basicauth-current').text(Object.keys((data && data.basicauth_users) || {}).join(', ') || 'none'); $('.basicauth-current').text(Object.keys((data && data.basicauth_users) || {}).join(', ') || 'none');
}); });
@@ -291,7 +291,7 @@
function hostDownloadCert(host, type){ function hostDownloadCert(host, type){
app.host.getCert({host}, function(error, data){ app.host.getCert({host}, function(error, data){
if(error) app.util.actionMessage(error.message, $.scope.hosts.$this, 'danger'); if(error) app.messages.action(error.message, $.scope.hosts.$this, 'danger');
app.util.downloadFile(`${host}-${type}.crt`, data[type]) app.util.downloadFile(`${host}-${type}.crt`, data[type])
}); });
} }
@@ -322,9 +322,9 @@
app.host.clearCache(function(error, data){ app.host.clearCache(function(error, data){
$btn.prop('disabled', false); $btn.prop('disabled', false);
if(error){ if(error){
return app.util.actionMessage(error.message || error, $.scope.hosts.$this, 'danger'); return app.messages.action(error.message || error, $.scope.hosts.$this, 'danger');
} }
app.util.actionMessage(data.message, $.scope.hosts.$this, 'success'); app.messages.action(data.message, $.scope.hosts.$this, 'success');
}); });
} }
+12 -8
View File
@@ -4,14 +4,18 @@
// If we arrived from the OIDC callback with a token in the URL fragment, // If we arrived from the OIDC callback with a token in the URL fragment,
// store it and forward on before doing anything else. // store it and forward on before doing anything else.
if(!app.auth.consumeTokenFragment()){ if(!app.auth.consumeTokenFragment()){
app.auth.isLoggedIn(function(error, isLoggedIn){ // The reveal below touches an element further down this page, so wait
if(isLoggedIn){ // for the DOM — isLoggedIn can answer before the parser gets there.
app.auth.logInRedirect(); $(document).ready(function(){
}else{ app.auth.isLoggedIn(function(error, isLoggedIn){
// Reveal the login card once we know the user is not logged in. if(isLoggedIn){
document.getElementById('login-card-row').style.display = ''; app.auth.logInRedirect();
} }else{
}) // Reveal the login card once we know the user is not logged in.
document.getElementById('login-card-row').style.display = '';
}
});
});
} }
</script> </script>
+2 -2
View File
@@ -42,7 +42,7 @@
function removePermission(id){ function removePermission(id){
app.permission.remove(id, function(error, data){ app.permission.remove(id, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.Permission.$this, 'danger'); if(error) return app.messages.action(error, $.scope.Permission.$this, 'danger');
// The websocket echo removes the row; drop it locally too for snappiness. // The websocket echo removes the row; drop it locally too for snappiness.
$.scope.Permission.remove(id); $.scope.Permission.remove(id);
}); });
@@ -51,7 +51,7 @@
$(document).ready(function(){ $(document).ready(function(){
// Existing permissions. // Existing permissions.
app.permission.list(function(error, data){ app.permission.list(function(error, data){
if(error) return app.util.actionMessage(error, $.scope.Permission.$this, 'danger'); if(error) return app.messages.action(error, $.scope.Permission.$this, 'danger');
for(let p of data.results) $.scope.Permission.push(p); for(let p of data.results) $.scope.Permission.push(p);
}); });
+4 -4
View File
@@ -63,7 +63,7 @@
$(document).ready(function(){ $(document).ready(function(){
app.api.get('user/me', function(error, data){ app.api.get('user/me', function(error, data){
if(error) return app.util.actionMessage(error, $('#profile-card'), 'danger'); if(error) return app.messages.action(error, $('#profile-card'), 'danger');
renderProfile(data); renderProfile(data);
}); });
}); });
@@ -170,7 +170,7 @@
function tableAJAX(){ function tableAJAX(){
app.apiToken.list(function(error, data){ app.apiToken.list(function(error, data){
if(error) return app.util.actionMessage(error, $.scope.apiTokenCard.$this, 'danger'); if(error) return app.messages.action(error, $.scope.apiTokenCard.$this, 'danger');
$.scope.apiTokenCard.empty(); $.scope.apiTokenCard.empty();
(data.results || []).forEach(function(token){ (data.results || []).forEach(function(token){
$.scope.apiTokenCard.push(processToken(token)); $.scope.apiTokenCard.push(processToken(token));
@@ -181,7 +181,7 @@
function revokeToken(id, name, btn){ function revokeToken(id, name, btn){
if(!confirm('Revoke API token "' + name + '"? It stops working immediately.')) return; if(!confirm('Revoke API token "' + name + '"? It stops working immediately.')) return;
app.apiToken.remove({id: id}, function(error, data){ app.apiToken.remove({id: id}, function(error, data){
if(error) return app.util.actionMessage(error, $(btn).closest('.card'), 'danger'); if(error) return app.messages.action(error, $(btn).closest('.card'), 'danger');
$.scope.apiTokenCard.remove('id', id); $.scope.apiTokenCard.remove('id', id);
}); });
} }
@@ -189,7 +189,7 @@
function rotateToken(id, name, btn){ function rotateToken(id, name, btn){
if(!confirm('Rotate API token "' + name + '"? The old token stops working immediately.')) return; if(!confirm('Rotate API token "' + name + '"? The old token stops working immediately.')) return;
app.apiToken.rotate({id: id}, function(error, data){ app.apiToken.rotate({id: id}, function(error, data){
if(error) return app.util.actionMessage(error, $(btn).closest('.card'), 'danger'); if(error) return app.messages.action(error, $(btn).closest('.card'), 'danger');
showSecret(data.token); showSecret(data.token);
tableAJAX(); tableAJAX();
}); });
+55 -55
View File
@@ -4,8 +4,11 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title><%- name %> <%- title %></title> <title><%- name %> <%- title %></title>
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed
via app.locals in app.js). Edit all three copies together. -->
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> <link rel="icon" type="image/svg+xml" href="<%- ui.faviconUrl %>">
<!-- CSS are placed here --> <!-- CSS are placed here -->
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css"> <link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css"> <link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
@@ -14,63 +17,52 @@
<!-- Scripts are placed here --> <!-- Scripts are placed here -->
<script type="text/javascript" src="/socket.io/socket.io.js"></script> <script type="text/javascript" src="/socket.io/socket.io.js"></script>
<script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script> <script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
<!-- <script type="text/javascript" src="/static/lib/js/popper-1.16.0.min.js"></script> -->
<!-- <script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.min.js"></script> -->
<script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script> <script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script> <script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script> <script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script> <script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
<script type="text/javascript" src='/static/lib/js/val.js'></script>
<script type="text/javascript" src="/static-modules/moment/moment.js"></script> <script type="text/javascript" src="/static-modules/moment/moment.js"></script>
<script type="text/javascript" src="/static/lib/js/app-base.js"></script> <script type="text/javascript" src="/static/lib/js/app-base.js"></script>
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.messages.js"></script>
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.modal.js"></script>
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.validate.js"></script>
<script type="text/javascript" src="/static/js/app.js"></script> <script type="text/javascript" src="/static/js/app.js"></script>
</head> </head>
<body> <body>
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark"> <nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
<a class="navbar-brand" href="#"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a> <a class="navbar-brand" href="/"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent"> <div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
<ul class="navbar-nav top-nav"> <ul class="navbar-nav top-nav">
<li class="nav-item"> <%# Items gated on a group start hidden (.group-required) and are
<a class="nav-link active" href="/hosts"> revealed by app-base.js for the groups the user is in. %>
<i class="fa-solid fa-network-wired"></i> <% for(const item of ui.nav){ %>
Hosts <li class="nav-item<%- item.groups.length ? ' group-required' : '' %><%- item.groups.map(group => ' group-required-' + group).join('') %>">
</a> <a class="nav-link" href="<%- item.href %>"><i class="<%- item.icon %>"></i>
</li> <%- item.label %>
<li class="nav-item">
<a class="nav-link" href="/dns"><i class="fa-solid fa-record-vinyl"></i>
DNS
</a>
</li>
<li class="nav-item nav-admin" style="display: none;">
<a class="nav-link" href="/users"><i class="fa-solid fa-users"></i>
Users
</a>
</li>
<li class="nav-item nav-admin" style="display: none;">
<a class="nav-link" href="/permissions"><i class="fa-solid fa-user-shield"></i>
Permissions
</a>
</li>
<li class="nav-item nav-admin" style="display: none;">
<a class="nav-link" href="/groups"><i class="fa-solid fa-users-gear"></i>
Groups
</a> </a>
</li> </li>
<% } %>
</ul> </ul>
<div class="form-inline mt-2 mt-md-0"> <div class="form-inline mt-2 mt-md-0">
<a id="cl-username" class="navbar-text text-light me-3" href="/profile" style="display: none;"> <% if(ui.profileUrl){ %>
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span> <i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</a> </a>
<% } else { %>
<span id="cl-username" class="navbar-text text-light me-3" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</span>
<% } %>
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;"> <a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
<i class="fas fa-sign-out"></i> <i class="fas fa-sign-in"></i>
Login Login
</a> </a>
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(e => window.location.href='/')" style="display: none;"> <button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(function(){ window.location.href = '<%- ui.logoutRedirect %>'; })" style="display: none;">
<i class="fas fa-sign-out"></i> <i class="fas fa-sign-out"></i>
Log Out Log Out
</button> </button>
@@ -78,6 +70,7 @@
</div> </div>
</nav> </nav>
<% if(ui.updateCheck){ %>
<!-- Admin-only "a newer release is available" notice (services/update_check.js). <!-- Admin-only "a newer release is available" notice (services/update_check.js).
Dismissal is per-browser-session only (sessionStorage), not persisted server-side. Dismissal is per-browser-session only (sessionStorage), not persisted server-side.
Fixed-positioned below the fixed navbar (a plain in-flow div here would render Fixed-positioned below the fixed navbar (a plain in-flow div here would render
@@ -102,6 +95,22 @@
sessionStorage.setItem('update-banner-dismissed', '1'); sessionStorage.setItem('update-banner-dismissed', '1');
} }
function checkForUpdate(){
if(sessionStorage.getItem('update-banner-dismissed')) return;
app.api.get('update-check', function(error, info){
if(error || !info || !info.updateAvailable) return;
$('#update-banner-text').html(
'A newer version of <%- ui.updateLabel %> is available: <b>v' + info.latestVersion + '</b> ' +
'(running v' + info.currentVersion + ') — ' +
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
);
showUpdateBanner();
});
}
</script>
<% } %>
<script type="text/javascript">
$(document).ready(function(){ $(document).ready(function(){
// Set the correct link to active in the top nav bar // Set the correct link to active in the top nav bar
@@ -113,34 +122,24 @@
} }
}) })
// Set the correct login/logout button, and reveal admin-only nav // Set the correct login/logout button, and reveal the current user's
// items for global admins. // name once we know who they are. Group-gated nav items are revealed
app.auth.isLoggedIn(function(error, data){ // by app-base.js off the same cached user/me.
if(data){ app.auth.isLoggedIn(function(error, me){
if(me){
$('#cl-logout-button').show(); $('#cl-logout-button').show();
if(data.username){ let username = me.uid || me.username;
$('#cl-username-text').text(data.username); if(username){
$('#cl-username-text').text(username);
$('#cl-username').css('display', ''); $('#cl-username').css('display', '');
} }
<% if(ui.updateCheck){ %>
if(me.isAdmin) checkForUpdate();
<% } %>
}else{ }else{
$('#cl-login-button').show(); $('#cl-login-button').show();
} }
if(data && data.isAdmin){
$('.nav-admin').css('display', '');
if(!sessionStorage.getItem('update-banner-dismissed')){
app.api.get('update-check', function(error, info){
if(error || !info || !info.updateAvailable) return;
$('#update-banner-text').html(
'A newer version of the proxy is available: <b>v' + info.latestVersion + '</b> ' +
'(running v' + info.currentVersion + ') — ' +
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
);
showUpdateBanner();
});
}
}
}); });
}); });
@@ -148,4 +147,5 @@
<!-- Container --> <!-- Container -->
<div id="spa-shell" class="container-fluid"> <div id="spa-shell" class="container-fluid">
<div class="actionMessage" style="display:none;"></div>
+2 -2
View File
@@ -22,7 +22,7 @@
function populateUsers(actionMessage){ function populateUsers(actionMessage){
app.user.list(function(error, data){ app.user.list(function(error, data){
if(error) return app.util.actionMessage(error, $.scope.users.$this, 'danger'); if(error) return app.messages.action(error, $.scope.users.$this, 'danger');
for(let user of data.results){ for(let user of data.results){
$.scope.users.push(user); $.scope.users.push(user);
} }
@@ -37,7 +37,7 @@
function removeUser(username){ function removeUser(username){
app.user.remove({username: username}, function(error, data){ app.user.remove({username: username}, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.users.$this, 'danger'); if(error) return app.messages.action(error, $.scope.users.$this, 'danger');
$.scope.users.remove(username); $.scope.users.remove(username);
}); });
} }
+2 -3
View File
@@ -62,7 +62,7 @@ function M.get(ngx, domain, targetInfo)
local json = require "cjson" local json = require "cjson"
local redis = require "resty.redis" local redis = require "resty.redis"
local round_robin = require "resty.balancer.round_robin" local roundrobin = require "resty.roundrobin"
if not domain then if not domain then
return nil, 499 return nil, 499
@@ -114,12 +114,11 @@ function M.get(ngx, domain, targetInfo)
local cache_key = domain .. "_" .. (res["updated_on"] or "0") local cache_key = domain .. "_" .. (res["updated_on"] or "0")
if not M.host_balancers[domain] or M.host_balancers[domain].key ~= cache_key then if not M.host_balancers[domain] or M.host_balancers[domain].key ~= cache_key then
local b = round_robin:new()
local nodes = {} local nodes = {}
for _, t in ipairs(target_list) do for _, t in ipairs(target_list) do
nodes[t] = 1 nodes[t] = 1
end end
b:reinit(nodes) local b = roundrobin:new(nodes)
M.host_balancers[domain] = { b = b, key = cache_key } M.host_balancers[domain] = { b = b, key = cache_key }
end end