Add firewall management over SSH for UFW and iptables/ip6tables: view and edit rules and default policies, persist iptables rules, SSH lockout protection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+188
-3
@@ -41,6 +41,7 @@ const ICONS = {
|
||||
download: '<svg viewBox="0 0 24 24"><path d="M12 4v12M6 10l6 6 6-6M4 20h16"/></svg>',
|
||||
upload: '<svg viewBox="0 0 24 24"><path d="M12 20V8M6 14l6-6 6 6M4 4h16"/></svg>',
|
||||
docker: '<svg viewBox="0 0 24 24"><path d="M2 12h19c-.6 4.5-4 8-10 8-5 0-8-3-9-8z"/><path d="M5 12V9h3v3M8 12V9h3v3M11 12V9h3v3M8 9V6h3v3M21 12c.5-1.5 0-3-1-3.5"/></svg>',
|
||||
wall: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="1"/><path d="M3 9.3h18M3 14.7h18M9 4v5.3M15 4v5.3M6 9.3v5.4M12 9.3v5.4M18 9.3v5.4M9 14.7V20M15 14.7V20"/></svg>',
|
||||
logs: '<svg viewBox="0 0 24 24"><path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/></svg>',
|
||||
};
|
||||
const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100'];
|
||||
@@ -389,7 +390,7 @@ function hostMenu(hst) {
|
||||
return [
|
||||
{ label: hst.protocol === 'rdp' ? T('Connect in tab') : T('Connect'), icon: 'play', run: () => connectHost(hst) },
|
||||
...(hst.protocol === 'rdp' ? [{ label: T('Open in separate window'), icon: 'screen', run: () => launchRdp(hst) }] : []),
|
||||
...(hst.protocol !== 'rdp' ? [{ label: T('Open SFTP'), icon: 'folder', run: () => openSftp(hst) }, { label: T('Docker containers'), icon: 'docker', run: () => openDocker(hst) }] : []),
|
||||
...(hst.protocol !== 'rdp' ? [{ label: T('Open SFTP'), icon: 'folder', run: () => openSftp(hst) }, { label: T('Docker containers'), icon: 'docker', run: () => openDocker(hst) }, { label: T('Firewall'), icon: 'wall', run: () => openFirewall(hst) }] : []),
|
||||
'-',
|
||||
{ label: T('Edit'), icon: 'edit', run: () => editHost(hst) },
|
||||
{ label: T('Duplicate'), icon: 'dup', run: async () => { const { id, createdAt, updatedAt, ...rest } = hst; await call('vault:upsert', 'hosts', { ...rest, label: (hst.label || hst.address) + T(' (copy)') }); reload(); } },
|
||||
@@ -1160,7 +1161,7 @@ function addTab(session) {
|
||||
tab.onclick = (e) => { if (e.target.closest('.x')) return closeTab(session.id); activateTab(session.id); };
|
||||
tab.onauxclick = (e) => { if (e.button === 1) closeTab(session.id); };
|
||||
tab.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
|
||||
...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => ({ sftp: openSftp, docker: openDocker }[session.kind] || openTerminal)(session.host) }] : []),
|
||||
...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => ({ sftp: openSftp, docker: openDocker, firewall: openFirewall }[session.kind] || openTerminal)(session.host) }] : []),
|
||||
...(session.kind === 'ssh' ? [{ label: T('Reconnect'), icon: 'refresh', run: () => session.reconnect() }] : []),
|
||||
{ label: T('Rename'), icon: 'edit', run: async () => { const n = await promptBox(T('Rename tab'), T('Title'), session.title); if (n) { session.title = n; $('span:nth-child(2)', tab).textContent = n; } } },
|
||||
'-', { label: T('Close'), icon: 'close', run: () => closeTab(session.id) },
|
||||
@@ -1207,7 +1208,7 @@ class TerminalSession {
|
||||
this.el = h(`<div class="session">
|
||||
<div class="sbar"><div class="info">${avatar(host, 22).replace('<span class="proto">SSH</span>', '')}<span>${esc(hostSub(host))}</span></div>
|
||||
<button class="btn ghost sm" data-a="sftp" title="${T('SFTP for this host')}">${ICONS.folder}SFTP</button>
|
||||
${host.execCommand ? '' : `<button class="btn ghost sm" data-a="docker" title="${T('Docker containers')}">${ICONS.docker}Docker</button>`}
|
||||
${host.execCommand ? '' : `<button class="btn ghost sm" data-a="docker" title="${T('Docker containers')}">${ICONS.docker}Docker</button><button class="btn ghost sm" data-a="firewall" title="${T('Firewall')}">${ICONS.wall}${T('Firewall')}</button>`}
|
||||
<button class="btn ghost sm" data-a="snip" title="Snippets">${ICONS.code}Snippets</button>
|
||||
<button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button>
|
||||
</div>
|
||||
@@ -1266,6 +1267,7 @@ class TerminalSession {
|
||||
const a = e.target.closest('[data-a]')?.dataset.a;
|
||||
if (a === 'sftp') openSftp(this.host);
|
||||
if (a === 'docker') openDocker(this.host);
|
||||
if (a === 'firewall') openFirewall(this.host);
|
||||
if (a === 'snip') { $('.snip-panel', this.el).classList.toggle('open'); this.renderSnips(); this.doFit(); }
|
||||
if (a === 'snipnew') editSnippet();
|
||||
if (a === 'find') this.toggleFind();
|
||||
@@ -1787,6 +1789,188 @@ class DockerSession {
|
||||
}
|
||||
function openDocker(host) { return new DockerSession(host); }
|
||||
|
||||
// ============================================================ Firewall (UFW / iptables über SSH)
|
||||
class FirewallSession {
|
||||
constructor(host) {
|
||||
this.kind = 'firewall'; this.id = uid(); this.host = host;
|
||||
this.title = `${T('Firewall')} · ${host.label || host.address}`;
|
||||
this.backend = null; this.data = null;
|
||||
this.el = h(`<div class="session firewall">
|
||||
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
|
||||
<div class="seg sm backends"></div>
|
||||
<button class="btn ghost sm" data-a="save" style="display:none" title="${esc(T('Save the current iptables rules so they survive a reboot'))}">${ICONS.download}${T('Save permanently')}</button>
|
||||
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
||||
<button class="btn primary sm" data-a="add" disabled>${ICONS.plus}${T('Add rule')}</button>
|
||||
</div>
|
||||
<div class="docker-body fw-body"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div></div>`);
|
||||
this.body = $('.fw-body', this.el);
|
||||
this.el.addEventListener('click', (e) => {
|
||||
const a = e.target.closest('[data-a]')?.dataset.a;
|
||||
if (a === 'refresh') this.refresh();
|
||||
if (a === 'add') this.backend === 'ufw' ? this.addUfw() : this.addIpt();
|
||||
if (a === 'save') this.op('ipt', 'save', {}, T('Rules saved permanently'));
|
||||
});
|
||||
this.unsub = api.on('firewall:closed', (sid) => { if (sid === this.id) { setTabState(this, 'err'); this.error(T('Connection closed.')); } });
|
||||
addTab(this);
|
||||
this.open();
|
||||
}
|
||||
|
||||
async open() {
|
||||
try {
|
||||
const r = await api.call('firewall:open', this.id, hostRef(this.host));
|
||||
$('.rt', this.el).textContent = r.sudo ? ' · sudo' : ' · root';
|
||||
const opts = [...(r.tools.includes('ufw') ? [['ufw', 'UFW']] : []), ...(r.tools.includes('iptables') ? [['iptables', 'iptables']] : []), ...(r.tools.includes('ip6tables') ? [['ip6tables', 'ip6tables']] : [])];
|
||||
const seg = $('.backends', this.el);
|
||||
opts.forEach(([id, l]) => { const b = h(`<button data-b="${id}">${l}</button>`); b.onclick = () => this.switchTo(id); seg.append(b); });
|
||||
setTabState(this, 'on');
|
||||
$('[data-a=add]', this.el).disabled = false;
|
||||
await this.switchTo(opts[0][0]);
|
||||
} catch (e) {
|
||||
setTabState(this, 'err');
|
||||
this.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
error(msg) {
|
||||
this.body.innerHTML = `<div class="pane-empty"><div style="color:var(--red);max-width:560px;text-align:center">${esc(msg)}</div></div>`;
|
||||
const b = h(`<button class="btn primary">${ICONS.refresh}${T('Try again')}</button>`);
|
||||
b.onclick = () => { closeTab(this.id); openFirewall(this.host); };
|
||||
$('.pane-empty', this.body).append(b);
|
||||
}
|
||||
|
||||
async switchTo(b) {
|
||||
this.backend = b;
|
||||
$$('.backends button', this.el).forEach((x) => x.classList.toggle('active', x.dataset.b === b));
|
||||
$('[data-a=save]', this.el).style.display = b === 'ufw' ? 'none' : '';
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try { this.data = await api.call('firewall:list', this.id, this.backend); this.draw(); } catch (e) { toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// Aktion ausführen, danach neu laden
|
||||
async op(kind, op, args, okMsg) {
|
||||
try { await call(kind === 'ufw' ? 'firewall:ufw' : 'firewall:ipt', this.id, op, args); if (okMsg) toast(okMsg, 'ok'); } catch { return false; }
|
||||
await this.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
sshPort() { return String(this.host.port || 22); }
|
||||
|
||||
draw() {
|
||||
const scroll = this.body.scrollTop;
|
||||
this.body.innerHTML = '';
|
||||
if (this.backend === 'ufw') this.drawUfw(); else this.drawIpt();
|
||||
this.body.scrollTop = scroll;
|
||||
}
|
||||
|
||||
drawUfw() {
|
||||
const d = this.data;
|
||||
const on = d.status === 'active';
|
||||
const head = h(`<div class="fw-head"><span class="status-pill ${on ? 'on' : ''}">${on ? T('active') : T('inactive')}</span></div>`);
|
||||
const tgl = h(`<button class="btn sm ${on ? '' : 'primary'}">${on ? T('Disable') : T('Enable')}</button>`);
|
||||
tgl.onclick = async () => {
|
||||
if (on) { if (await confirmBox(T('Disable firewall?'), T('All UFW rules will stop filtering traffic.'), T('Disable'))) this.op('ufw', 'disable', {}); return; }
|
||||
const sshOk = d.rules.some((r) => r.action !== 'DENY' && r.action !== 'REJECT' && r.dir === 'IN' && new RegExp(`(^|[^0-9])${this.sshPort()}(/tcp)?($|[^0-9])|OpenSSH|ssh`, 'i').test(r.to));
|
||||
if (!sshOk) {
|
||||
const r = await modal({ title: T('Enable firewall?'), text: T('There is no rule that allows SSH (port {port}). Enabling UFW could lock you out of this server.', { port: this.sshPort() }),
|
||||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Enable anyway'), value: 'force', cls: 'danger' }, { label: T('Allow SSH and enable'), value: 'ssh', cls: 'primary' }] });
|
||||
if (!r) return;
|
||||
if (r === 'ssh' && !(await this.op('ufw', 'add', { action: 'allow', dir: 'in', port: this.sshPort(), proto: 'tcp', comment: 'SSH (MrTerm)' }))) return;
|
||||
}
|
||||
this.op('ufw', 'enable', {}, T('Firewall enabled'));
|
||||
};
|
||||
head.append(tgl);
|
||||
if (d.defaults) {
|
||||
for (const dir of ['incoming', 'outgoing']) {
|
||||
const sel = h(`<label class="fw-def">${dir === 'incoming' ? T('Incoming') : T('Outgoing')}<select>${['allow', 'deny', 'reject'].map((p) => `<option value="${p}" ${d.defaults[dir] === p ? 'selected' : ''}>${{ allow: T('Allow'), deny: T('Deny'), reject: T('Reject') }[p]}</option>`).join('')}</select></label>`);
|
||||
$('select', sel).onchange = async (e) => {
|
||||
const pol = e.target.value;
|
||||
if (dir === 'incoming' && pol !== 'allow' && on && !(await confirmBox(T('Change default policy?'), T('Incoming connections without a matching rule will be blocked. Make sure SSH is allowed.'), T('Change')))) return this.draw();
|
||||
this.op('ufw', 'default', { policy: pol, dir });
|
||||
};
|
||||
head.append(sel);
|
||||
}
|
||||
}
|
||||
this.body.append(head);
|
||||
if (!d.rules.length) { this.body.append(h(`<div class="pane-empty" style="height:auto;padding:40px"><div style="color:var(--muted)">${T('No rules yet.')}</div></div>`)); return; }
|
||||
const t = h(`<table class="docker-table fw-table"><thead><tr><th>#</th><th>${T('To')}</th><th>${T('Action')}</th><th>${T('From')}</th><th>${T('Comment')}</th><th></th></tr></thead><tbody></tbody></table>`);
|
||||
for (const r of d.rules) {
|
||||
const tr = h(`<tr><td class="num">${r.num}</td><td><b>${esc(r.to)}</b></td><td><span class="fw-act ${r.action.toLowerCase()}">${esc(r.action)} ${esc(r.dir)}</span></td><td class="muted">${esc(r.from)}</td><td class="muted">${esc(r.comment)}</td>
|
||||
<td class="acts"><button class="btn ghost sm" title="${esc(T('Delete'))}">${ICONS.trash}</button></td></tr>`);
|
||||
$('button', tr).onclick = async () => { if (await confirmBox(T('Delete rule?'), `${r.to} · ${r.action} ${r.dir} · ${r.from}`)) this.op('ufw', 'delete', { num: r.num }, T('Rule deleted')); };
|
||||
$('tbody', t).append(tr);
|
||||
}
|
||||
this.body.append(t);
|
||||
}
|
||||
|
||||
drawIpt() {
|
||||
const fam = this.backend === 'ip6tables' ? 6 : 4;
|
||||
const builtin = ['INPUT', 'FORWARD', 'OUTPUT'];
|
||||
const chains = [...this.data.chains].sort((a, b) => ((builtin.indexOf(a.name) + 1 || 99) - (builtin.indexOf(b.name) + 1 || 99)));
|
||||
this.body.append(h(`<div class="fw-note">${T('Changes apply immediately but are lost after a reboot unless you click “Save permanently”.')}</div>`));
|
||||
for (const c of chains) {
|
||||
const sec = h(`<div class="fw-chain"><div class="fw-chain-head"><b>${esc(c.name)}</b><span class="muted">${c.rules.length} ${T('rules')}</span></div></div>`);
|
||||
if (c.policy && builtin.includes(c.name)) {
|
||||
const sel = h(`<label class="fw-def">${T('Policy')}<select>${['ACCEPT', 'DROP'].map((p) => `<option ${c.policy === p ? 'selected' : ''}>${p}</option>`).join('')}</select></label>`);
|
||||
$('select', sel).onchange = async (e) => {
|
||||
if (e.target.value === 'DROP' && c.name !== 'FORWARD' && !(await confirmBox(T('Change default policy?'), T('Traffic without a matching ACCEPT rule will be dropped. Make sure SSH is allowed, or you may lock yourself out.'), T('Change')))) return this.draw();
|
||||
this.op('ipt', 'policy', { family: fam, chain: c.name, policy: e.target.value });
|
||||
};
|
||||
$('.fw-chain-head', sec).append(sel);
|
||||
}
|
||||
if (c.rules.length) {
|
||||
const t = h(`<table class="docker-table fw-table"><tbody></tbody></table>`);
|
||||
for (const r of c.rules) {
|
||||
const tgt = (r.spec.match(/-j (\S+)/) || [])[1] || '';
|
||||
const tr = h(`<tr><td class="num" style="width:36px">${r.num}</td><td class="mono spec">${esc(r.spec)}</td><td style="width:90px"><span class="fw-act ${tgt === 'ACCEPT' ? 'allow' : /DROP|REJECT/.test(tgt) ? 'deny' : ''}">${esc(tgt)}</span></td>
|
||||
<td class="acts" style="width:50px"><button class="btn ghost sm" title="${esc(T('Delete'))}">${ICONS.trash}</button></td></tr>`);
|
||||
$('button', tr).onclick = async () => { if (await confirmBox(T('Delete rule?'), `${c.name} #${r.num}: ${r.spec}`)) this.op('ipt', 'delete', { family: fam, chain: c.name, num: r.num }, T('Rule deleted')); };
|
||||
$('tbody', t).append(tr);
|
||||
}
|
||||
sec.append(t);
|
||||
}
|
||||
this.body.append(sec);
|
||||
}
|
||||
}
|
||||
|
||||
async addUfw() {
|
||||
const r = await modal({ title: T('Add UFW rule'), body: `
|
||||
<div class="row"><div class="field"><label>${T('Action')}</label><select name="action"><option value="allow">${T('Allow')}</option><option value="deny">${T('Deny')}</option><option value="reject">${T('Reject')}</option><option value="limit">${T('Limit (rate-limit)')}</option></select></div>
|
||||
<div class="field"><label>${T('Direction')}</label><select name="dir"><option value="in">${T('Incoming')}</option><option value="out">${T('Outgoing')}</option></select></div></div>
|
||||
<div class="row"><div class="field"><label>${T('Port')}</label><input name="port" placeholder="22, 80,443, 6000:6010"/></div>
|
||||
<div class="field small"><label>${T('Protocol')}</label><select name="proto"><option value="any">${T('Any')}</option><option value="tcp">TCP</option><option value="udp">UDP</option></select></div></div>
|
||||
<div class="field"><label>${T('From (IP or network)')}</label><input name="from" placeholder="any, 192.168.0.0/24"/></div>
|
||||
<div class="field"><label>${T('Comment')}</label><input name="comment" placeholder="${esc(T('optional'))}"/></div>
|
||||
<label class="check"><input type="checkbox" name="top"/>${T('Insert at the top (highest priority)')}</label>`,
|
||||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Add'), value: 'form', cls: 'primary' }] });
|
||||
if (!r) return;
|
||||
this.op('ufw', 'add', { ...r, port: r.port.replace(/\s/g, ''), from: r.from.trim() || 'any', comment: r.comment.trim() }, T('Rule added'));
|
||||
}
|
||||
|
||||
async addIpt() {
|
||||
const fam = this.backend === 'ip6tables' ? 6 : 4;
|
||||
const chains = this.data.chains.map((c) => c.name);
|
||||
const r = await modal({ title: T('Add {bin} rule', { bin: this.backend }), body: `
|
||||
<div class="row"><div class="field"><label>${T('Chain')}</label><select name="chain">${chains.map((c) => `<option ${c === 'INPUT' ? 'selected' : ''}>${esc(c)}</option>`).join('')}</select></div>
|
||||
<div class="field"><label>${T('Target')}</label><select name="target"><option>ACCEPT</option><option>DROP</option><option>REJECT</option><option>LOG</option><option>RETURN</option></select></div></div>
|
||||
<div class="row"><div class="field small"><label>${T('Protocol')}</label><select name="proto"><option value="tcp">TCP</option><option value="udp">UDP</option><option value="${fam === 6 ? 'icmpv6' : 'icmp'}">ICMP</option><option value="all">${T('Any')}</option></select></div>
|
||||
<div class="field"><label>${T('Port')}</label><input name="port" placeholder="22, 80,443, 6000:6010"/></div></div>
|
||||
<div class="row"><div class="field"><label>${T('Source (IP or network)')}</label><input name="source" placeholder="any, ${fam === 6 ? 'fd00::/8' : '192.168.0.0/24'}"/></div>
|
||||
<div class="field small"><label>${T('Interface')}</label><input name="iface" placeholder="eth0"/></div></div>
|
||||
<div class="field"><label>${T('Connection state')}</label><select name="state"><option value="">${T('Any')}</option><option value="NEW">NEW</option><option value="ESTABLISHED,RELATED">ESTABLISHED,RELATED</option></select></div>
|
||||
<div class="field"><label>${T('Comment')}</label><input name="comment" placeholder="${esc(T('optional'))}"/></div>
|
||||
<label class="check"><input type="checkbox" name="top" checked/>${T('Insert at the top (highest priority)')}</label>`,
|
||||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Add'), value: 'form', cls: 'primary' }] });
|
||||
if (!r) return;
|
||||
this.op('ipt', 'add', { ...r, family: fam, port: r.port.replace(/\s/g, ''), source: r.source.trim(), iface: r.iface.trim(), comment: r.comment.trim() }, T('Rule added'));
|
||||
}
|
||||
|
||||
dispose() { this.unsub(); api.call('firewall:close', this.id).catch(() => {}); }
|
||||
}
|
||||
function openFirewall(host) { return new FirewallSession(host); }
|
||||
|
||||
// ============================================================ Command Palette / Quick Connect
|
||||
function openPalette() {
|
||||
if ($('.palette')) return;
|
||||
@@ -1806,6 +1990,7 @@ function openPalette() {
|
||||
items.push({ grp: 'Hosts', icon: avatar(x), label: x.label || x.address, sub: hostSub(x), run: () => connectHost(x) });
|
||||
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--folder)">${ICONS.folder}</div>`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) });
|
||||
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--accent-2)">${ICONS.docker}</div>`, label: `Docker: ${x.label || x.address}`, run: () => openDocker(x) });
|
||||
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--orange)">${ICONS.wall}</div>`, label: `${T('Firewall')}: ${x.label || x.address}`, run: () => openFirewall(x) });
|
||||
});
|
||||
S.vault.snippets.filter((s) => q && s.label.toLowerCase().includes(q)).slice(0, 5).forEach((s) => items.push({ grp: 'Snippets', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div>`, label: s.label, sub: s.command, run: () => runSnippet(s) }));
|
||||
[[T('New host'), () => { activateTab('home'); editHost({}); }], [T('Generate key'), generateKey], [T('Settings'), () => $('[data-view=settings]').click()]]
|
||||
|
||||
@@ -386,3 +386,19 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
|
||||
.docker-table .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); }
|
||||
.docker-table .dot.on { background: var(--green); }
|
||||
.docker-table .dot.wait { background: var(--orange); }
|
||||
|
||||
/* Firewall */
|
||||
.seg.sm button { padding: 4px 10px; font-size: 12px; }
|
||||
.fw-head { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
||||
.fw-def { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; margin-left: 8px; }
|
||||
.fw-def select { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; color: var(--text); }
|
||||
.fw-note { padding: 10px 16px; color: var(--faint); font-size: 12px; border-bottom: 1px solid var(--border); }
|
||||
.fw-chain-head { display: flex; align-items: center; gap: 10px; padding: 14px 16px 8px; }
|
||||
.fw-chain-head .muted { color: var(--faint); font-size: 12px; }
|
||||
.fw-chain-head .fw-def { margin-left: auto; }
|
||||
.fw-table td.spec { font-size: 12px; white-space: normal; word-break: break-all; }
|
||||
.fw-act { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 10px; background: rgb(var(--tint) / 0.063); color: var(--muted); }
|
||||
.fw-act.allow, .fw-act.accept { background: color-mix(in srgb, var(--green) 14%, transparent); color: var(--green); }
|
||||
.fw-act.deny, .fw-act.reject { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); }
|
||||
.fw-act.limit { background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--orange); }
|
||||
.fw-table th:first-child, .fw-table td:first-child { width: 44px; text-align: left; padding-left: 16px; }
|
||||
|
||||
Reference in New Issue
Block a user