Files
proxy/nodejs/views/hosts.ejs
T
wmantly ad2cacf094 Make basic auth and SSO mutually exclusive per host; fix silently-broken validation errors
- Auth tab is now a single choice (Off / Basic auth / SSO) instead of two
  independent toggles that could both be on at once, which made it
  ambiguous which gate actually protected a request. Enforced both in the
  UI and server-side (POST/PUT), accounting for partial PUT updates against
  the existing record.
- Add per-user basic-auth management (change password, delete) so an admin
  no longer has to blow away and retype the whole user list to remove or
  rotate one account.
- Fix: `Model.errors.ObjectValidateError(...)` is a constructor and was
  being called without `new` everywhere in this codebase. Without `new`,
  `this` inside it was the module's shared `errors` object (mutated in
  place) and the call evaluated to `undefined` — so every
  `throw Model.errors.ObjectValidateError(...)` actually threw `undefined`,
  which Express's `next(undefined)` treats as "no error" and silently
  falls through to the catch-all 404 handler. Every host/user/group/
  permission/dns-provider validation error (bad hostname, bad IP, etc.) was
  showing a confusing "Page not found" instead of the real message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 00:41:16 -04:00

813 lines
33 KiB
Plaintext
Executable File

<%- include('top') %>
<script type="text/javascript">
// Require login to see this page.
app.auth.forceLogin()
</script>
<style type="text/css">
label.form-label{
font-weight: bold;
margin-bottom: 1px;
}
div.form-group{
margin-bottom: 1.1em;
}
.field-help{
font-size: .82rem;
}
/* my Div class for my search bar */
.search-wrapper {
display: flex;
gap: .5rem;
align-items: center;
margin-top: 10px;
}
/* Greys out a challenge/matching option that isn't available for the host. */
.challengeType-container {
pointer-events: none; /* Prevents clicking */
opacity: 0.5; /* Greys it out */
filter: grayscale(1); /* Removes blue/color tint */
cursor: not-allowed;
}
</style>
<script type="text/javascript">
// Parse the JSON object for a host to something the UI wants
function hostParseRow(host) {
host['updated_on_text'] = moment(host['updated_on'], "x").fromNow();
host['wildcard_expires_text'] = moment(host['wildcard_expires'], "x").fromNow();
host['targetssl_text'] = host['targetssl'] ? 'https://' : 'http://';
host['forcessl_text'] = host['forcessl'] ? 'https://' : 'http://';
host['wildcard_text'] = host['is_wildcard'] ? host['wildcard_status'] : 'Auto';
host['wildcard_text'] = host['wildcard_parent'] ? 'Child' : host['wildcard_text'];
host['wildcard_text_bg'] = 'warning';
if(!host['is_wildcard']){
host['wildcard_text_bg'] = 'success';
}
if(host['wildcard_status'] == 'Done'){
host['wildcard_text_bg'] = 'success';
host['wildcard_text'] = undefined;
}
if(host['wildcard_status'] && host['wildcard_status'].includes('failed')){
host['wildcard_text_bg'] = 'danger';
}
return host;
}
function hostPopulate(){
app.api.get('host?detail=1&provider=1', function(error, res){
if(error) return app.util.actionMessage(error, $.scope.hosts.$this, 'danger');
for(let host of res.results){
$.scope.hosts.push(hostParseRow(host));
}
$.scope.hosts.put = function($el, item, list){
$el.addClass('table-success');
$el.fadeIn(2000, function(){
$el.removeClass('table-success');
});
};
});
}
// Mirror of utils/host_features.js stringify* helpers for populating the edit
// form's textareas. The server re-parses the posted text authoritatively.
function hostFeatureHeadersToText(obj){
if(!obj || typeof obj != 'object') return '';
return Object.keys(obj).map(function(name){ return name + ': ' + obj[name]; }).join('\n');
}
function hostFeatureListToText(arr){
return Array.isArray(arr) ? arr.join('\n') : '';
}
// ----- Add / Edit modal --------------------------------------------------
function hostModal(){
return bootstrap.Modal.getOrCreateInstance(document.getElementById('hostModal'));
}
function hostModalClose(){ hostModal().hide(); }
function hostShowTab(id){
bootstrap.Tab.getOrCreateInstance(document.getElementById(id)).show();
}
// Append a picked/typed value to one of the SSO allow-list textareas (deduped).
function allowListAdd(input, name){
let val = (input.value || '').trim();
if(!val) return;
let $ta = $('#hostForm textarea[name="' + name + '"]');
let lines = ($ta.val() || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean);
if(lines.indexOf(val) === -1) lines.push(val);
$ta.val(lines.join('\n'));
input.value = '';
input.focus();
}
// Host name of the record currently open in the edit modal, or null when
// adding a new host (basic-auth user management needs a saved host to
// attach users to).
let hostFormCurrentHost = null;
// The auth_mode radios aren't real form fields (no [name]); this keeps the
// two hidden basicauth_enabled/sso_enabled inputs — the ones actually
// submitted — in sync so only one can ever be true, and shows/hides the
// matching field group.
function hostAuthModeChanged(mode){
$('#basicauth_enabled-hidden').val(mode === 'basic' ? 'true' : 'false');
$('#sso_enabled-hidden').val(mode === 'sso' ? 'true' : 'false');
$('#hostTab-auth-basicFields').toggle(mode === 'basic');
$('#hostTab-auth-ssoFields').toggle(mode === 'sso');
$('#hostTab-auth-basicUsersMgmt').toggle(mode === 'basic' && !!hostFormCurrentHost);
}
// Per-user basic-auth management (delete / change password) for the host
// currently open in the edit modal. Only shown once a host exists to attach
// users to (not on "Add host", before it's been saved).
function hostRenderBasicAuthUsers(host, users){
let $rows = $('#basicAuthUserRows').empty();
let usernames = Object.keys(users || {});
if(!usernames.length){
$rows.append('<tr><td colspan="3" class="text-muted">No basic-auth users yet.</td></tr>');
return;
}
for(let username of usernames){
let $tr = $('<tr>');
$tr.append($('<td>').text(username));
let $pass = $('<input type="text" class="form-control form-control-sm" placeholder="new password">');
$tr.append($('<td>').append($pass));
let $actions = $('<td>');
let $save = $('<button type="button" class="btn btn-sm btn-outline-secondary me-1"><i class="fa-solid fa-key"></i></button>');
$save.on('click', function(){
let password = $pass.val();
if(!password) return;
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');
$pass.val('');
app.util.actionMessage('Password updated for "' + username + '".', $rows, 'success');
});
});
// No confirm step, matching this form's existing "Delete" button
// (host deletion itself is also a single click, no dialog — see the
// host row actions above).
let $del = $('<button type="button" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-trash"></i></button>');
$del.on('click', function(){
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');
$tr.remove();
$('.basicauth-current').text(Object.keys((data && data.basicauth_users) || {}).join(', ') || 'none');
});
});
$actions.append($save).append($del);
$tr.append($actions);
$rows.append($tr);
}
}
// Fill the user/group datalists that back the allow-list autocomplete.
function hostLoadAuthSuggestions(){
app.api.get('host/auth-suggestions', function(error, data){
if(error || !data) return;
let $u = $('#hostSsoUsers').empty();
for(let u of (data.users || [])) $u.append($('<option>').val(u));
let $g = $('#hostSsoGroups').empty();
for(let g of (data.groups || [])) $g.append($('<option>').val(g));
});
}
// Return the form to a clean "add" state.
function hostFormReset(){
let form = document.getElementById('hostForm');
form.reset();
let $f = $(form);
$f.attr('method', 'POST').attr('action', 'host').attr('evalAJAX', 'hostModalClose()');
$f.find('[name=host]').prop('disabled', false);
if($f.validateClear) $f.validateClear();
// A fresh host only qualifies for HTTP-01 until the name says otherwise.
$('#challengeType-child-container, #challengeType-DNS-01-wildcard-container, #wildcard_matchAny-container')
.addClass('challengeType-container');
$('#challengeType-child-relatedHost').text('');
$('.basicauth-current').text('none');
hostFormCurrentHost = null;
hostAuthModeChanged('none');
hostShowTab('hostTab-general-btn');
}
function hostAddOpen(){
hostFormReset();
$('#hostModalTitle').text('Add host');
$('#hostModalSubmitText').text('Add host');
hostModal().show();
}
function hostEditOpen(host){
hostFormReset();
let h = $.scope.hosts.getByKey(host);
let $f = $('#hostForm');
$f.attr('method', 'PUT').attr('action', 'host/' + encodeURIComponent(host));
$('#hostModalTitle').text('Edit ' + host);
$('#hostModalSubmitText').text('Save changes');
// Scalar fields: booleans drive the matching radio, everything else the
// input with that name. Object/array fields are handled as text below.
$.each(h, function(key, value){
if(typeof value === 'boolean'){
$f.find('#' + key + '-' + value).prop('checked', true);
}else{
$f.find("input[name='" + key + "']").val(value);
}
});
$f.find("textarea[name='req_headers']").val(hostFeatureHeadersToText(h.req_headers));
$f.find("textarea[name='resp_headers']").val(hostFeatureHeadersToText(h.resp_headers));
$f.find("textarea[name='ip_allow']").val(hostFeatureListToText(h.ip_allow));
$f.find("textarea[name='ip_deny']").val(hostFeatureListToText(h.ip_deny));
$f.find("textarea[name='sso_allow_users']").val(hostFeatureListToText(h.sso_allow_users));
$f.find("textarea[name='sso_allow_groups']").val(hostFeatureListToText(h.sso_allow_groups));
// Never echo basic-auth passwords; show current usernames as a hint.
$f.find("textarea[name='basicauth_users']").val('');
$('.basicauth-current').text(Object.keys(h.basicauth_users || {}).join(', ') || 'none');
// Auth: one radio drives both mutually-exclusive booleans.
hostFormCurrentHost = host;
let authMode = h.sso_enabled ? 'sso' : (h.basicauth_enabled ? 'basic' : 'none');
$f.find('#auth_mode-' + authMode).prop('checked', true);
hostAuthModeChanged(authMode);
hostRenderBasicAuthUsers(host, h.basicauth_users);
// The host name is the key; it can't change on edit. Wildcard hosts can
// still toggle their matching mode.
$f.find('[name=host]').prop('disabled', true);
if(h.is_wildcard){
$('#wildcard_matchAny-container').removeClass('challengeType-container');
}
hostModal().show();
}
function hostDownloadCert(host, type){
app.host.getCert({host}, function(error, data){
if(error) app.util.actionMessage(error.message, $.scope.hosts.$this, 'danger');
app.util.downloadFile(`${host}-${type}.crt`, data[type])
});
}
function hostSearchInput(){
let inputValue = $(event.target).val().toLowerCase();
for(let hostObj of $.scope.hosts){
if (hostObj.host.toLowerCase().includes(inputValue)) {
hostObj.__jq_$el.show();
} else {
hostObj.__jq_$el.hide();
}
}
};
async function verifyWildcardRequirements(host){
try{
let res = await app.api.get(`dns/domain/${host}`);
return res.results.length === 1;
}catch(error){
return false;
}
}
function hostClearCache(btn){
let $btn = $(btn);
$btn.prop('disabled', true);
app.host.clearCache(function(error, data){
$btn.prop('disabled', false);
if(error){
return app.util.actionMessage(error.message || error, $.scope.hosts.$this, 'danger');
}
app.util.actionMessage(data.message, $.scope.hosts.$this, 'success');
});
}
async function hostMatchWildcard(host){
try{
let res = await app.api.get(`host/lookup/${host}`);
if(res.results && res.results.is_wildcard){
return res.results;
}
}catch(error){
return false;
}
}
$(document).ready(function(){
// Populate the host UI table
hostPopulate();
hostLoadAuthSuggestions();
// Determine what Let's Encrypt challenge type the given host name can use.
let $hostField = $('#hostForm [name=host]');
$hostField.on('keyup', async function(){
// Reset the allowed types on start
$('#challengeType-child-container').addClass('challengeType-container');
$('#challengeType-DNS-01-wildcard-container').addClass('challengeType-container');
$('#wildcard_matchAny-container').addClass('challengeType-container');
let host = $hostField.val();
// If it's a wildcard, we must check the domain has a registered provider.
if(host.startsWith("*.") && await verifyWildcardRequirements(host)){
$('#challengeType-DNS-01-wildcard-container').removeClass('challengeType-container');
$('#wildcard_matchAny-container').removeClass('challengeType-container');
return;
}
// Check if a wildcard cert is available for the given host. When it is,
// make "Parent Wildcard" the default choice (it reuses an existing cert).
let wildcardParent = await hostMatchWildcard(host);
if(wildcardParent){
$('#challengeType-child-container').removeClass('challengeType-container');
$('#challengeType-child-relatedHost').text(wildcardParent.host);
$('#challengeType-wildcardChild').prop('checked', true);
return;
}
// Revert the form to a valid state.
$('#challengeType-child-relatedHost').text('');
$('#challengeType-HTTP-01').prop('checked', true);
});
$.scope.hosts.take = function($el, item, list){
$el.addClass('table-danger');
$el.fadeOut(500, function(){ $el.remove() });
};
$.scope.hosts.putUpdate = function($el, $render, item, list){
$render.show()
$el.replaceWith($render);
};
app.subscribe(/^model:Host:create/, function(data, topic){
let [a,b, action, host] = topic.split(':');
if($.scope.hosts.indexOf(host) >= 0){
$.scope.hosts.update(host, hostParseRow(data));
}else{
$.scope.hosts.unshift(hostParseRow(data));
}
});
app.subscribe(/^model:Host:update/, function(data, topic){
let [a,b, action, host] = topic.split(':');
if($.scope.hosts.indexOf(host) >= 0){
$.scope.hosts.update(host, hostParseRow(data));
}else{
$.scope.hosts.unshift(hostParseRow(data));
}
});
app.subscribe(/^model:Host:remove/, function(data, topic){
let [a,b, action, host] = topic.split(':');
$.scope.hosts.remove(host);
});
});
</script>
<div class="row" style="display:none">
<div class="col-12">
<div class="card shadow-lg hostListPanel">
<div class="card-header d-flex align-items-center">
<span class="card-icon me-2"><i class="fa-solid fa-network-wired"></i></span>
<span class="card-title fw-bold">Proxy List</span>
<span class="ms-auto">
<button type="button" class="btn btn-sm btn-outline-secondary me-2" onclick="hostClearCache(this)" title="Clear cached wildcard subdomain lookups">
<i class="fa-solid fa-broom"></i>
Clear cache
</button>
<button type="button" class="btn btn-sm btn-success" onclick="hostAddOpen()">
<i class="fa-solid fa-plus"></i>
Add host
</button>
</span>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body search-wrapper">
<label class="form-label" for="search" style="margin-left: -5px;">
Search Hosts:
</label>
<input type="search" oninput="hostSearchInput()" />
</div>
<div class='table-responsive'>
<table class="m-0 card-body table table-striped overflow-x-scroll">
<thead>
<th>
<input type="checkbox"
onclick="$('.host_checkbox:visible').each((i, element)=>{
$(element).prop('checked', $(this).prop('checked'));
})"
/>
<br />
<button type="button" class="btn btn-sm btn-danger" onclick="$('.host_checkbox:checked').each((i, element)=>{
app.api.delete(`host/${$(element).parents('[jq-repeat-index]').attr('jq-repeat-index')}`, function(){});
})">
<i class="fa-solid fa-trash-can"></i>
</button>
</th>
<th>SSL Expire</th>
<th>Host Name</th>
<th>target</th>
<th class="hidden-xs">Updated</th>
<th>Actions</th>
</thead>
<tbody>
<tr action="api" jq-repeat="hosts" jq-index-key='host' style="display:none">
<td>
<input type="checkbox" class="host_checkbox">
</td>
<td class="table-{{wildcard_text_bg}}">
{{{ wildcard_text }}}
{{#wildcard_expires}}
<span class="momentFromNow" data-date="{{ wildcard_expires }}" >{{wildcard_expires_text}}</span>
{{/wildcard_expires}}
</td>
<td>
<a target="_blank" href="{{ forcessl_text }}{{ host }}">
{{{ forcessl_text }}}{{ host }}
</a>
{{#domain.provider}}
<br />
<img width="24px" src="{{displayIconHtml}}" /> {{displayName}} - {{name}}
{{/domain.provider}}
{{#wildcard_parent}}
<i>{{wildcard_parent}}</i>
{{/wildcard_parent}}
</td>
<td>
{{{ targetssl_text }}}{{ ip }}:{{ targetPort }}
</td>
<td class="hidden-xs momentFromNow" data-date="{{ updated_on }}" >
{{ updated_on_text }}
</td>
<td>
<div class="btn-group">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-brands fa-expeditedssl"></i>
Certs
</button>
<ul class="dropdown-menu">
<li>
<button type="button" class="dropdown-item" onclick="hostDownloadCert('{{host}}', 'cert_pem')">
<i class="fa-solid fa-certificate"></i>
Cert
<i class="fa-solid fa-file-arrow-down float-end"></i>
</button>
</li>
<li>
<button type="button" class="dropdown-item" onclick="hostDownloadCert('{{host}}', 'fullchain_pem')">
<i class="fa-solid fa-link"></i>
Full Chain
<i class="fa-solid fa-file-arrow-down float-end"></i>
</button>
</li>
<li>
<button type="button" class="dropdown-item" onclick="hostDownloadCert('{{host}}', 'privkey_pem')">
<i class="fa-solid fa-key"></i>
Private key
<i class="fa-solid fa-file-arrow-down float-end"></i>
</button>
</li>
</ul>
</div>
<button type="button" onclick="hostEditOpen('{{ host }}');" class="btn btn-sm btn-warning">
<i class="fa-solid fa-pencil"></i>
Edit
</button>
<button type="button" method="DELETE" action="host/{{host}}" onclick="formAJAX()" class="btn btn-sm btn-danger">
<i class="fa-solid fa-trash-can"></i>
Delete
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Add / Edit host modal ------------------------------------------------- -->
<div class="modal fade" id="hostModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content card border-0">
<div class="modal-header">
<h5 class="modal-title" id="hostModalTitle">Add host</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="card-header actionMessage m-0" style="display:none"></div>
<div class="modal-body">
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item"><button class="nav-link active" id="hostTab-general-btn" data-bs-toggle="tab" data-bs-target="#hostTab-general" type="button" role="tab">General</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-tls-btn" data-bs-toggle="tab" data-bs-target="#hostTab-tls" type="button" role="tab">TLS &amp; Wildcard</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-traffic-btn" data-bs-toggle="tab" data-bs-target="#hostTab-traffic" type="button" role="tab">Traffic</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-headers-btn" data-bs-toggle="tab" data-bs-target="#hostTab-headers" type="button" role="tab">Headers</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-access-btn" data-bs-toggle="tab" data-bs-target="#hostTab-access" type="button" role="tab">Access</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-auth-btn" data-bs-toggle="tab" data-bs-target="#hostTab-auth" type="button" role="tab">Authentication</button></li>
</ul>
<form class="addHost" id="hostForm" method="POST" action="host" onsubmit="formAJAX(this)" evalAJAX="hostModalClose()">
<div class="tab-content pt-3">
<!-- General -->
<div class="tab-pane fade show active" id="hostTab-general" role="tabpanel">
<div class="form-group">
<label for="host" class="form-label">Incoming host name</label>
<input type="text" name="host" class="form-control" placeholder="ex: app.example.com, *.example.com, **.example.com, or **" validate="host">
<b class="invalid-feedback"></b>
<small class="field-help text-muted d-block">
The public hostname clients request. Use <code>*.example.com</code>
for one subdomain level, <code>**.example.com</code> for any depth,
or <code>**</code> as a catch-all.
</small>
</div>
<div class="form-group">
<label class="form-label">Incoming SSL</label>
<div class="radio"><label>
<input type="radio" name="forcessl" id="forcessl-true" value="true" checked>
Force HTTPS <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="forcessl" id="forcessl-false" value="false">
Allow both HTTP and HTTPS
</label></div>
<small class="field-help text-muted d-block">Redirect plain HTTP requests to HTTPS.</small>
</div>
<hr>
<div class="form-group">
<label for="ip" class="form-label">Target IP or host name</label>
<input type="text" name="ip" class="form-control" placeholder="ex: 10.10.10.10, app.internal.net, or sso-manager" validate="target:3" />
<b class="invalid-feedback"></b>
<small class="field-help text-muted d-block">Where matching requests are proxied. Hostname or IP only &mdash; no protocol, port, or path.</small>
</div>
<div class="row">
<div class="col form-group">
<label for="targetPort" class="form-label">Target TCP port</label>
<input type="number" name="targetPort" class="form-control" value="80" min="0" max="65535" />
<b class="invalid-feedback"></b>
</div>
<div class="col form-group">
<label class="form-label">Target SSL</label>
<div class="radio"><label>
<input type="radio" name="targetssl" id="targetssl-false" value="false" checked>
Proxy to HTTP <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="targetssl" id="targetssl-true" value="true">
Proxy to HTTPS
</label></div>
</div>
</div>
</div>
<!-- TLS & Wildcard -->
<div class="tab-pane fade" id="hostTab-tls" role="tabpanel">
<div class="form-group autoSll">
<label class="form-label">
SSL <a href="https://letsencrypt.org/docs/challenge-types/" target="_blank">validation type</a>
</label>
<div class="radio" id="challengeType-HTTP-01-container"><label>
<input type="radio" name="challengeType" id="challengeType-HTTP-01" value="HTTP-01" checked>
HTTP-01
</label></div>
<div class="radio challengeType-container" id="challengeType-DNS-01-wildcard-container"><label>
<input type="radio" name="challengeType" id="challengeType-DNS-01-wildcard" value="DNS-01-wildcard">
DNS-01 Wildcard
</label></div>
<div class="radio challengeType-container" id="challengeType-child-container"><label>
<input type="radio" name="challengeType" id="challengeType-wildcardChild" value="wildcardChild">
Parent Wildcard from <i id="challengeType-child-relatedHost"></i>
</label></div>
<small class="field-help text-muted d-block">
Options light up based on the host name: wildcard certs need a DNS
provider for the domain; child hosts reuse a parent wildcard.
</small>
</div>
<div class="form-group challengeType-container" id="wildcard_matchAny-container">
<label class="form-label">Wildcard matching</label>
<div class="radio"><label>
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-false" value="false" checked>
Match only subdomains defined here <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-true" value="true">
Match any subdomain and proxy to this host
</label></div>
</div>
</div>
<!-- Traffic -->
<div class="tab-pane fade" id="hostTab-traffic" role="tabpanel">
<div class="form-group">
<label class="form-label">Rate limiting</label>
<div class="radio"><label>
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-false" value="false" checked>
Off <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-true" value="true">
Limit requests per client IP
</label></div>
</div>
<div class="row">
<div class="col form-group">
<label for="ratelimit_rate" class="form-label">Requests / sec</label>
<input type="number" name="ratelimit_rate" class="form-control" value="10" min="1" max="1000000" />
</div>
<div class="col form-group">
<label for="ratelimit_burst" class="form-label">Burst</label>
<input type="number" name="ratelimit_burst" class="form-control" value="20" min="0" max="1000000" />
</div>
</div>
<small class="field-help text-muted d-block mb-3">Token bucket per client IP; bursts above the rate are queued, then rejected with 429.</small>
<hr>
<div class="form-group">
<label class="form-label">Response caching</label>
<div class="radio"><label>
<input type="radio" name="respcache_enabled" id="respcache_enabled-false" value="false" checked>
Off <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="respcache_enabled" id="respcache_enabled-true" value="true">
Cache cacheable responses
</label></div>
<small class="field-help text-muted d-block">Cache upstream responses that declare themselves cacheable.</small>
</div>
<div class="form-group">
<label class="form-label">HSTS</label>
<div class="radio"><label>
<input type="radio" name="hsts_enabled" id="hsts_enabled-false" value="false" checked>
Off
</label></div>
<div class="radio"><label>
<input type="radio" name="hsts_enabled" id="hsts_enabled-true" value="true">
Send Strict-Transport-Security
</label></div>
<small class="field-help text-muted d-block">Tells browsers to only use HTTPS for this host. Enable once HTTPS is confirmed working.</small>
</div>
</div>
<!-- Headers -->
<div class="tab-pane fade" id="hostTab-headers" role="tabpanel">
<div class="form-group">
<label for="req_headers" class="form-label">Upstream request headers</label>
<textarea name="req_headers" class="form-control" rows="3" placeholder="Name: value, one per line"></textarea>
<small class="field-help text-muted d-block">Added to each request sent to the target. One <code>Name: value</code> per line.</small>
</div>
<div class="form-group">
<label for="resp_headers" class="form-label">Response headers</label>
<textarea name="resp_headers" class="form-control" rows="3" placeholder="Name: value, one per line"></textarea>
<small class="field-help text-muted d-block">Added to each response returned to the client.</small>
</div>
</div>
<!-- Access -->
<div class="tab-pane fade" id="hostTab-access" role="tabpanel">
<h6 class="text-muted">IP access</h6>
<div class="form-group">
<label for="ip_allow" class="form-label">Allow IPs / CIDRs</label>
<textarea name="ip_allow" class="form-control" rows="2" placeholder="one per line; if set, only these are allowed"></textarea>
<small class="field-help text-muted d-block">If non-empty, only these sources may connect (default-deny).</small>
</div>
<div class="form-group">
<label for="ip_deny" class="form-label">Deny IPs / CIDRs</label>
<textarea name="ip_deny" class="form-control" rows="2" placeholder="one per line; these are blocked"></textarea>
<small class="field-help text-muted d-block">These sources are always blocked (deny wins over allow).</small>
</div>
</div>
<!-- Authentication -->
<div class="tab-pane fade" id="hostTab-auth" role="tabpanel">
<p class="field-help text-muted">
Pick one authentication method for this host &mdash; basic auth and
SSO can't both be enabled, to avoid ambiguity about which one
actually protected a request. Choose "Off" for a public host.
</p>
<div class="form-group">
<div class="radio"><label>
<input type="radio" id="auth_mode-none" value="none" checked onchange="hostAuthModeChanged('none')">
Off (public)
</label></div>
<div class="radio"><label>
<input type="radio" id="auth_mode-basic" value="basic" onchange="hostAuthModeChanged('basic')">
Basic authentication
</label></div>
<div class="radio"><label>
<input type="radio" id="auth_mode-sso" value="sso" onchange="hostAuthModeChanged('sso')">
Single sign-on (SSO)
</label></div>
</div>
<!-- Actually-submitted fields; kept in sync with the radios above by
hostAuthModeChanged() so only one can be true at a time. -->
<input type="hidden" name="basicauth_enabled" id="basicauth_enabled-hidden" value="false">
<input type="hidden" name="sso_enabled" id="sso_enabled-hidden" value="false">
<div id="hostTab-auth-basicFields" style="display:none">
<hr>
<h6 class="text-muted">Basic authentication</h6>
<div class="form-group">
<label for="basicauth_realm" class="form-label">Realm</label>
<input type="text" name="basicauth_realm" class="form-control" value="Restricted" placeholder="Restricted" />
</div>
<div class="form-group">
<label for="basicauth_users" class="form-label">Users</label>
<textarea name="basicauth_users" class="form-control" rows="2" placeholder="username:password, one per line"></textarea>
<small class="field-help text-muted d-block">
Current: <span class="basicauth-current">none</span>.
Passwords are stored hashed and never shown here. Leave blank to keep
the current users; entering any lines replaces the whole list. To
manage individual users (delete / change password), use the table
below once the host has been saved.
</small>
</div>
</div>
<div id="hostTab-auth-ssoFields" style="display:none">
<hr>
<h6 class="text-muted">Single sign-on (SSO)</h6>
<p class="field-help text-muted">Gates the site behind the same identity provider the admin app uses. Empty allow-lists below mean any authenticated user is allowed.</p>
<div class="form-group">
<label for="sso_allow_users" class="form-label">Allowed users</label>
<div class="input-group mb-1">
<input type="text" class="form-control" list="hostSsoUsers" placeholder="type to search users…"
onkeydown="if(event.key==='Enter'){event.preventDefault();allowListAdd(this,'sso_allow_users');}">
<button type="button" class="btn btn-outline-secondary" onclick="allowListAdd(this.previousElementSibling,'sso_allow_users')">
<i class="fa-solid fa-plus"></i> Add
</button>
</div>
<textarea name="sso_allow_users" class="form-control" rows="2" placeholder="one email/username per line; blank = any authenticated user"></textarea>
</div>
<div class="form-group">
<label for="sso_allow_groups" class="form-label">Allowed groups</label>
<div class="input-group mb-1">
<input type="text" class="form-control" list="hostSsoGroups" placeholder="type to search groups…"
onkeydown="if(event.key==='Enter'){event.preventDefault();allowListAdd(this,'sso_allow_groups');}">
<button type="button" class="btn btn-outline-secondary" onclick="allowListAdd(this.previousElementSibling,'sso_allow_groups')">
<i class="fa-solid fa-plus"></i> Add
</button>
</div>
<textarea name="sso_allow_groups" class="form-control" rows="2" placeholder="one group per line; blank = any authenticated user"></textarea>
</div>
</div>
<div id="hostTab-auth-basicUsersMgmt" style="display:none">
<hr>
<h6 class="text-muted">Manage basic-auth users</h6>
<div class="table-responsive">
<table class="table table-sm">
<thead><tr><th>Username</th><th>New password</th><th></th></tr></thead>
<tbody id="basicAuthUserRows"></tbody>
</table>
</div>
</div>
</div>
</div>
<datalist id="hostSsoUsers"></datalist>
<datalist id="hostSsoGroups"></datalist>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fa-solid fa-ban"></i> Cancel
</button>
<button type="submit" form="hostForm" class="btn btn-success">
<i class="fa-solid fa-floppy-disk"></i>
<span id="hostModalSubmitText">Add host</span>
</button>
</div>
</div>
</div>
</div>
<%- include('bottom') %>