Files
proxy/nodejs/views/hosts.ejs
T
wmantly 6092468901 Add per-host reverse-proxy controls (rate limit, cache, headers, IP ACL)
Every proxied request flows through one shared OpenResty location whose
behavior is chosen at request time from the host's Redis hash. Add per-host
controls as new Host fields enforced in Lua rather than static nginx config
(which can't key off a per-request variable):

- Rate limiting: per-client-IP token bucket via resty.limit.req
  (ratelimit_enabled/rate/burst), backed by a new `ratelimit` shared dict.
- Response caching: opt-in per host via a global proxy_cache zone gated by
  $skip_cache (respcache_enabled). Off by default; upstream Cache-Control
  still honored.
- Custom/security headers: req_headers (upstream) + resp_headers (client) and
  hsts_enabled, applied in access/header_filter phases.
- IP allow/deny CIDR lists via resty.ipmatcher (deny wins; non-empty allow is
  default-deny).

New ops/nginx_conf/hostfeatures.lua holds the enforcement; proxy.conf's
access_by_lua string becomes a block that calls it, plus a header_filter block.
nodejs/utils/host_features.js is the pure, unit-tested normalize/validate layer
(header/CIDR parsing, range clamping, injection-safe values) applied in
routes/host.js and mirrored by the hosts.ejs edit form. install.sh gains the
ipmatcher rock, the cache dir, and the hostfeatures.lua symlink.

Per-host cache TTL is intentionally deferred (global default only) — see the
plan's limitations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:10:55 -04:00

691 lines
21 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: 1em;
}
/* my Div class for my search bar */
.search-wrapper {
display: flex;
gap: .5rem;
align-items: center;
margin-top: 10px;
}
/* The input bar */
input {
font-size: 1rem;
border-top-left-radius: 5px !important;
border-bottom-left-radius: 5px !important;
border-top-right-radius: 5px !important;
border-bottom-right-radius: 5px !important;
}
.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">
var $editHostForm;
// 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');
});
};
});
}
function hostEditCancle(){
$('tr.jq-repeat-hosts').each(function(idx, el){
$(el).removeClass('table-warning');
});
$.scope.editHost.remove(0);
}
// 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 hostFeatureCidrsToText(arr){
return Array.isArray(arr) ? arr.join('\n') : '';
}
function hostEditOpen(btn, host){
hostEditCancle();
console.log('host:', host)
host = $.scope.hosts.getByKey(host);
host.__jq_$el.addClass('table-warning');
$editHostForm.find('[name=is_wildcard').attr('disabled', true);
$.scope.editHost.update({...host, form: $editHostForm.html()});
if(host.is_wildcard){
$('.hostEditPanel [name="host"]').attr('disabled', true);
// Allow toggling the wildcard matching mode when editing a wildcard host.
$('.hostEditPanel #wildcard_matchAny-container').removeClass('challengeType-container');
}
$.each(host, function( key, value ) { if(typeof value == "boolean"){
$(".hostEditPanel #"+ key +"-"+ value).prop('checked', true)
}else{
$(".hostEditPanel input[name='" + key + "']").val(value);
}
});
// Object/array proxy-control fields render into textareas as text. Server
// (utils/host_features.js) parses the same text/shape back on save.
$(".hostEditPanel textarea[name='req_headers']").val(hostFeatureHeadersToText(host.req_headers));
$(".hostEditPanel textarea[name='resp_headers']").val(hostFeatureHeadersToText(host.resp_headers));
$(".hostEditPanel textarea[name='ip_allow']").val(hostFeatureCidrsToText(host.ip_allow));
$(".hostEditPanel textarea[name='ip_deny']").val(hostFeatureCidrsToText(host.ip_deny));
$('.hostEditPanel').scrollTo();
};
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(){
//search bar html event logic stolen from here
// https://github.com/WebDevSimplified/js-search-bar/blob/main/script.js
let inputValue = $(event.target).val().toLowerCase();
// on each input detected we need to get all host list that was called by the hostPopulate function which is store in
// $.scope.hosts and then we need to loop through each host and check if the host name is equal to the input value
// if it is we will display the host if not we will hide it
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(){
// Clone the new host form to be used on edit requests.
$editHostForm = $('#addHost').clone();
$editHostForm.find('hr.buttonBreak').nextAll().remove();
// $editHostForm.find('.autoSll').addClass('bg-secondary');
// Populate the host UI table
hostPopulate();
// Determine what lets encrypt challenge type the given host name can use
$hostField = $('[name=host');
$hostField.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 its a wild card, we must check if the domain has a registered
// provider.
if(host.startsWith("*.") && await verifyWildcardRequirements(host)){
$('#challengeType-DNS-01-wildcard-container').removeClass('challengeType-container');
// Wildcard matching mode only applies to wildcard hosts.
$('#wildcard_matchAny-container').removeClass('challengeType-container');
return;
}
// Check if a wildcard cert is available for the given host.
let wildcardParent = await hostMatchWildcard($hostField.val());
if(wildcardParent){
$('#challengeType-child-container').removeClass('challengeType-container');
$('#challengeType-child-relatedHost').text(wildcardParent.host);
return;
}
// If we hit here, make sure the form is reverted 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);
};
$.scope.editHost.put = function($el, item, list){
$el.slideDown();
};
$.scope.editHost.take = function($el, item, list){
$el.slideUp();
};
// app.subscribe(/^model:Host/, function(data, topic){
// console.log(topic, data);
// });
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 col-md-12 col-lg-4 col-xl-3 col-xxl-2">
<!--
left column
-->
<div jq-repeat="editHost" class="card shadow-lg border-warning hostEditPanel mb-3" style="display:none">
<!--
Edit host card
-->
<div class="card-header text-center bg-warning">
<span class="card-icon float-start">
<i class="fa-solid fa-pencil"></i>
</span>
<span class="card-title">
Edit {{ host }}
</span>
<span class="float-end">
<i class="fa-solid fa-circle-minus"></i>
<i class="fa-solid fa-circle-xmark" onclick="hostEditCancle()"></i>
</span>
</div>
<div class="card-body">
<form class="addHost" method="PUT" action="host/{{ host }}" onsubmit="formAJAX(this)" evalAJAX="hostEditCancle()">
{{{ form }}}
<input type="hidden" name="edit_host" />
<button type="submit" data-type="edit" class="btn btn-warning">
<i class="fa-solid fa-pencil"></i>
Update
</button>
<button class="btn btn-secondary" type="reset" onclick="hostEditCancle()">
<i class="fa-solid fa-ban"></i>
Cancel
</button>
</form>
</div>
</div>
<div class="card shadow-lg mb-3 hostAddPanel">
<!--
Add new host card
-->
<div class="card-header text-center">
<span class="card-icon float-start">
<i class="fa-solid fa-plus"></i>
</span>
<span class="card-title">
New Entry
</span>
<span class="float-end">
<i class="fa-solid fa-circle-minus"></i>
</span>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body d-none d-md-block">
<form class="addHost" id="addHost" method="POST" action="host" onsubmit="formAJAX(this)">
<div class="form-group">
<label class="form-label">
Incoming SSL
</label>
<br />
<div class="radio">
<label>
<input type="radio" name="forcessl" id="forcessl-true" value="true" checked>
Force incoming connections over HTTPS <b>Recommended</b>
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="forcessl" id="forcessl-false" value="false">
Allow use of both HTTP and HTTPS
</label>
</div>
</div>
<div class="form-group">
<label for="host" class="form-label">
Incoming Host Name
</label>
<div>
<input type="text" name="host" class="form-control" placeholder="ex: proxy.cloud-ops.net" validate=":3" >
<b class="invalid-feedback"></b>
</div>
</div>
<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>
</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 class="mb-3 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" validate=":3" />
<b class="invalid-feedback"></b>
</div>
<div class="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="form-group">
<label class="form-label">
Target SSL
</label>
<div class="radio">
<label>
<input type="radio" name="targetssl" id="targetssl-true" value="true">
Proxy to HTTPS
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="targetssl" id="targetssl-false" value="false" checked>
Proxy to HTTP <b>Recommended</b>
</label>
</div>
<b class="invalid-feedback"></b>
</div>
<hr />
<h6 class="text-muted">Proxy controls</h6>
<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>
<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>
</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>
</div>
<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>
</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>
</div>
<div class="form-group">
<label for="req_headers" class="form-label">Upstream request headers</label>
<textarea name="req_headers" class="form-control" rows="2" placeholder="Name: value, one per line"></textarea>
</div>
<div class="form-group">
<label for="resp_headers" class="form-label">Response headers</label>
<textarea name="resp_headers" class="form-control" rows="2" placeholder="Name: value, one per line"></textarea>
</div>
<hr class="buttonBreak" />
<button type="submit" class="btn btn-success">
<i class="fa-solid fa-plus"></i>
Add
</button>
</form>
</div>
</div>
</div>
<div class="col col-md-12 col-lg-8 col-xl-9 col-xxl-10">
<!--
Right column
-->
<div class="card shadow-lg hostListPanel">
<!--
List current hosts
-->
<div class="card-header text-center">
<span class="card-icon float-start">
<i class="fa-solid fa-network-wired"></i>
</span>
<span class="card-title">
Proxy List
</span>
<span class="float-end">
<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>
<i class="fa-solid fa-circle-minus"></i>
</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(this, '{{ 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>
<%- include('bottom') %>