a9dfebc481
The target ("ip") field validator required at least two dot-separated
labels, rejecting legitimate single-label hostnames like Docker Compose
service names ("sso-manager"), /etc/hosts entries, or anything resolved
via a search domain. This was enforced identically client-side
(public/lib/js/val.js) and server-side (utils/hostname_validate.js,
routes/host.js), so there was no way to set one through the UI or API
— only by writing to the Host model directly, bypassing validation
entirely (which is how theta-env's setup.sh registers sso-manager as a
target today, working only because it calls Host.create() directly).
Relax HOSTNAME in both places to accept either a bare single label or
the existing dotted-FQDN pattern. Flips the one existing test that
codified the old behavior (isValidHostname('localhost') was asserted
false) and adds coverage for the reported case.
727 lines
29 KiB
Plaintext
Executable File
727 lines
29 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();
|
|
}
|
|
|
|
// 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');
|
|
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');
|
|
|
|
// 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 & 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 — 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">
|
|
Basic auth and SSO are OR'd — if either is enabled, a request
|
|
is allowed when it passes <b>either</b> one. Leave both off for a
|
|
public host.
|
|
</p>
|
|
|
|
<h6 class="text-muted">Basic authentication</h6>
|
|
<div class="form-group">
|
|
<div class="radio"><label>
|
|
<input type="radio" name="basicauth_enabled" id="basicauth_enabled-false" value="false" checked>
|
|
Off
|
|
</label></div>
|
|
<div class="radio"><label>
|
|
<input type="radio" name="basicauth_enabled" id="basicauth_enabled-true" value="true">
|
|
Require username / password
|
|
</label></div>
|
|
</div>
|
|
<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.
|
|
</small>
|
|
</div>
|
|
|
|
<hr>
|
|
<h6 class="text-muted">Single sign-on (SSO)</h6>
|
|
<div class="form-group">
|
|
<div class="radio"><label>
|
|
<input type="radio" name="sso_enabled" id="sso_enabled-false" value="false" checked>
|
|
Off
|
|
</label></div>
|
|
<div class="radio"><label>
|
|
<input type="radio" name="sso_enabled" id="sso_enabled-true" value="true">
|
|
Require login via the configured OIDC provider
|
|
</label></div>
|
|
<small class="field-help text-muted d-block">Gates the site behind the same identity provider the admin app uses. Empty allow-lists below mean any authenticated user is allowed.</small>
|
|
</div>
|
|
|
|
<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>
|
|
<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') %>
|