feat: rebuild web UI on the shared theta42 app stack; OIDC + local admin auth

The web management UI was a bespoke minimal theme with LDAP-bind login.
Rebuild it to match the SSO Manager and Proxy — same stack, same look/feel,
same auth model. The SSH bridge, audit, metrics, and access logic are
unchanged; this is purely the web layer.

Frontend (mirrors proxy/sso):
- Express + EJS with the shared top.ejs/bottom.ejs shell, Bootstrap 5,
  jQuery, jq-repeat, FontAwesome, Socket.IO, and the shared app-base.js /
  val.js client framework (copied verbatim). Vendor libs served from
  node_modules via /static-modules; app assets via /static.
- Dashboard / Sessions / Audit pages render in the common look/feel,
  loading data through the authenticated /api/* endpoints.

Auth (mirrors proxy):
- OIDC against the SSO (utils/oidc.js + routes/auth.js + models/oidc_state)
  plus a local anti-lockout admin (models/user_redis.js, bootstrapped from
  auth.adminUsers[0] / auth.localAdminPass). AuthToken sessions carry the
  group snapshot; middleware gates the data API on adminGroups or the local
  admin. New config: oidc{} + auth.adminUsers/localAdminPass.
- /api/user/me drives the client login state; "Log in with SSO" hidden when
  oidc.enabled is false.

Verified end to end: local admin login -> token -> /api/user/me isAdmin,
metrics/sessions/audit 200 with token / 401 without / 401 bad password;
static + page shells serve; 26 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 20:57:34 -04:00
parent eb8e5b409e
commit aa3b3ed515
38 changed files with 2550 additions and 374 deletions
-31
View File
@@ -1,31 +0,0 @@
:root { --bg:#0f1115; --panel:#181b22; --line:#272b34; --fg:#e6e8ec; --mut:#8b93a1; --acc:#4f9cf9; --bad:#ff6b6b; --ok:#4ec9a5; }
* { box-sizing: border-box; }
body { margin:0; font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--fg); }
a { color:var(--acc); text-decoration:none; } a:hover { text-decoration:underline; }
.nav { display:flex; align-items:center; gap:16px; padding:12px 20px; background:var(--panel); border-bottom:1px solid var(--line); }
.brand { font-weight:600; } .brand small { color:var(--mut); font-weight:400; }
.nav .spacer { flex:1; } .nav .who { color:var(--mut); }
.wrap { max-width:1100px; margin:0 auto; padding:24px 20px; }
h1 { font-size:20px; margin:0 0 16px; } h2 { font-size:15px; margin:24px 0 8px; }
.tiles { display:flex; gap:16px; flex-wrap:wrap; }
.tile { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:16px 20px; min-width:150px; }
.tile .n { display:block; font-size:28px; font-weight:600; } .tile .l { color:var(--mut); }
.cols { display:grid; grid-template-columns:2fr 1fr; gap:24px; }
@media (max-width:800px){ .cols { grid-template-columns:1fr; } }
table { width:100%; border-collapse:collapse; margin-top:8px; }
th,td { text-align:left; padding:7px 10px; border-bottom:1px solid var(--line); }
th { color:var(--mut); font-weight:500; font-size:12px; text-transform:uppercase; letter-spacing:.03em; }
td.r,th.r { text-align:right; }
tr.bad td { color:var(--bad); }
.muted { color:var(--mut); }
.more { font-size:12px; font-weight:400; margin-left:8px; }
.foot { max-width:1100px; margin:0 auto; padding:16px 20px; color:var(--mut); font-size:12px; }
.filters { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; }
.filters input,.filters select,.login input { background:#0c0e12; border:1px solid var(--line); color:var(--fg); border-radius:7px; padding:7px 10px; }
button { background:var(--acc); color:#fff; border:0; border-radius:7px; padding:8px 14px; cursor:pointer; font:inherit; }
button.link { background:none; color:var(--acc); padding:0; }
.inline { display:inline; } .pager { display:flex; gap:16px; align-items:center; margin-top:16px; color:var(--mut); }
.center { display:grid; place-items:center; min-height:100vh; }
.card.login { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:28px; width:320px; display:flex; flex-direction:column; gap:12px; }
.card.login h1 { margin:0 0 8px; } .card.login label { display:flex; flex-direction:column; gap:4px; font-size:13px; color:var(--mut); }
.card.login .hint { color:var(--mut); font-size:12px; margin:4px 0 0; } .err { color:var(--bad); margin:0; }
+20
View File
@@ -0,0 +1,20 @@
nav.navbar{
padding-left: 1em;
padding-right: 1em;
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
#spa-shell {
margin-top: 4.5rem;
padding-bottom: 1em;
flex-grow: 1;
}
.card-title{
font-weight: bold;
}
+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<!-- Background circle -->
<circle cx="50" cy="50" r="48" fill="#1a1a1a" stroke="#4a9eff" stroke-width="3"/>
<!-- Network nodes -->
<circle cx="30" cy="30" r="8" fill="#4a9eff"/>
<circle cx="70" cy="30" r="8" fill="#4a9eff"/>
<circle cx="50" cy="50" r="10" fill="#66b3ff"/>
<circle cx="30" cy="70" r="8" fill="#4a9eff"/>
<circle cx="70" cy="70" r="8" fill="#4a9eff"/>
<!-- Connection lines -->
<line x1="30" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="70" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="30" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="70" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 788 B

+51
View File
@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
<defs>
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#C59341" />
<stop offset="20%" stop-color="#E4B869" />
<stop offset="40%" stop-color="#FBF0B9" />
<stop offset="60%" stop-color="#DFB260" />
<stop offset="80%" stop-color="#BC8837" />
<stop offset="100%" stop-color="#A36F28" />
</linearGradient>
<linearGradient id="text-grad" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#FFFFFF" />
<stop offset="40%" stop-color="#F5E3B5" />
<stop offset="70%" stop-color="#D4A343" />
<stop offset="100%" stop-color="#8A5A16" />
</linearGradient>
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
</filter>
</defs>
<g filter="url(#drop-shadow)">
<g fill="url(#gold-grad)">
<path d="M 200,40
C 290,40 350,110 350,200
C 350,290 290,360 200,360
C 110,360 50,290 50,200
C 50,110 110,40 200,40 Z
M 200,75
C 130,75 88,130 88,200
C 88,270 130,325 200,325
C 270,325 312,270 312,200
C 312,130 270,75 200,75 Z"
fill-rule="evenodd" />
<path d="M 88,190 L 140,190 C 140,190 142,210 140,210 L 88,210 Z" />
<path d="M 260,190 L 312,190 C 312,190 310,210 260,210 Z" />
</g>
<text x="200" y="222"
font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
font-size="78"
font-weight="900"
fill="url(#text-grad)"
text-anchor="middle"
letter-spacing="-2">42</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+21
View File
@@ -0,0 +1,21 @@
'use strict';
// Jump-host page controllers. app.api / app.auth come from app-base.js (the
// shared client framework); this adds the jump-host data calls and the small
// render helpers each page uses.
app.jump = (function(app){
function metrics(cb){ app.api.get('metrics', cb); }
function sessions(cb){ app.api.get('sessions', cb); }
function audit(query, cb){
var qs = $.param(query || {});
app.api.get('audit' + (qs ? '?' + qs : ''), cb);
}
return {metrics: metrics, sessions: sessions, audit: audit};
})(app);
// Shared render helpers.
app.jump.fmtTime = function(ts){ return ts ? moment(Number(ts)).format('YYYY-MM-DD HH:mm:ss') : '—'; };
app.jump.esc = function(s){ return $('<div>').text(s == null ? '' : String(s)).html(); };
app.jump.result = function(e){ return e.success ? '<span class="badge bg-success">ok</span>'
: '<span class="badge bg-danger">' + app.jump.esc(e.failReason || 'fail') + '</span>'; };
+552
View File
@@ -0,0 +1,552 @@
var app = {};
app.pubsub = (function(){
app.topics = {};
app.subscribe = function(topic, listener){
if(topic instanceof RegExp){
listener.match = topic;
topic = "__REGEX__";
}
// create the topic if not yet created
if(!app.topics[topic]) app.topics[topic] = [];
// add the listener
app.topics[topic].push(listener);
}
app.matchTopics = function(topic){
topic = topic || '';
var matches = [... app.topics[topic] ? app.topics[topic] : []];
if(!app.topics['__REGEX__']) return matches;
for(var listener of app.topics['__REGEX__']){
if(topic.match(listener.match)) matches.push(listener);
}
return matches;
}
app.publish = function(topic, data){
// send the event to all listeners
app.matchTopics(topic).forEach(function(listener){
setTimeout(function(data, topic){
listener(data || {}, topic);
}, 0, data, topic);
});
}
return this;
})(app);
app.socket = (function(app){
// $.getScript('/socket.io/socket.io.js')
// <script type="text/javascript" src="/socket.io/socket.io.js"></script>
var socket;
$(document).ready(function(){
socket = io({
auth: {
token: app.auth.getToken()
}
});
// socket.emit('chat message', $('#m').val());
socket.on('P2PSub', function(msg){
msg.data.__noSocket = true;
app.publish(msg.topic, msg.data);
});
app.subscribe(/./g, function(data, topic){
// console.log('local_pubs', data, topic)
if(data.__noSocket) return;
// console.log('local_pubs 2', data, topic)
socket.emit('P2PSub', { topic, data });
});
})
return socket;
})(app);
app.api = (function(app){
var baseURL = '/api/'
function post(url, data, callback){
if(typeof callback !== 'function') callback = callback2;
return $.ajax({
type: 'POST',
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 put(url, data, callback){
if(typeof callback !== 'function') callback = callback2;
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){
if(typeof callback !== 'function') callback = callback2;
return $.ajax({
type: 'delete',
url: baseURL+url,
headers:{
'auth-token': app.auth.getToken()
},
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 options(url, callback){
return $.ajax({
type: 'OPTIONS',
url: baseURL+url,
headers:{
'auth-token': app.auth.getToken()
},
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 get(url, callback){
return $.ajax({
type: 'GET',
url: baseURL+url,
headers:{
'auth-token': app.auth.getToken()
},
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(){}
}
});
}
return {post: post, get: get, put: put, delete: remove, options: options,}
})(app)
app.auth = (function(app){
var user = {}
function setToken(token){
localStorage.setItem('APIToken', token);
}
function getToken(){
return localStorage.getItem('APIToken');
}
function isLoggedIn(callback){
if(getToken()){
return app.api.get('user/me', function(error, data){
// data now carries effective rights (isAdmin, global, domains).
if(!error) app.auth.user = app.auth.perms = data;
return callback(error, data);
});
}else{
callback(null, false);
}
}
// Constrain a redirect target to a same-origin absolute path. Rejects
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
function safeInternalPath(path){
if(typeof path !== 'string' || path.charAt(0) !== '/'
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
return '/';
}
return path;
}
// Consume an app token handed back by the OIDC callback via the URL
// fragment (#token=…&redirect=…). Stores it, strips the fragment, and
// forwards to the intended page. Returns true if a token was consumed.
function consumeTokenFragment(){
if(!location.hash) return false;
var params = new URLSearchParams(location.hash.replace(/^#/, ''));
var token = params.get('token');
if(!token) return false;
setToken(token);
// redirect comes from the URL fragment (attacker-controllable); only
// allow a same-origin path so it can't become an open redirect / XSS.
var redirect = safeInternalPath(params.get('redirect') || '/');
// Drop the token from the address bar before navigating on.
history.replaceState(null, '', location.pathname + location.search);
window.location.href = redirect;
return true;
}
// True when the logged-in user is a global admin (per user/me).
function isAdmin(){
return !!(app.auth.perms && app.auth.perms.isAdmin);
}
function logIn(args, callback){
app.api.post('auth/login', args, function(error, data){
if(data.login){
setToken(data.token);
}
callback(error, !!data.token);
});
}
function logOut(callback){
localStorage.removeItem('APIToken');
callback();
}
function forceLogin(){
// 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(error || !isLoggedIn){
app.auth.logOut(function(){})
location.replace(`/login${location.href.replace(location.origin, '')}`);
}
});
}
function logInRedirect(){
window.location.href = safeInternalPath(location.href.replace(location.origin+'/login', '') || '/')
}
return {
getToken: getToken,
setToken: setToken,
isLoggedIn: isLoggedIn,
consumeTokenFragment: consumeTokenFragment,
isAdmin: isAdmin,
perms: null,
logIn: logIn,
logOut: logOut,
forceLogin,
logInRedirect,
}
})(app);
app.user = (function(app){
function list(callback){
app.api.get('user/?detail=true', function(error, data){
callback(error, data);
})
}
function add(args, callback){
app.api.post('user/', args, function(error, data){
callback(error, data);
});
}
function remove(args, callback){
app.api.delete('user/'+ args.username, function(error, data){
callback(error, data);
});
}
function changePassword(args, callback){
app.api.put('users/'+ arg.username || '', args, function(error, data){
callback(error, data);
});
}
return {list, remove};
})(app);
app.permission = (function(app){
function list(callback){
app.api.get('permission/', function(error, data){
callback(error, data);
});
}
function subjects(callback){
app.api.get('permission/subjects', function(error, data){
callback(error, data);
});
}
function add(args, callback){
app.api.post('permission/', args, function(error, data){
callback(error, data);
});
}
function remove(id, callback){
app.api.delete('permission/' + encodeURIComponent(id), function(error, data){
callback(error, data);
});
}
return {list, subjects, add, remove};
})(app);
app.group = (function(app){
function list(callback){
app.api.get('group/', function(error, data){
callback(error, data);
});
}
function add(args, callback){
app.api.post('group/', args, function(error, data){
callback(error, data);
});
}
function remove(name, callback){
app.api.delete('group/' + encodeURIComponent(name), function(error, data){
callback(error, data);
});
}
function addMember(name, username, callback){
app.api.post('group/' + encodeURIComponent(name) + '/members', {username}, function(error, data){
callback(error, data);
});
}
function removeMember(name, username, callback){
app.api.delete('group/' + encodeURIComponent(name) + '/members/' + encodeURIComponent(username), function(error, data){
callback(error, data);
});
}
return {list, add, remove, addMember, removeMember};
})(app);
app.util = (function(app){
function getUrlParameter(name){
name = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]');
var regex = new RegExp('[\\?&]' + name + '=([^&#]*)');
var results = regex.exec(location.search);
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
};
function actionMessage(message, $target, type, callback){
message = message || '';
$target = $target.closest('div.card').find('.actionMessage');
type = type || 'info';
callback = callback || function(){};
if($target.html() === message) return;
if($target.html()){
$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() {
var obj = {};
// Get the form values and work over them
for (let {name, value} of $(this).serializeArray()) {
console.log(name, value)
if (obj[name] === undefined) {
if (!value
&& !$(this).parent().find(`[name="${name}"]`).attr('value')
// Keep empty <textarea>s so a cleared field is submitted (and
// can reset a list, e.g. the per-host IP/header controls).
&& !$(this).filter(`textarea[name="${name}"]`).length
){
continue;
}
obj[name] = value;
let type = $(this).parent().find(`[name="${name}"]`).attr('type');
if (['number', 'range'].includes(type)) {
obj[name] = Number(value);
}
if (['radio'].includes(type) && ['true', 'false'].includes(value)) {
obj[name] = value == 'true' ? true : false;
}
} else {
if (!(obj[name] instanceof Array)) {
obj[name] = [obj[name]];
}
obj[name].push(value);
}
}
return obj;
};
function downloadFile(filename, text){
// https://stackoverflow.com/a/18197341
var element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
return {
downloadFile: downloadFile,
getUrlParameter: getUrlParameter,
actionMessage: actionMessage
}
})(app);
$( document ).ready(function(){
$('div.row').fadeIn('slow'); //show the page
//panel button's
$('.fa-arrows-v').click(function(){
$(this).closest('.card').find('.card-body').slideToggle('fast');
});
$('.fa-circle-minus').click(function(){
let $body = $(this).closest('.card').find('.card-body');
if($body.hasClass('d-none')){
$body.removeClass("d-none").removeClass('d-md-block');
if($body.is(":visible")) $body.hide();
}
$body.slideToggle('fast');
});
$('.fa-circle-xmark').click(function(){
$(this).closest('.card').slideUp('fast');
});
$('.actionMessage').on('click', 'button.action-close', function(event){
app.util.actionMessage(null, $(this));
});
setInterval(()=>{
$('.momentFromNow').each((idx, el)=>{
var $el = $(el);
try{
$el.html(moment($(el).data('date')).fromNow());
}catch{}
})
}, 30000,);
});
(function($){
$.fn.scrollTo = function(){
const yOffset = Number($('#spa-shell').css('margin-top').replace('px', ''));
const y = this[0].getBoundingClientRect().top + window.scrollY - yOffset;
console.log('y', y)
window.scrollTo({top: y, behavior: 'smooth'});
};
})(jQuery);
//ajax form submit
function formAJAX(btn){
event.preventDefault(btn); // avoid to execute the actual submit of the form.
var $form = $(btn || event.target).closest('[action]'); // gets the 'form' parent
var formData = $form.find('[name]').serializeObject(); // builds query formDataing
var method = ($form.attr('method') || 'post').toLowerCase();
if($form.validate && !$form.validate()){
app.util.actionMessage('Please fix the form errors.', $form, 'danger');
return false;
}
app.util.actionMessage(
'<div class="spinner-border" role="status"><span class="sr-only">Loading...</span></div>',
$form,
'info'
);
app.api[method]($form.attr('action'), formData, function(error, data){
app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table
$form.validateClear();
if(!error){
$form.trigger("reset");
eval($form.attr('evalAJAX')); //gets JS to run after completion
}else{
console.log('formAJAX res error', error, data)
if(data && data.name === 'ObjectValidateError'){
app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table
}
if(data && data.keys){
console.log('form key errors', data.keys)
for(let keyError of data.keys){
$form.find(`[name=${keyError.key}]`).validateMessage(keyError.message);
}
}
}
});
}
+201
View File
@@ -0,0 +1,201 @@
( 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)";
}
}
}
});
})();