{% extends "base.twig" %}
{% block title %}{{ t('ui.admin.binkp_config.page_title', {}, locale, ['common']) }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">{{ t('ui.admin.binkp_config.heading', {}, locale, ['common']) }}</h1>
</div>
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
{{ t('ui.admin.binkp_config.restart_notice', {}, locale, ['common']) }}
</div>
<div id="binkpConfigAlert"></div>
<div class="row g-4" id="binkpConfigCards">
<div class="col-xl-6">
<div class="card shadow h-100">
<div class="card-header">
<h6 class="mb-0">{{ t('ui.admin.binkp_config.system.title', {}, locale, ['common']) }}</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label" for="systemName">{{ t('ui.admin.binkp_config.system.system_name', {}, locale, ['common']) }}</label>
<input type="text" class="form-control" id="systemName">
</div>
<div class="mb-3">
<label class="form-label" for="systemAddress">{{ t('ui.admin.binkp_config.system.system_address', {}, locale, ['common']) }}</label>
<input type="text" class="form-control" id="systemAddress">
</div>
<div class="mb-3">
<label class="form-label" for="systemSysop">{{ t('ui.admin.binkp_config.system.sysop_name', {}, locale, ['common']) }}</label>
<select class="form-select" id="systemSysop"></select>
<small class="text-muted">{{ t('ui.admin.binkp_config.system.select_admin_user', {}, locale, ['common']) }}</small>
</div>
<div class="mb-3">
<label class="form-label" for="systemLocation">{{ t('ui.admin.binkp_config.system.location', {}, locale, ['common']) }}</label>
<input type="text" class="form-control" id="systemLocation">
</div>
<div class="mb-3">
<label class="form-label" for="systemHostname">{{ t('ui.common.hostname', {}, locale, ['common']) }}</label>
<input type="text" class="form-control" id="systemHostname">
</div>
<div class="mb-3">
<label class="form-label" for="systemTimezone">{{ t('ui.admin.binkp_config.system.timezone', {}, locale, ['common']) }}</label>
<select class="form-select" id="systemTimezone">
{% for tz in timezone_list %}
<option value="{{ tz }}">{{ tz }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label" for="systemOrigin">{{ t('ui.admin.binkp_config.system.origin_line', {}, locale, ['common']) }}</label>
<input type="text" class="form-control" id="systemOrigin">
</div>
</div>
</div>
</div>
<div class="col-xl-6">
<div class="card shadow h-100">
<div class="card-header">
<h6 class="mb-0">{{ t('ui.admin.binkp_config.binkp.title', {}, locale, ['common']) }}</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label" for="binkpPort">{{ t('ui.common.port', {}, locale, ['common']) }}</label>
<input type="number" class="form-control" id="binkpPort" min="1" max="65535">
</div>
<div class="mb-3">
<label class="form-label" for="binkpTimeout">{{ t('ui.admin.binkp_config.binkp.timeout_seconds', {}, locale, ['common']) }}</label>
<input type="number" class="form-control" id="binkpTimeout" min="30" max="3600">
</div>
<div class="mb-3">
<label class="form-label" for="binkpMaxConnections">{{ t('ui.admin.binkp_config.binkp.max_connections', {}, locale, ['common']) }}</label>
<input type="number" class="form-control" id="binkpMaxConnections" min="1" max="100">
</div>
<div class="mb-3">
<label class="form-label" for="binkpOutboundQueueTimer">{{ t('ui.admin.binkp_config.binkp.outbound_queue_timer', {}, locale, ['common']) }}</label>
<input type="number" class="form-control" id="binkpOutboundQueueTimer" min="1">
<div class="form-text">{{ t('ui.admin.binkp_config.binkp.outbound_queue_timer_help', {}, locale, ['common']) }}</div>
</div>
<div class="mb-3">
<label class="form-label" for="binkpBindAddress">{{ t('ui.admin.binkp_config.binkp.bind_address', {}, locale, ['common']) }}</label>
<input type="text" class="form-control" id="binkpBindAddress">
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="binkpPreserveProcessed">
<label class="form-check-label" for="binkpPreserveProcessed">{{ t('ui.admin.binkp_config.binkp.preserve_processed_packets', {}, locale, ['common']) }}</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="binkpPreserveSent">
<label class="form-check-label" for="binkpPreserveSent">{{ t('ui.admin.binkp_config.binkp.preserve_sent_packets', {}, locale, ['common']) }}</label>
</div>
</div>
</div>
</div>
<div class="col-xl-6">
<div class="card shadow h-100">
<div class="card-header">
<h6 class="mb-0">{{ t('ui.admin.binkp_config.security.title', {}, locale, ['common']) }}</h6>
</div>
<div class="card-body">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="securityAllowInsecureInbound">
<label class="form-check-label" for="securityAllowInsecureInbound">{{ t('ui.admin.binkp_config.security.allow_insecure_inbound', {}, locale, ['common']) }}</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="securityInsecureReceiveOnly">
<label class="form-check-label" for="securityInsecureReceiveOnly">{{ t('ui.admin.binkp_config.security.insecure_receive_only', {}, locale, ['common']) }}</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="securityInsecureAllowFreq">
<label class="form-check-label" for="securityInsecureAllowFreq">{{ t('ui.admin.binkp_config.security.insecure_allow_freq', {}, locale, ['common']) }}</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="securityRequireAllowlist">
<label class="form-check-label" for="securityRequireAllowlist">{{ t('ui.admin.binkp_config.security.require_allowlist_for_insecure', {}, locale, ['common']) }}</label>
</div>
<div class="mb-3">
<label class="form-label" for="securityMaxInsecureSessions">{{ t('ui.admin.binkp_config.security.max_insecure_sessions_per_hour', {}, locale, ['common']) }}</label>
<input type="number" class="form-control" id="securityMaxInsecureSessions" min="1">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="securityAllowPlaintextFallback">
<label class="form-check-label" for="securityAllowPlaintextFallback">{{ t('ui.admin.binkp_config.security.allow_plaintext_fallback', {}, locale, ['common']) }}</label>
</div>
</div>
</div>
</div>
<div class="col-xl-6">
<div class="card shadow h-100">
<div class="card-header">
<h6 class="mb-0">{{ t('ui.admin.binkp_config.crashmail.title', {}, locale, ['common']) }}</h6>
</div>
<div class="card-body">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="crashmailEnabled">
<label class="form-check-label" for="crashmailEnabled">{{ t('ui.admin.binkp_config.crashmail.enable_crashmail', {}, locale, ['common']) }}</label>
</div>
<div class="mb-3">
<label class="form-label" for="crashmailMaxAttempts">{{ t('ui.admin.binkp_config.crashmail.max_attempts', {}, locale, ['common']) }}</label>
<input type="number" class="form-control" id="crashmailMaxAttempts" min="1">
</div>
<div class="mb-3">
<label class="form-label" for="crashmailRetryInterval">{{ t('ui.admin.binkp_config.crashmail.retry_interval_minutes', {}, locale, ['common']) }}</label>
<input type="number" class="form-control" id="crashmailRetryInterval" min="1">
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="crashmailUseNodelist">
<label class="form-check-label" for="crashmailUseNodelist">{{ t('ui.admin.binkp_config.crashmail.use_nodelist_for_routing', {}, locale, ['common']) }}</label>
</div>
<div class="mb-3">
<label class="form-label" for="crashmailFallbackPort">{{ t('ui.admin.binkp_config.crashmail.fallback_port', {}, locale, ['common']) }}</label>
<input type="number" class="form-control" id="crashmailFallbackPort" min="1" max="65535">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="crashmailAllowInsecure">
<label class="form-check-label" for="crashmailAllowInsecure">{{ t('ui.admin.binkp_config.crashmail.allow_insecure_crash_delivery', {}, locale, ['common']) }}</label>
</div>
</div>
</div>
</div>
</div>
<div class="card shadow mt-4" id="binkpUplinksCard">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">{{ t('ui.admin.binkp_config.uplinks.title', {}, locale, ['common']) }}</h6>
<button class="btn btn-sm btn-outline-primary" type="button" onclick="openUplinkModal()">{{ t('ui.admin.binkp_config.uplinks.add_uplink', {}, locale, ['common']) }}</button>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped" id="uplinksTable">
<thead>
<tr>
<th class="d-none d-sm-table-cell">{{ t('ui.admin.binkp_config.uplinks.table.me', {}, locale, ['common']) }}</th>
<th>{{ t('ui.admin.binkp_config.uplinks.table.uplink', {}, locale, ['common']) }}</th>
<th class="d-none d-sm-table-cell">{{ t('ui.admin.binkp_config.uplinks.table.domain', {}, locale, ['common']) }}</th>
<th class="d-none d-md-table-cell">{{ t('ui.admin.binkp_config.uplinks.table.host', {}, locale, ['common']) }}</th>
<th class="d-none d-lg-table-cell">{{ t('ui.admin.binkp_config.uplinks.table.schedule', {}, locale, ['common']) }}</th>
<th>{{ t('ui.admin.binkp_config.uplinks.table.status', {}, locale, ['common']) }}</th>
<th>{{ t('ui.admin.binkp_config.uplinks.table.actions', {}, locale, ['common']) }}</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<div class="mt-4">
<button class="btn btn-primary" type="button" onclick="saveBinkpConfig()">{{ t('ui.admin.binkp_config.save_configuration', {}, locale, ['common']) }}</button>
<button class="btn btn-secondary ms-2" type="button" onclick="reloadBinkpConfig()" title="{{ t('ui.admin.binkp_config.reload_title', {}, locale, ['common']) }}">
<i class="fas fa-sync-alt"></i> {{ t('ui.admin.binkp_config.reload_config', {}, locale, ['common']) }}
</button>
</div>
</div>
<div class="modal fade" id="uplinkModal" tabindex="-1" aria-labelledby="uplinkModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="uplinkModalLabel">{{ t('ui.admin.binkp_config.uplinks.modal.uplink', {}, locale, ['common']) }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ t('ui.common.close', {}, locale, ['common']) }}"></button>
</div>
<div class="modal-body">
<input type="hidden" id="uplinkIndex">
<div class="row g-3">
<div class="col-lg-7">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="uplinkMe">{{ t('ui.admin.binkp_config.uplinks.modal.me_address', {}, locale, ['common']) }}</label>
<input type="text" class="form-control" id="uplinkMe">
</div>
<div class="col-md-6">
<label class="form-label" for="uplinkAddress">{{ t('ui.admin.binkp_config.uplinks.modal.uplink_address', {}, locale, ['common']) }}</label>
<input type="text" class="form-control" id="uplinkAddress">
</div>
<div class="col-md-6">
<label class="form-label" for="uplinkDomain">{{ t('ui.admin.binkp_config.uplinks.modal.domain', {}, locale, ['common']) }}</label>
<div class="input-group">
<select class="form-select" id="uplinkDomain">
{% for network in networks %}
<option value="{{ network.domain }}">{{ network.name }} ({{ network.domain }})</option>
{% endfor %}
</select>
<a class="btn btn-outline-secondary" href="/admin/networks" title="{{ t('ui.admin.binkp_config.uplinks.modal.edit_network_settings', {}, locale, ['common']) }}" aria-label="{{ t('ui.admin.binkp_config.uplinks.modal.edit_network_settings', {}, locale, ['common']) }}">
<i class="fas fa-pen"></i>
</a>
</div>
</div>
<div class="col-md-6">
<label class="form-label" for="uplinkHostname">{{ t('ui.common.hostname', {}, locale, ['common']) }}</label>
<input type="text" class="form-control" id="uplinkHostname">
</div>
<div class="col-md-4">
<label class="form-label" for="uplinkPort">{{ t('ui.common.port', {}, locale, ['common']) }}</label>
<input type="number" class="form-control" id="uplinkPort" min="1" max="65535">
</div>
<div class="col-md-8">
<label class="form-label" for="uplinkPassword">{{ t('ui.admin.binkp_config.uplinks.modal.binkp_session_password', {}, locale, ['common']) }}</label>
<div class="input-group">
<input type="password" class="form-control" id="uplinkPassword">
<button class="btn btn-outline-secondary" type="button" onclick="toggleUplinkPasswordVisibility('uplinkPassword', this)" title="{{ t('ui.common.show_password', {}, locale, ['common']) }}" aria-label="{{ t('ui.common.show_password', {}, locale, ['common']) }}">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
<div class="col-md-6">
<label class="form-label" for="uplinkPktPassword">{{ t('ui.admin.binkp_config.uplinks.modal.packet_password', {}, locale, ['common']) }} <small class="text-muted">({{ t('ui.common.optional', {}, locale, ['common']) }})</small></label>
<div class="input-group">
<input type="password" class="form-control" id="uplinkPktPassword" maxlength="8">
<button class="btn btn-outline-secondary" type="button" onclick="toggleUplinkPasswordVisibility('uplinkPktPassword', this)" title="{{ t('ui.common.show_password', {}, locale, ['common']) }}" aria-label="{{ t('ui.common.show_password', {}, locale, ['common']) }}">
<i class="fas fa-eye"></i>
</button>
</div>
<div class="form-text">{{ t('ui.admin.binkp_config.uplinks.modal.packet_password_help', {}, locale, ['common']) }}</div>
</div>
<div class="col-md-6">
<label class="form-label" for="uplinkTicPassword">{{ t('ui.admin.binkp_config.uplinks.modal.tic_password', {}, locale, ['common']) }} <small class="text-muted">({{ t('ui.common.optional', {}, locale, ['common']) }})</small></label>
<div class="input-group">
<input type="password" class="form-control" id="uplinkTicPassword">
<button class="btn btn-outline-secondary" type="button" onclick="toggleUplinkPasswordVisibility('uplinkTicPassword', this)" title="{{ t('ui.common.show_password', {}, locale, ['common']) }}" aria-label="{{ t('ui.common.show_password', {}, locale, ['common']) }}">
<i class="fas fa-eye"></i>
</button>
</div>
<div class="form-text">{{ t('ui.admin.binkp_config.uplinks.modal.tic_password_help', {}, locale, ['common']) }}</div>
</div>
<div class="col-md-6">
<label class="form-label" for="uplinkAreafixPassword">{{ t('ui.admin.binkp_config.uplinks.modal.areafix_password', {}, locale, ['common']) }} <small class="text-muted">({{ t('ui.common.optional', {}, locale, ['common']) }})</small></label>
<div class="input-group">
<input type="password" class="form-control" id="uplinkAreafixPassword">
<button class="btn btn-outline-secondary" type="button" onclick="toggleUplinkPasswordVisibility('uplinkAreafixPassword', this)" title="{{ t('ui.common.show_password', {}, locale, ['common']) }}" aria-label="{{ t('ui.common.show_password', {}, locale, ['common']) }}">
<i class="fas fa-eye"></i>
</button>
</div>
<div class="form-text">{{ t('ui.admin.binkp_config.uplinks.modal.areafix_password_help', {}, locale, ['common']) }}</div>
</div>
<div class="col-md-6">
<label class="form-label" for="uplinkFilefixPassword">{{ t('ui.admin.binkp_config.uplinks.modal.filefix_password', {}, locale, ['common']) }} <small class="text-muted">({{ t('ui.common.optional', {}, locale, ['common']) }})</small></label>
<div class="input-group">
<input type="password" class="form-control" id="uplinkFilefixPassword">
<button class="btn btn-outline-secondary" type="button" onclick="toggleUplinkPasswordVisibility('uplinkFilefixPassword', this)" title="{{ t('ui.common.show_password', {}, locale, ['common']) }}" aria-label="{{ t('ui.common.show_password', {}, locale, ['common']) }}">
<i class="fas fa-eye"></i>
</button>
</div>
<div class="form-text">{{ t('ui.admin.binkp_config.uplinks.modal.filefix_password_help', {}, locale, ['common']) }}</div>
</div>
<div class="col-md-12">
<label class="form-label" for="uplinkSchedule">{{ t('ui.admin.binkp_config.uplinks.modal.poll_schedule', {}, locale, ['common']) }}</label>
<input type="text" class="form-control" id="uplinkSchedule">
<div class="form-text">{{ t('ui.admin.binkp_config.uplinks.modal.poll_schedule_help', {}, locale, ['common']) }}</div>
</div>
<div class="col-md-12">
<label class="form-label" for="uplinkNetworks">{{ t('ui.admin.binkp_config.uplinks.modal.networks_one_per_line', {}, locale, ['common']) }}</label>
<textarea class="form-control" id="uplinkNetworks" rows="4"></textarea>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="border rounded bg-light p-3 h-100">
<h6 class="mb-3">{{ t('ui.admin.binkp_config.uplinks.modal.connection_options', {}, locale, ['common']) }}</h6>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="uplinkEnabled">
<label class="form-check-label" for="uplinkEnabled" data-bs-toggle="tooltip" data-bs-placement="top" title="{{ t('ui.admin.binkp_config.uplinks.modal.enabled_help', {}, locale, ['common']) }}">{{ t('ui.admin.binkp_config.uplinks.table.enabled', {}, locale, ['common']) }}</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="uplinkSendDomainInAddr">
<label class="form-check-label" for="uplinkSendDomainInAddr" data-bs-toggle="tooltip" data-bs-placement="top" title="{{ t('ui.admin.binkp_config.uplinks.modal.send_domain_help', {}, locale, ['common']) }}">{{ t('ui.admin.binkp_config.uplinks.modal.send_domain', {}, locale, ['common']) }}</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="uplinkCompression">
<label class="form-check-label" for="uplinkCompression" data-bs-toggle="tooltip" data-bs-placement="top" title="{{ t('ui.admin.binkp_config.uplinks.modal.compression_help', {}, locale, ['common']) }}">{{ t('ui.admin.binkp_config.uplinks.modal.compression', {}, locale, ['common']) }}</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="uplinkCrypt">
<label class="form-check-label" for="uplinkCrypt" data-bs-toggle="tooltip" data-bs-placement="top" title="{{ t('ui.admin.binkp_config.uplinks.modal.crypt_help', {}, locale, ['common']) }}">{{ t('ui.admin.binkp_config.uplinks.modal.crypt', {}, locale, ['common']) }}</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="uplinkDefault">
<label class="form-check-label" for="uplinkDefault" data-bs-toggle="tooltip" data-bs-placement="top" title="{{ t('ui.admin.binkp_config.uplinks.modal.default_help', {}, locale, ['common']) }}">{{ t('ui.admin.binkp_config.uplinks.table.default', {}, locale, ['common']) }}</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="uplinkAllowInsecureEchomail">
<label class="form-check-label" for="uplinkAllowInsecureEchomail" data-bs-toggle="tooltip" data-bs-placement="top" title="{{ t('ui.admin.binkp_config.uplinks.modal.allow_insecure_echomail_help', {}, locale, ['common']) }}">{{ t('ui.admin.binkp_config.uplinks.modal.allow_insecure_echomail', {}, locale, ['common']) }}</label>
</div>
</div>
</div>
</div>
<div id="uplinkModalAlert" class="mt-3"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-bs-dismiss="modal">{{ t('ui.common.cancel', {}, locale, ['common']) }}</button>
<button class="btn btn-primary" type="button" onclick="saveUplink()">{{ t('ui.admin.binkp_config.uplinks.modal.save_uplink', {}, locale, ['common']) }}</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/cron-builder.js"></script>
<script>
let binkpConfig = null;
let uplinkModal;
let uplinkCheckStates = {};
const configuredNetworkDomains = {{ networks|map(n => n.domain)|json_encode|raw }};
const requestedUplinkAddress = new URLSearchParams(window.location.search).get('uplink');
function uiT(key, fallback, params = {}) {
if (window.t) {
return window.t(key, params, fallback);
}
return fallback;
}
document.addEventListener('DOMContentLoaded', function() {
uplinkModal = new bootstrap.Modal(document.getElementById('uplinkModal'));
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function(element) {
new bootstrap.Tooltip(element);
});
loadI18nNamespaces(['common', 'errors']).then(function() {
CronBuilder.init('uplinkSchedule');
loadBinkpConfig();
loadAdminUsers('systemSysop');
});
document.getElementById('uplinkModal').addEventListener('shown.bs.modal', function () {
CronBuilder.refresh('uplinkSchedule');
});
});
function loadBinkpConfig() {
setSettingsLoading('#binkpConfigCards, #binkpUplinksCard', true);
fetch('/admin/api/binkp-config')
.then(response => response.json())
.then(data => {
if (!data.success) {
throw new Error(apiError(data, uiT('ui.admin.binkp_config.load_failed', 'Failed to load config')));
}
binkpConfig = data.config || {};
populateForm();
renderUplinks();
openRequestedUplinkEditor();
setSettingsLoading('#binkpConfigCards, #binkpUplinksCard', false);
})
.catch(error => {
showBinkpAlert(error.message || uiT('ui.admin.binkp_config.load_failed', 'Failed to load config'), 'danger');
});
}
function populateForm() {
const system = binkpConfig.system || {};
document.getElementById('systemName').value = system.name || '';
document.getElementById('systemAddress').value = system.address || '';
document.getElementById('systemSysop').dataset.currentValue = system.sysop || '';
document.getElementById('systemLocation').value = system.location || '';
document.getElementById('systemHostname').value = system.hostname || '';
document.getElementById('systemTimezone').value = system.timezone || '';
document.getElementById('systemOrigin').value = system.origin || '';
syncSysopSelect('systemSysop');
const binkp = binkpConfig.binkp || {};
document.getElementById('binkpPort').value = binkp.port ?? '';
document.getElementById('binkpTimeout').value = binkp.timeout ?? '';
document.getElementById('binkpMaxConnections').value = binkp.max_connections ?? '';
document.getElementById('binkpOutboundQueueTimer').value = binkp.outbound_queue_timer_minutes ?? 30;
document.getElementById('binkpBindAddress').value = binkp.bind_address || '';
document.getElementById('binkpPreserveProcessed').checked = !!binkp.preserve_processed_packets;
document.getElementById('binkpPreserveSent').checked = !!binkp.preserve_sent_packets;
const security = binkpConfig.security || {};
document.getElementById('securityAllowInsecureInbound').checked = !!security.allow_insecure_inbound;
document.getElementById('securityInsecureReceiveOnly').checked = !!security.insecure_inbound_receive_only;
document.getElementById('securityInsecureAllowFreq').checked = !!security.insecure_allow_freq;
document.getElementById('securityRequireAllowlist').checked = !!security.require_allowlist_for_insecure;
document.getElementById('securityMaxInsecureSessions').value = security.max_insecure_sessions_per_hour ?? '';
document.getElementById('securityAllowPlaintextFallback').checked = !!security.allow_plaintext_fallback;
const crashmail = binkpConfig.crashmail || {};
document.getElementById('crashmailEnabled').checked = !!crashmail.enabled;
document.getElementById('crashmailMaxAttempts').value = crashmail.max_attempts ?? '';
document.getElementById('crashmailRetryInterval').value = crashmail.retry_interval_minutes ?? '';
document.getElementById('crashmailUseNodelist').checked = !!crashmail.use_nodelist_for_routing;
document.getElementById('crashmailFallbackPort').value = crashmail.fallback_port ?? '';
document.getElementById('crashmailAllowInsecure').checked = !!crashmail.allow_insecure_crash_delivery;
}
function gatherConfig() {
return {
system: {
name: document.getElementById('systemName').value.trim(),
address: document.getElementById('systemAddress').value.trim(),
sysop: document.getElementById('systemSysop').value,
location: document.getElementById('systemLocation').value.trim(),
hostname: document.getElementById('systemHostname').value.trim(),
timezone: document.getElementById('systemTimezone').value.trim(),
origin: document.getElementById('systemOrigin').value.trim()
},
binkp: {
port: parseInt(document.getElementById('binkpPort').value, 10),
timeout: parseInt(document.getElementById('binkpTimeout').value, 10),
max_connections: parseInt(document.getElementById('binkpMaxConnections').value, 10),
outbound_queue_timer_minutes: parseInt(document.getElementById('binkpOutboundQueueTimer').value, 10),
bind_address: document.getElementById('binkpBindAddress').value.trim(),
preserve_processed_packets: document.getElementById('binkpPreserveProcessed').checked,
preserve_sent_packets: document.getElementById('binkpPreserveSent').checked
},
uplinks: binkpConfig.uplinks || [],
security: {
allow_insecure_inbound: document.getElementById('securityAllowInsecureInbound').checked,
insecure_inbound_receive_only: document.getElementById('securityInsecureReceiveOnly').checked,
insecure_allow_freq: document.getElementById('securityInsecureAllowFreq').checked,
require_allowlist_for_insecure: document.getElementById('securityRequireAllowlist').checked,
max_insecure_sessions_per_hour: parseInt(document.getElementById('securityMaxInsecureSessions').value, 10),
allow_plaintext_fallback: document.getElementById('securityAllowPlaintextFallback').checked
},
crashmail: {
enabled: document.getElementById('crashmailEnabled').checked,
max_attempts: parseInt(document.getElementById('crashmailMaxAttempts').value, 10),
retry_interval_minutes: parseInt(document.getElementById('crashmailRetryInterval').value, 10),
use_nodelist_for_routing: document.getElementById('crashmailUseNodelist').checked,
fallback_port: parseInt(document.getElementById('crashmailFallbackPort').value, 10),
allow_insecure_crash_delivery: document.getElementById('crashmailAllowInsecure').checked
},
};
}
function validateConfig(config) {
if (!config.system.name || !config.system.address || !config.system.sysop) {
return uiT('ui.admin.binkp_config.validation.system_required', 'System name, address, and sysop are required.');
}
if (!Number.isInteger(config.binkp.port) || config.binkp.port < 1 || config.binkp.port > 65535) {
return uiT('ui.admin.binkp_config.validation.port_range', 'Binkp port must be between 1 and 65535.');
}
if (!Number.isInteger(config.binkp.timeout) || config.binkp.timeout < 1) {
return uiT('ui.admin.binkp_config.validation.timeout_positive', 'Binkp timeout must be a positive number.');
}
if (!Number.isInteger(config.binkp.max_connections) || config.binkp.max_connections < 1) {
return uiT('ui.admin.binkp_config.validation.max_connections_positive', 'Max connections must be a positive number.');
}
if (!Number.isInteger(config.binkp.outbound_queue_timer_minutes) || config.binkp.outbound_queue_timer_minutes < 1) {
return uiT('ui.admin.binkp_config.validation.outbound_queue_timer_positive', 'Outbound queue timer must be a positive number of minutes.');
}
return null;
}
function saveBinkpConfig() {
if (!binkpConfig) {
showBinkpAlert(uiT('ui.admin.binkp_config.config_not_loaded', 'Config not loaded.'), 'danger');
return;
}
const config = gatherConfig();
const error = validateConfig(config);
if (error) {
showBinkpAlert(error, 'danger');
return;
}
fetch('/admin/api/binkp-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config })
})
.then(response => response.json())
.then(data => {
if (!data.success) {
throw new Error(apiError(data, uiT('ui.admin.binkp_config.save_failed', 'Failed to save config')));
}
binkpConfig = data.config || config;
renderUplinks();
showBinkpAlert(apiMessage(data, uiT('ui.admin.binkp_config.configuration_saved', 'Configuration saved.')), 'success');
})
.catch(error => {
showBinkpAlert(error.message || uiT('ui.admin.binkp_config.save_failed', 'Failed to save config'), 'danger');
});
}
function reloadBinkpConfig() {
if (!confirm(uiT('ui.admin.binkp_config.reload_confirm', 'Reload binkp daemon configuration? This will send SIGHUP to the daemon.'))) {
return;
}
showBinkpAlert(uiT('ui.admin.binkp_config.reloading', 'Reloading configuration...'), 'info');
fetch('/admin/api/binkp-reload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
.then(response => response.json())
.then(data => {
if (!data.success) {
throw new Error(apiError(data, uiT('ui.admin.binkp_config.reload_failed', 'Failed to reload config')));
}
showBinkpAlert(
apiMessage(data, uiT('ui.admin.binkp_config.reloaded_success', 'Configuration reloaded successfully. Daemon has picked up changes.')),
'success'
);
})
.catch(error => {
showBinkpAlert(error.message || uiT('ui.admin.binkp_config.reload_failed', 'Failed to reload config'), 'danger');
});
}
function renderUplinks() {
const tbody = document.querySelector('#uplinksTable tbody');
tbody.innerHTML = '';
const uplinks = binkpConfig.uplinks || [];
if (uplinks.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">' + escapeHtml(uiT('ui.admin.binkp_config.uplinks.none_configured', 'No uplinks configured.')) + '</td></tr>';
return;
}
uplinks.forEach((uplink, index) => {
const row = document.createElement('tr');
const address = String(uplink.address || '');
const host = uplink.hostname ? escapeHtml(uplink.hostname) + (uplink.port ? ':' + uplink.port : '') : '';
const enabledBadge = uplink.enabled
? `<span class="badge bg-success">${uiT('ui.admin.binkp_config.uplinks.table.enabled', 'Enabled')}</span>`
: `<span class="badge bg-secondary">${uiT('ui.admin.binkp_config.uplinks.table.disabled', 'Disabled')}</span>`;
const defaultBadge = uplink.default
? ` <span class="badge bg-primary">${uiT('ui.admin.binkp_config.uplinks.table.default', 'Default')}</span>`
: '';
row.innerHTML = `
<td class="d-none d-sm-table-cell">${escapeHtml(uplink.me || '')}</td>
<td>${escapeHtml(address)}</td>
<td class="d-none d-sm-table-cell">${escapeHtml(uplink.domain || '')}</td>
<td class="d-none d-md-table-cell">${host}</td>
<td class="d-none d-lg-table-cell"><code>${escapeHtml(uplink.poll_schedule || '')}</code></td>
<td>${enabledBadge}${defaultBadge}</td>
<td>
<button class="btn btn-sm btn-outline-primary" type="button" onclick="openUplinkModal(${index})">${uiT('ui.common.edit', 'Edit')}</button>
${renderUplinkCheckButton(address)}
<button class="btn btn-sm btn-outline-danger ms-1" type="button" onclick="deleteUplink(${index})">${uiT('ui.common.delete', 'Delete')}</button>
</td>
`;
tbody.appendChild(row);
});
}
function openRequestedUplinkEditor() {
if (!requestedUplinkAddress) {
return;
}
const uplinks = binkpConfig.uplinks || [];
const index = uplinks.findIndex(uplink => String(uplink.address || '').toLowerCase() === requestedUplinkAddress.toLowerCase());
if (index === -1) {
showBinkpAlert(uiT('ui.admin.binkp_config.uplinks.not_found', 'Uplink not found.'), 'warning');
return;
}
openUplinkModal(index);
}
function renderUplinkCheckButton(address) {
const disabled = address === '';
const state = uplinkCheckStates[address] || 'idle';
if (state === 'checking') {
return `<button class="btn btn-sm btn-outline-secondary ms-1" type="button" disabled><span class="spinner-border spinner-border-sm" aria-hidden="true"></span><span class="visually-hidden">${escapeHtml(uiT('ui.binkp.checking', 'Checking...'))}</span></button>`;
}
if (state === 'success') {
return `<button class="btn btn-sm btn-success ms-1" type="button" onclick="checkUplinkConnectivity('${escapeJsString(address)}')" title="${escapeHtmlAttr(uiT('ui.admin.binkp_config.uplinks.check_success', 'Connectivity check succeeded'))}" aria-label="${escapeHtmlAttr(uiT('ui.admin.binkp_config.uplinks.check_success', 'Connectivity check succeeded'))}"><i class="fas fa-check"></i></button>`;
}
return `<button class="btn btn-sm btn-outline-secondary ms-1" type="button" onclick="checkUplinkConnectivity('${escapeJsString(address)}')"${disabled ? ' disabled' : ''}>${escapeHtml(uiT('ui.binkp.check_status', 'Check'))}</button>`;
}
function checkUplinkConnectivity(address) {
if (!address) {
return;
}
uplinkCheckStates[address] = 'checking';
renderUplinks();
fetch(`/api/binkp/uplink-status?address=${encodeURIComponent(address)}`)
.then(response => response.json().then(data => ({ response, data })))
.then(({ response, data }) => {
if (!response.ok || !data.success) {
throw new Error(apiError(data, uiT('ui.admin.binkp_config.uplinks.check_failed', 'Connectivity check failed')));
}
uplinkCheckStates[address] = 'success';
renderUplinks();
})
.catch(() => {
uplinkCheckStates[address] = 'idle';
renderUplinks();
window.alert(uiT('ui.admin.binkp_config.uplinks.check_failed', 'Connectivity check failed'));
});
}
function apiError(payload, fallback) {
if (window.getApiErrorMessage) {
return window.getApiErrorMessage(payload, fallback);
}
if (payload && payload.error) {
return String(payload.error);
}
return fallback;
}
function apiMessage(payload, fallback) {
if (window.getApiMessage) {
return window.getApiMessage(payload, fallback);
}
if (payload && payload.message) {
return String(payload.message);
}
return fallback;
}
function toggleUplinkPasswordVisibility(inputId, button) {
const input = document.getElementById(inputId);
if (!input || !button) {
return;
}
const icon = button.querySelector('i');
const isHidden = input.type === 'password';
const nextLabel = isHidden
? uiT('ui.common.hide_password', 'Hide password')
: uiT('ui.common.show_password', 'Show password');
input.type = isHidden ? 'text' : 'password';
button.setAttribute('title', nextLabel);
button.setAttribute('aria-label', nextLabel);
if (icon) {
icon.classList.toggle('fa-eye', !isHidden);
icon.classList.toggle('fa-eye-slash', isHidden);
}
}
function resetUplinkPasswordVisibility() {
['uplinkPassword', 'uplinkPktPassword', 'uplinkTicPassword'].forEach(function(inputId) {
const input = document.getElementById(inputId);
if (!input) {
return;
}
input.type = 'password';
const button = input.parentElement ? input.parentElement.querySelector('button') : null;
const icon = button ? button.querySelector('i') : null;
const label = uiT('ui.common.show_password', 'Show password');
if (button) {
button.setAttribute('title', label);
button.setAttribute('aria-label', label);
}
if (icon) {
icon.classList.remove('fa-eye-slash');
icon.classList.add('fa-eye');
}
});
}
function openUplinkModal(index = null) {
document.getElementById('uplinkModalAlert').innerHTML = '';
if (index === null) {
document.getElementById('uplinkModalLabel').textContent = uiT('ui.admin.binkp_config.uplinks.add_uplink', 'Add Uplink');
document.getElementById('uplinkIndex').value = '';
document.getElementById('uplinkMe').value = '';
document.getElementById('uplinkAddress').value = '';
document.getElementById('uplinkDomain').value = configuredNetworkDomains[0] || '';
document.getElementById('uplinkHostname').value = '';
document.getElementById('uplinkPort').value = 24554;
document.getElementById('uplinkPassword').value = '';
document.getElementById('uplinkPktPassword').value = '';
document.getElementById('uplinkTicPassword').value = '';
document.getElementById('uplinkAreafixPassword').value = '';
document.getElementById('uplinkFilefixPassword').value = '';
document.getElementById('uplinkSchedule').value = '0 */4 * * *';
document.getElementById('uplinkNetworks').value = '';
document.getElementById('uplinkSendDomainInAddr').checked = false;
document.getElementById('uplinkEnabled').checked = true;
document.getElementById('uplinkCompression').checked = false;
document.getElementById('uplinkCrypt').checked = false;
document.getElementById('uplinkDefault').checked = false;
document.getElementById('uplinkAllowInsecureEchomail').checked = false;
} else {
const uplink = (binkpConfig.uplinks || [])[index];
if (!uplink) {
return;
}
document.getElementById('uplinkModalLabel').textContent = uiT('ui.admin.binkp_config.uplinks.edit_uplink', 'Edit Uplink');
document.getElementById('uplinkIndex').value = index;
document.getElementById('uplinkMe').value = uplink.me || '';
document.getElementById('uplinkAddress').value = uplink.address || '';
ensureUplinkDomainOption(uplink.domain || '');
document.getElementById('uplinkDomain').value = uplink.domain || '';
document.getElementById('uplinkHostname').value = uplink.hostname || '';
document.getElementById('uplinkPort').value = uplink.port ?? 24554;
document.getElementById('uplinkPassword').value = uplink.password || '';
document.getElementById('uplinkPktPassword').value = uplink.pkt_password || '';
document.getElementById('uplinkTicPassword').value = uplink.tic_password || '';
document.getElementById('uplinkAreafixPassword').value = uplink.areafix_password || '';
document.getElementById('uplinkFilefixPassword').value = uplink.filefix_password || '';
document.getElementById('uplinkSchedule').value = uplink.poll_schedule || '';
document.getElementById('uplinkNetworks').value = Array.isArray(uplink.networks) ? uplink.networks.join('\n') : '';
document.getElementById('uplinkSendDomainInAddr').checked = !!uplink.send_domain_in_addr;
document.getElementById('uplinkEnabled').checked = !!uplink.enabled;
document.getElementById('uplinkAllowInsecureEchomail').checked = !!uplink.allow_insecure_echomail;
document.getElementById('uplinkCompression').checked = !!uplink.compression;
document.getElementById('uplinkCrypt').checked = !!uplink.crypt;
document.getElementById('uplinkDefault').checked = !!uplink.default;
}
resetUplinkPasswordVisibility();
uplinkModal.show();
}
function ensureUplinkDomainOption(domain) {
if (!domain || configuredNetworkDomains.includes(domain)) {
return;
}
const select = document.getElementById('uplinkDomain');
if (!select || Array.from(select.options).some(option => option.value === domain)) {
return;
}
const option = document.createElement('option');
option.value = domain;
option.textContent = `${domain} (${uiT('ui.admin.binkp_config.uplinks.modal.unconfigured_network', 'unconfigured')})`;
select.appendChild(option);
}
function saveUplink() {
const indexValue = document.getElementById('uplinkIndex').value;
const uplink = {
me: document.getElementById('uplinkMe').value.trim(),
address: document.getElementById('uplinkAddress').value.trim(),
domain: document.getElementById('uplinkDomain').value.trim(),
hostname: document.getElementById('uplinkHostname').value.trim(),
port: parseInt(document.getElementById('uplinkPort').value, 10),
password: document.getElementById('uplinkPassword').value.trim(),
pkt_password: document.getElementById('uplinkPktPassword').value.trim(),
tic_password: document.getElementById('uplinkTicPassword').value.trim(),
areafix_password: document.getElementById('uplinkAreafixPassword').value.trim(),
filefix_password: document.getElementById('uplinkFilefixPassword').value.trim(),
poll_schedule: document.getElementById('uplinkSchedule').value.trim(),
networks: document.getElementById('uplinkNetworks').value.split('\n').map(v => v.trim()).filter(Boolean),
send_domain_in_addr: document.getElementById('uplinkSendDomainInAddr').checked,
enabled: document.getElementById('uplinkEnabled').checked,
compression: document.getElementById('uplinkCompression').checked,
crypt: document.getElementById('uplinkCrypt').checked,
default: document.getElementById('uplinkDefault').checked,
allow_insecure_echomail: document.getElementById('uplinkAllowInsecureEchomail').checked
};
const error = validateUplink(uplink);
if (error) {
showUplinkModalAlert(error, 'danger');
return;
}
if (!Array.isArray(binkpConfig.uplinks)) {
binkpConfig.uplinks = [];
}
if (uplink.default) {
binkpConfig.uplinks = binkpConfig.uplinks.map(existing => ({ ...existing, default: false }));
}
if (indexValue === '') {
binkpConfig.uplinks.push(uplink);
} else {
binkpConfig.uplinks[parseInt(indexValue, 10)] = uplink;
}
renderUplinks();
uplinkModal.hide();
}
function deleteUplink(index) {
if (!confirm(uiT('ui.admin.binkp_config.remove_uplink_confirm', 'Remove this uplink?'))) {
return;
}
binkpConfig.uplinks.splice(index, 1);
renderUplinks();
}
function validateUplink(uplink) {
if (!uplink.hostname) {
return uiT('ui.admin.binkp_config.validation.uplink_hostname_required', 'Uplink hostname is required.');
}
if (!uplink.domain) {
return uiT('ui.admin.binkp_config.validation.uplink_domain_required', 'Network is required.');
}
if (!configuredNetworkDomains.includes(uplink.domain)) {
return uiT('ui.admin.binkp_config.validation.uplink_domain_unknown', 'Select a configured network.');
}
if (!Number.isInteger(uplink.port) || uplink.port < 1 || uplink.port > 65535) {
return uiT('ui.admin.binkp_config.validation.uplink_port_range', 'Uplink port must be between 1 and 65535.');
}
return null;
}
function showUplinkModalAlert(message, type) {
const container = document.getElementById('uplinkModalAlert');
container.innerHTML = `
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
}
function showBinkpAlert(message, type) {
const container = document.getElementById('binkpConfigAlert');
container.innerHTML = `
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
function escapeHtmlAttr(value) {
return escapeHtml(value).replace(/"/g, '"');
}
function escapeJsString(value) {
return String(value)
.replaceAll('\\', '\\\\')
.replaceAll("'", "\\'");
}
function loadAdminUsers(selectId) {
fetch('/admin/api/admin-users')
.then(response => response.json())
.then(data => {
const list = Array.isArray(data.admins) ? data.admins : [];
const select = document.getElementById(selectId);
select.innerHTML = '';
list.forEach(name => {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
select.appendChild(option);
});
syncSysopSelect(selectId);
})
.catch(() => {});
}
function syncSysopSelect(selectId) {
const select = document.getElementById(selectId);
const current = select.dataset.currentValue || '';
if (!current) {
return;
}
const exists = Array.from(select.options).some(opt => opt.value === current);
if (!exists) {
const option = document.createElement('option');
option.value = current;
option.textContent = `${current} (${uiT('ui.admin.binkp_config.current', 'current')})`;
select.insertBefore(option, select.firstChild);
}
select.value = current;
}
</script>
{% endblock %}
|