Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90f0907430 | ||
|
|
128ca98030 | ||
|
|
a608948823 | ||
|
|
70a15eddbc |
@@ -32,6 +32,8 @@ sudo pacman -S freerdp gnome-keyring # use kwallet instead of gnome-keyring on
|
|||||||
- **SFTP**: two-pane file browser (local ↔ remote), drag & drop, recursive folders, rename, delete, chmod and progress display
|
- **SFTP**: two-pane file browser (local ↔ remote), drag & drop, recursive folders, rename, delete, chmod and progress display
|
||||||
- **RDP in a tab** right inside MrTerm (Windows: `mstsc`, Linux: FreeRDP), or in a separate window if you prefer
|
- **RDP in a tab** right inside MrTerm (Windows: `mstsc`, Linux: FreeRDP), or in a separate window if you prefer
|
||||||
- **Keychain**: generate Ed25519/ECDSA/RSA keys, import existing ones and copy the public key
|
- **Keychain**: generate Ed25519/ECDSA/RSA keys, import existing ones and copy the public key
|
||||||
|
- **Docker & Podman**: list a host's containers, open a shell inside a container, follow logs, and start, stop, restart or remove containers, all over SSH
|
||||||
|
- **Firewall**: view and edit UFW and iptables/ip6tables rules on your servers
|
||||||
- **Port forwarding**: local (-L), remote (-R) and dynamic/SOCKS5 (-D)
|
- **Port forwarding**: local (-L), remote (-R) and dynamic/SOCKS5 (-D)
|
||||||
- **VPN**: add WireGuard or OpenVPN configurations and assign them to hosts. MrTerm connects the VPN automatically when you open such a host
|
- **VPN**: add WireGuard or OpenVPN configurations and assign them to hosts. MrTerm connects the VPN automatically when you open such a host
|
||||||
- **Snippets**: save frequently used commands and send them to a terminal with one click
|
- **Snippets**: save frequently used commands and send them to a terminal with one click
|
||||||
@@ -67,6 +69,26 @@ For a quick one-off connection, press `Ctrl+Shift+K` and type `user@host` (or `r
|
|||||||
|
|
||||||
`Ctrl+W`, `Ctrl+K` and `Ctrl+T` still reach the terminal, so editors like nano work as usual.
|
`Ctrl+W`, `Ctrl+K` and `Ctrl+T` still reach the terminal, so editors like nano work as usual.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Right-click an SSH host and choose **Docker containers**, or click **Docker** in the toolbar of an open terminal. MrTerm connects over SSH and shows all containers on that host, with status, ports, CPU and memory.
|
||||||
|
|
||||||
|
- **Open shell**: opens a terminal tab inside the container (bash if available, otherwise sh). You can also double-click a running container.
|
||||||
|
- **Logs**: follows the container's logs live in a terminal tab.
|
||||||
|
- **Start, stop, restart, delete** from the row buttons or the right-click menu.
|
||||||
|
|
||||||
|
Nothing needs to be installed on the server. MrTerm uses the `docker` command (or `podman` if Docker isn't installed). Your SSH user needs permission to run it, which usually means membership in the `docker` group (`sudo usermod -aG docker <user>`). If a password is saved for the host, MrTerm falls back to `sudo` automatically.
|
||||||
|
|
||||||
|
## Firewall
|
||||||
|
|
||||||
|
Right-click an SSH host and choose **Firewall**, or click **Firewall** in the toolbar of an open terminal. MrTerm supports **UFW** and **iptables/ip6tables**. If a server has both, you can switch between them at the top.
|
||||||
|
|
||||||
|
- **UFW**: turn the firewall on or off, change the default policies for incoming and outgoing traffic, and add or delete rules (allow, deny, reject, limit, with port, protocol, source and comment).
|
||||||
|
- **iptables**: all chains with their rules, the policy of INPUT, FORWARD and OUTPUT, and adding or deleting rules. iptables changes are lost on reboot unless you click **Save permanently** (uses `netfilter-persistent` on Debian/Ubuntu or `/etc/iptables/*.rules` on Arch).
|
||||||
|
- **Lockout protection**: if you enable UFW without a rule that allows SSH, MrTerm warns you and offers to allow SSH first. Switching a default policy to blocking asks for confirmation.
|
||||||
|
|
||||||
|
This needs root privileges. Either log in as root, or save the password of a user with sudo rights on the host.
|
||||||
|
|
||||||
## VPN
|
## VPN
|
||||||
|
|
||||||
Under **VPN** in the sidebar you can add WireGuard (`.conf`) and OpenVPN (`.ovpn`) configurations. Paste them or load them from a file, then assign hosts, either in the VPN itself or through the *VPN* field of a host.
|
Under **VPN** in the sidebar you can add WireGuard (`.conf`) and OpenVPN (`.ovpn`) configurations. Paste them or load them from a file, then assign hosts, either in the VPN itself or through the *VPN* field of a host.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"productName": "MrTerm",
|
"productName": "MrTerm",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"description": "Moderner SSH-, SFTP- und RDP-Client",
|
"description": "Moderner SSH-, SFTP- und RDP-Client",
|
||||||
"main": "src/main/main.js",
|
"main": "src/main/main.js",
|
||||||
"author": "MrBlake",
|
"author": "MrBlake",
|
||||||
|
|||||||
+82
@@ -386,6 +386,88 @@
|
|||||||
'timeout': 'Zeitüberschreitung',
|
'timeout': 'Zeitüberschreitung',
|
||||||
'Connecting VPN “{name}” …': 'Baue VPN „{name}“ auf …',
|
'Connecting VPN “{name}” …': 'Baue VPN „{name}“ auf …',
|
||||||
|
|
||||||
|
// Docker
|
||||||
|
'Docker containers': 'Docker-Container',
|
||||||
|
'Search containers …': 'Container suchen …',
|
||||||
|
'Show stopped': 'Gestoppte anzeigen',
|
||||||
|
'Connection closed.': 'Verbindung beendet.',
|
||||||
|
'No containers on this host.': 'Keine Container auf diesem Host.',
|
||||||
|
'Ports': 'Ports',
|
||||||
|
'Memory': 'Speicher',
|
||||||
|
'Open shell': 'Shell öffnen',
|
||||||
|
'Logs': 'Logs',
|
||||||
|
'Restart': 'Neu starten',
|
||||||
|
'Copy ID': 'ID kopieren',
|
||||||
|
'Delete container?': 'Container löschen?',
|
||||||
|
'“{name}” will be removed including its writable layer. Volumes are kept.': '„{name}“ wird samt beschreibbarer Ebene entfernt. Volumes bleiben erhalten.',
|
||||||
|
'Stop container?': 'Container stoppen?',
|
||||||
|
'Started': 'Gestartet',
|
||||||
|
'Stopped': 'Gestoppt',
|
||||||
|
'Restarted': 'Neu gestartet',
|
||||||
|
'Deleted': 'Gelöscht',
|
||||||
|
'Docker session not found': 'Docker-Sitzung nicht gefunden',
|
||||||
|
'Neither Docker nor Podman was found on this host.': 'Auf diesem Host wurde weder Docker noch Podman gefunden.',
|
||||||
|
'No permission to use {runtime}. Add the user to the "docker" group (sudo usermod -aG docker {user}) or save the host password so MrTerm can use sudo.': 'Keine Berechtigung für {runtime}. Füge den Benutzer der Gruppe „docker“ hinzu (sudo usermod -aG docker {user}) oder hinterlege das Host-Passwort, damit MrTerm sudo verwenden kann.',
|
||||||
|
'{runtime} exited with code {code}': '{runtime} beendet mit Code {code}',
|
||||||
|
|
||||||
|
// Firewall
|
||||||
|
'Firewall': 'Firewall',
|
||||||
|
'Save the current iptables rules so they survive a reboot': 'Aktuelle iptables-Regeln speichern, damit sie einen Neustart überstehen',
|
||||||
|
'Save permanently': 'Dauerhaft speichern',
|
||||||
|
'Add rule': 'Regel hinzufügen',
|
||||||
|
'Rules saved permanently': 'Regeln dauerhaft gespeichert',
|
||||||
|
'Enable': 'Aktivieren',
|
||||||
|
'Disable firewall?': 'Firewall deaktivieren?',
|
||||||
|
'All UFW rules will stop filtering traffic.': 'Keine UFW-Regel filtert dann mehr den Datenverkehr.',
|
||||||
|
'Enable firewall?': 'Firewall aktivieren?',
|
||||||
|
'There is no rule that allows SSH (port {port}). Enabling UFW could lock you out of this server.': 'Es gibt keine Regel, die SSH (Port {port}) erlaubt. Wenn du UFW aktivierst, könntest du dich von diesem Server aussperren.',
|
||||||
|
'Enable anyway': 'Trotzdem aktivieren',
|
||||||
|
'Allow SSH and enable': 'SSH erlauben und aktivieren',
|
||||||
|
'Firewall enabled': 'Firewall aktiviert',
|
||||||
|
'Incoming': 'Eingehend',
|
||||||
|
'Outgoing': 'Ausgehend',
|
||||||
|
'Allow': 'Erlauben',
|
||||||
|
'Deny': 'Verweigern',
|
||||||
|
'Reject': 'Abweisen',
|
||||||
|
'Change default policy?': 'Standardrichtlinie ändern?',
|
||||||
|
'Incoming connections without a matching rule will be blocked. Make sure SSH is allowed.': 'Eingehende Verbindungen ohne passende Regel werden dann blockiert. Stelle sicher, dass SSH erlaubt ist.',
|
||||||
|
'Change': 'Ändern',
|
||||||
|
'No rules yet.': 'Noch keine Regeln.',
|
||||||
|
'To': 'Ziel',
|
||||||
|
'Action': 'Aktion',
|
||||||
|
'From': 'Quelle',
|
||||||
|
'Comment': 'Kommentar',
|
||||||
|
'Rule deleted': 'Regel gelöscht',
|
||||||
|
'Changes apply immediately but are lost after a reboot unless you click “Save permanently”.': 'Änderungen gelten sofort, gehen nach einem Neustart aber verloren, wenn du nicht auf „Dauerhaft speichern“ klickst.',
|
||||||
|
'rules': 'Regeln',
|
||||||
|
'Policy': 'Richtlinie',
|
||||||
|
'Traffic without a matching ACCEPT rule will be dropped. Make sure SSH is allowed, or you may lock yourself out.': 'Datenverkehr ohne passende ACCEPT-Regel wird dann verworfen. Stelle sicher, dass SSH erlaubt ist, sonst sperrst du dich eventuell aus.',
|
||||||
|
'Add UFW rule': 'UFW-Regel hinzufügen',
|
||||||
|
'Limit (rate-limit)': 'Begrenzen (Rate-Limit)',
|
||||||
|
'Direction': 'Richtung',
|
||||||
|
'Port': 'Port',
|
||||||
|
'Protocol': 'Protokoll',
|
||||||
|
'Any': 'Beliebig',
|
||||||
|
'From (IP or network)': 'Quelle (IP oder Netz)',
|
||||||
|
'Insert at the top (highest priority)': 'Ganz oben einfügen (höchste Priorität)',
|
||||||
|
'Add': 'Hinzufügen',
|
||||||
|
'Rule added': 'Regel hinzugefügt',
|
||||||
|
'Add {bin} rule': '{bin}-Regel hinzufügen',
|
||||||
|
'Chain': 'Kette',
|
||||||
|
'Target': 'Ziel',
|
||||||
|
'Source (IP or network)': 'Quelle (IP oder Netz)',
|
||||||
|
'Interface': 'Schnittstelle',
|
||||||
|
'Connection state': 'Verbindungsstatus',
|
||||||
|
'Invalid value for {field}: {value}': 'Ungültiger Wert für {field}: {value}',
|
||||||
|
'Firewall session not found': 'Firewall-Sitzung nicht gefunden',
|
||||||
|
'Command failed with code {code}': 'Befehl fehlgeschlagen mit Code {code}',
|
||||||
|
'Root privileges are required. Log in as root or save the password of a user with sudo rights on the host.': 'Dafür sind root-Rechte nötig. Melde dich als root an oder hinterlege am Host das Passwort eines Benutzers mit sudo-Rechten.',
|
||||||
|
'This host uses firewalld, which is not supported yet.': 'Dieser Host nutzt firewalld, das noch nicht unterstützt wird.',
|
||||||
|
'Neither UFW nor iptables was found on this host.': 'Auf diesem Host wurde weder UFW noch iptables gefunden.',
|
||||||
|
'Port ranges and lists need a protocol (TCP or UDP).': 'Portbereiche und -listen brauchen ein Protokoll (TCP oder UDP).',
|
||||||
|
'A port needs the protocol TCP or UDP.': 'Für einen Port ist das Protokoll TCP oder UDP nötig.',
|
||||||
|
'No known way to save iptables rules on this host (e.g. install iptables-persistent).': 'Keine bekannte Möglichkeit, iptables-Regeln auf diesem Host zu speichern (z. B. iptables-persistent installieren).',
|
||||||
|
|
||||||
// Main-Prozess
|
// Main-Prozess
|
||||||
'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
|
'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
|
||||||
'Unknown host': 'Unbekannter Host',
|
'Unknown host': 'Unbekannter Host',
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Docker/Podman auf entfernten Hosts über die bestehende SSH-Verbindung (CLI per exec).
|
||||||
|
// Kein Zugriff auf den Docker-Socket nötig: MrTerm führt `docker ps`, `docker start` … aus.
|
||||||
|
// Fehlt die Berechtigung (Benutzer nicht in der Gruppe "docker"), wird sudo mit dem gespeicherten Host-Passwort versucht.
|
||||||
|
const i18n = require('../i18n');
|
||||||
|
|
||||||
|
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||||
|
const ACTIONS = { start: 'start', stop: 'stop', restart: 'restart', remove: 'rm -f' };
|
||||||
|
const LIST_FMT = "'{{.ID}}\\t{{.Names}}\\t{{.Image}}\\t{{.State}}\\t{{.Status}}\\t{{.Ports}}'";
|
||||||
|
const STATS_FMT = "'{{.ID}}\\t{{.CPUPerc}}\\t{{.MemUsage}}'";
|
||||||
|
const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
|
||||||
|
const denied = (s) => /permission denied|connect to the docker daemon socket/i.test(s);
|
||||||
|
|
||||||
|
function execOn(conn, cmd, stdin) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
conn.exec(cmd, (err, stream) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
let out = '', errOut = '';
|
||||||
|
stream.on('data', (d) => { out += d; });
|
||||||
|
stream.stderr.on('data', (d) => { errOut += d; });
|
||||||
|
stream.on('close', (code) => resolve({ code: code ?? 0, out, err: errOut }));
|
||||||
|
stream.end(stdin ?? '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class DockerManager {
|
||||||
|
constructor(ssh) {
|
||||||
|
this.ssh = ssh;
|
||||||
|
this.sessions = new Map(); // id -> { conn, jumps, host, runtime, sudo }
|
||||||
|
}
|
||||||
|
|
||||||
|
async open(id, host, onClose) {
|
||||||
|
const { conn, jumps } = await this.ssh.connect(host, id);
|
||||||
|
this.sessions.set(id, { conn, jumps, host, runtime: null, sudo: false });
|
||||||
|
conn.on('close', () => { if (this.sessions.has(id)) { this.close(id); onClose(); } });
|
||||||
|
conn.on('error', () => {});
|
||||||
|
return this.list(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id) {
|
||||||
|
const s = this.sessions.get(id);
|
||||||
|
if (!s) throw new Error(i18n.t('Docker session not found'));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
run(s, args) {
|
||||||
|
const sudo = s.sudo ? "sudo -S -p '' " : '';
|
||||||
|
return execOn(s.conn, `${sudo}${s.runtime} ${args}`, s.sudo ? `${s.host.password || ''}\n` : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(id) {
|
||||||
|
const s = this.get(id);
|
||||||
|
if (!s.runtime) {
|
||||||
|
const r = await execOn(s.conn, 'for c in docker podman; do if command -v $c >/dev/null 2>&1; then echo $c; exit 0; fi; done; exit 127');
|
||||||
|
if (r.code) throw new Error(i18n.t('Neither Docker nor Podman was found on this host.'));
|
||||||
|
s.runtime = r.out.trim();
|
||||||
|
}
|
||||||
|
let r = await this.run(s, `ps -a --format ${LIST_FMT}`);
|
||||||
|
if (r.code && denied(r.err) && !s.sudo) {
|
||||||
|
s.sudo = true;
|
||||||
|
r = await this.run(s, `ps -a --format ${LIST_FMT}`);
|
||||||
|
if (r.code) s.sudo = false;
|
||||||
|
}
|
||||||
|
if (r.code) {
|
||||||
|
if (denied(r.err) || /sudo/i.test(r.err)) {
|
||||||
|
throw new Error(i18n.t('No permission to use {runtime}. Add the user to the "docker" group (sudo usermod -aG docker {user}) or save the host password so MrTerm can use sudo.', { runtime: s.runtime, user: s.host.username || '$USER' }));
|
||||||
|
}
|
||||||
|
throw new Error(lastLine(r.err) || i18n.t('{runtime} exited with code {code}', { runtime: s.runtime, code: r.code }));
|
||||||
|
}
|
||||||
|
const containers = r.out.split('\n').filter(Boolean).map((l) => {
|
||||||
|
const [cid, name, image, state, status, ports] = l.split('\t');
|
||||||
|
return { id: cid.slice(0, 12), name, image, state: (state || '').toLowerCase(), status, ports };
|
||||||
|
});
|
||||||
|
return { runtime: s.runtime, sudo: s.sudo, containers };
|
||||||
|
}
|
||||||
|
|
||||||
|
// CPU/RAM der laufenden Container (dauert ~2 s)
|
||||||
|
async stats(id) {
|
||||||
|
const s = this.get(id);
|
||||||
|
const r = await this.run(s, `stats --no-stream --format ${STATS_FMT}`);
|
||||||
|
if (r.code) return {};
|
||||||
|
return Object.fromEntries(r.out.split('\n').filter(Boolean).map((l) => {
|
||||||
|
const [cid, cpu, mem] = l.split('\t');
|
||||||
|
return [cid.slice(0, 12), { cpu, mem }];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async action(id, action, cid) {
|
||||||
|
const s = this.get(id);
|
||||||
|
if (!ACTIONS[action] || !SAFE_ID.test(cid)) throw new Error('Invalid action');
|
||||||
|
const r = await this.run(s, `${ACTIONS[action]} ${cid}`);
|
||||||
|
if (r.code) throw new Error(lastLine(r.err) || i18n.t('{runtime} exited with code {code}', { runtime: s.runtime, code: r.code }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Befehl für einen Terminal-Tab (läuft mit PTY; sudo fragt dort ggf. selbst nach dem Passwort)
|
||||||
|
command(id, kind, cid) {
|
||||||
|
const s = this.get(id);
|
||||||
|
if (!SAFE_ID.test(cid)) throw new Error('Invalid container');
|
||||||
|
const pre = `${s.sudo ? 'sudo ' : ''}${s.runtime}`;
|
||||||
|
if (kind === 'logs') return `${pre} logs -f --tail 500 ${cid}`;
|
||||||
|
return `${pre} exec -it ${cid} sh -c 'if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(id) {
|
||||||
|
const s = this.sessions.get(id);
|
||||||
|
if (!s) return;
|
||||||
|
this.sessions.delete(id);
|
||||||
|
try { s.conn.end(); } catch {}
|
||||||
|
s.jumps?.forEach((c) => { try { c.end(); } catch {} });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { DockerManager, execOn };
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// Firewall entfernter Hosts über SSH einsehen und bearbeiten: UFW sowie iptables/ip6tables (Tabelle filter).
|
||||||
|
// Braucht root: entweder als root angemeldet oder sudo mit dem gespeicherten Host-Passwort (über stdin).
|
||||||
|
// Alle Werte aus der Oberfläche werden streng geprüft, bevor sie in einen Shell-Befehl gelangen.
|
||||||
|
const i18n = require('../i18n');
|
||||||
|
const { execOn } = require('./docker');
|
||||||
|
|
||||||
|
const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
|
||||||
|
const RX = {
|
||||||
|
port: /^\d{1,5}([:,]\d{1,5})*$/,
|
||||||
|
addr: /^(any|[0-9a-fA-F.:]+(\/\d{1,3})?)$/,
|
||||||
|
comment: /^[^'"\\`$\n]{0,80}$/,
|
||||||
|
chain: /^[A-Za-z0-9_.-]{1,40}$/,
|
||||||
|
iface: /^[A-Za-z0-9_.@-]{1,15}\+?$/,
|
||||||
|
};
|
||||||
|
const check = (v, rx, what) => { if (!rx.test(String(v))) throw new Error(i18n.t('Invalid value for {field}: {value}', { field: what, value: v })); return String(v); };
|
||||||
|
|
||||||
|
// UFW-Regeln aus `ufw status numbered`
|
||||||
|
function parseUfw(out) {
|
||||||
|
const status = /Status:\s*active/i.test(out) ? 'active' : 'inactive';
|
||||||
|
const rules = [];
|
||||||
|
for (const line of out.split('\n')) {
|
||||||
|
const m = line.match(/^\[\s*(\d+)\]\s+(.+?)\s{2,}(ALLOW|DENY|REJECT|LIMIT)(?:\s+(IN|OUT|FWD))?\s+(.+?)\s*$/);
|
||||||
|
if (!m) continue;
|
||||||
|
let [, num, to, action, dir, from] = m;
|
||||||
|
let comment = '';
|
||||||
|
const c = from.match(/^(.*?)\s+#\s*(.*)$/);
|
||||||
|
if (c) { from = c[1].trim(); comment = c[2]; }
|
||||||
|
rules.push({ num: Number(num), to: to.trim(), action, dir: dir || 'IN', from: from.trim(), comment, v6: /\(v6\)/.test(to + from) });
|
||||||
|
}
|
||||||
|
return { status, rules };
|
||||||
|
}
|
||||||
|
|
||||||
|
// iptables -S: Richtlinien, Ketten und Regeln (Nummer = Position in der Kette)
|
||||||
|
function parseIptables(out) {
|
||||||
|
const chains = new Map();
|
||||||
|
const get = (n) => { if (!chains.has(n)) chains.set(n, { name: n, policy: null, rules: [] }); return chains.get(n); };
|
||||||
|
for (const line of out.split('\n')) {
|
||||||
|
let m;
|
||||||
|
if ((m = line.match(/^-P (\S+) (\S+)/))) get(m[1]).policy = m[2];
|
||||||
|
else if ((m = line.match(/^-N (\S+)/))) get(m[1]);
|
||||||
|
else if ((m = line.match(/^-A (\S+) (.*)$/))) { const c = get(m[1]); c.rules.push({ num: c.rules.length + 1, spec: m[2] }); }
|
||||||
|
}
|
||||||
|
return [...chains.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
class FirewallManager {
|
||||||
|
constructor(ssh) {
|
||||||
|
this.ssh = ssh;
|
||||||
|
this.sessions = new Map(); // id -> { conn, jumps, host, sudo, tools }
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id) {
|
||||||
|
const s = this.sessions.get(id);
|
||||||
|
if (!s) throw new Error(i18n.t('Firewall session not found'));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Befehl mit root-Rechten ausführen
|
||||||
|
run(s, cmd) {
|
||||||
|
if (!s.sudo) return execOn(s.conn, `PATH=$PATH:/usr/sbin:/sbin ${cmd}`);
|
||||||
|
return execOn(s.conn, `sudo -S -p '' ${cmd}`, `${s.host.password || ''}\n`);
|
||||||
|
}
|
||||||
|
async runOk(s, cmd) {
|
||||||
|
const r = await this.run(s, cmd);
|
||||||
|
if (r.code) throw new Error(lastLine(r.err) || lastLine(r.out) || i18n.t('Command failed with code {code}', { code: r.code }));
|
||||||
|
return r.out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async open(id, host, onClose) {
|
||||||
|
const { conn, jumps } = await this.ssh.connect(host, id);
|
||||||
|
const s = { conn, jumps, host, sudo: false, tools: [] };
|
||||||
|
this.sessions.set(id, s);
|
||||||
|
conn.on('close', () => { if (this.sessions.has(id)) { this.close(id); onClose(); } });
|
||||||
|
conn.on('error', () => {});
|
||||||
|
const uid = (await execOn(conn, 'id -u')).out.trim();
|
||||||
|
if (uid !== '0') {
|
||||||
|
s.sudo = true;
|
||||||
|
const r = await execOn(conn, "sudo -S -p '' -v", `${host.password || ''}\n`);
|
||||||
|
if (r.code) throw new Error(i18n.t('Root privileges are required. Log in as root or save the password of a user with sudo rights on the host.'));
|
||||||
|
}
|
||||||
|
const t = await this.run(s, 'sh -c \'for c in ufw iptables ip6tables firewall-cmd; do command -v $c >/dev/null 2>&1 && echo $c; done; true\'');
|
||||||
|
s.tools = t.out.split('\n').map((x) => x.trim()).filter(Boolean);
|
||||||
|
if (!s.tools.includes('ufw') && !s.tools.includes('iptables')) {
|
||||||
|
throw new Error(s.tools.includes('firewall-cmd') ? i18n.t('This host uses firewalld, which is not supported yet.') : i18n.t('Neither UFW nor iptables was found on this host.'));
|
||||||
|
}
|
||||||
|
return { tools: s.tools, sudo: s.sudo };
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(id, backend) {
|
||||||
|
const s = this.get(id);
|
||||||
|
if (backend === 'ufw') {
|
||||||
|
const out = await this.runOk(s, 'ufw status numbered');
|
||||||
|
const verbose = await this.runOk(s, 'ufw status verbose');
|
||||||
|
const def = verbose.match(/Default:\s*(\w+)\s*\(incoming\),\s*(\w+)\s*\(outgoing\)(?:,\s*(\w+)\s*\(routed\))?/i);
|
||||||
|
return { ...parseUfw(out), defaults: def ? { incoming: def[1], outgoing: def[2], routed: def[3] || '' } : null };
|
||||||
|
}
|
||||||
|
const bin = backend === 'ip6tables' ? 'ip6tables' : 'iptables';
|
||||||
|
return { chains: parseIptables(await this.runOk(s, `${bin} -S`)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async ufw(id, op, a = {}) {
|
||||||
|
const s = this.get(id);
|
||||||
|
switch (op) {
|
||||||
|
case 'enable': return this.runOk(s, 'ufw --force enable');
|
||||||
|
case 'disable': return this.runOk(s, 'ufw disable');
|
||||||
|
case 'delete': return this.runOk(s, `ufw --force delete ${Number(a.num)}`);
|
||||||
|
case 'default': {
|
||||||
|
const pol = check(a.policy, /^(allow|deny|reject)$/, 'policy');
|
||||||
|
const dir = check(a.dir, /^(incoming|outgoing|routed)$/, 'direction');
|
||||||
|
return this.runOk(s, `ufw default ${pol} ${dir}`);
|
||||||
|
}
|
||||||
|
case 'add': {
|
||||||
|
const action = check(a.action, /^(allow|deny|reject|limit)$/, 'action');
|
||||||
|
const dir = check(a.dir || 'in', /^(in|out)$/, 'direction');
|
||||||
|
const from = check(a.from || 'any', RX.addr, 'from');
|
||||||
|
const to = check(a.to || 'any', RX.addr, 'to');
|
||||||
|
const parts = ['ufw', a.top ? 'insert 1' : '', action, dir];
|
||||||
|
if (a.port) {
|
||||||
|
check(a.port, RX.port, 'port');
|
||||||
|
const proto = check(a.proto || 'any', /^(any|tcp|udp)$/, 'protocol');
|
||||||
|
// Portbereiche/-listen verlangen bei UFW ein Protokoll
|
||||||
|
if (/[:,]/.test(a.port) && proto === 'any') throw new Error(i18n.t('Port ranges and lists need a protocol (TCP or UDP).'));
|
||||||
|
if (proto !== 'any') parts.push('proto', proto);
|
||||||
|
parts.push('from', from, 'to', to, 'port', a.port);
|
||||||
|
} else parts.push('from', from, 'to', to);
|
||||||
|
if (a.comment) parts.push('comment', `'${check(a.comment, RX.comment, 'comment')}'`);
|
||||||
|
return this.runOk(s, parts.filter(Boolean).join(' '));
|
||||||
|
}
|
||||||
|
default: throw new Error('Invalid operation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async ipt(id, op, a = {}) {
|
||||||
|
const s = this.get(id);
|
||||||
|
const bin = a.family === 6 ? 'ip6tables' : 'iptables';
|
||||||
|
switch (op) {
|
||||||
|
case 'delete': return this.runOk(s, `${bin} -D ${check(a.chain, RX.chain, 'chain')} ${Number(a.num)}`);
|
||||||
|
case 'policy': return this.runOk(s, `${bin} -P ${check(a.chain, /^(INPUT|OUTPUT|FORWARD)$/, 'chain')} ${check(a.policy, /^(ACCEPT|DROP)$/, 'policy')}`);
|
||||||
|
case 'add': {
|
||||||
|
const parts = [bin, a.top ? '-I' : '-A', check(a.chain, RX.chain, 'chain')];
|
||||||
|
const proto = check(a.proto || 'all', /^(all|tcp|udp|icmp|icmpv6)$/, 'protocol');
|
||||||
|
if (proto !== 'all') parts.push('-p', proto);
|
||||||
|
if (a.source && a.source !== 'any') parts.push('-s', check(a.source, RX.addr, 'source'));
|
||||||
|
if (a.iface) parts.push(a.chain === 'OUTPUT' ? '-o' : '-i', check(a.iface, RX.iface, 'interface'));
|
||||||
|
if (a.port) {
|
||||||
|
check(a.port, RX.port, 'port');
|
||||||
|
if (proto !== 'tcp' && proto !== 'udp') throw new Error(i18n.t('A port needs the protocol TCP or UDP.'));
|
||||||
|
if (a.port.includes(',')) parts.push('-m', 'multiport', '--dports', a.port);
|
||||||
|
else parts.push('--dport', a.port);
|
||||||
|
}
|
||||||
|
if (a.state) parts.push('-m', 'conntrack', '--ctstate', check(a.state, /^[A-Z,]+$/, 'state'));
|
||||||
|
if (a.comment) parts.push('-m', 'comment', '--comment', `'${check(a.comment, RX.comment, 'comment')}'`);
|
||||||
|
parts.push('-j', check(a.target, /^(ACCEPT|DROP|REJECT|LOG|RETURN)$/, 'target'));
|
||||||
|
return this.runOk(s, parts.join(' '));
|
||||||
|
}
|
||||||
|
case 'save': {
|
||||||
|
// Dauerhaft speichern: Debian/Ubuntu (netfilter-persistent bzw. rules.v4/v6) oder Arch (iptables.rules)
|
||||||
|
const script = 'if command -v netfilter-persistent >/dev/null 2>&1; then netfilter-persistent save; '
|
||||||
|
+ 'elif [ -f /etc/debian_version ] && [ -d /etc/iptables ]; then iptables-save > /etc/iptables/rules.v4 && ip6tables-save > /etc/iptables/rules.v6; '
|
||||||
|
+ 'elif [ -d /etc/iptables ]; then iptables-save > /etc/iptables/iptables.rules && ip6tables-save > /etc/iptables/ip6tables.rules; '
|
||||||
|
+ 'else exit 3; fi';
|
||||||
|
const r = await this.run(s, `sh -c '${script}'`);
|
||||||
|
if (r.code === 3) throw new Error(i18n.t('No known way to save iptables rules on this host (e.g. install iptables-persistent).'));
|
||||||
|
if (r.code) throw new Error(lastLine(r.err) || i18n.t('Command failed with code {code}', { code: r.code }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default: throw new Error('Invalid operation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close(id) {
|
||||||
|
const s = this.sessions.get(id);
|
||||||
|
if (!s) return;
|
||||||
|
this.sessions.delete(id);
|
||||||
|
try { s.conn.end(); } catch {}
|
||||||
|
s.jumps?.forEach((c) => { try { c.end(); } catch {} });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { FirewallManager, parseUfw, parseIptables };
|
||||||
+30
-1
@@ -12,6 +12,8 @@ const { createEmbed, embedSupported } = require('./rdp-embed');
|
|||||||
const i18n = require('../i18n');
|
const i18n = require('../i18n');
|
||||||
const fido = require('./fido');
|
const fido = require('./fido');
|
||||||
const { VpnManager } = require('./vpn');
|
const { VpnManager } = require('./vpn');
|
||||||
|
const { DockerManager } = require('./docker');
|
||||||
|
const { FirewallManager } = require('./firewall');
|
||||||
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
|
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
|
||||||
|
|
||||||
let win;
|
let win;
|
||||||
@@ -61,6 +63,8 @@ function askSecret(sessionId, req) {
|
|||||||
const ssh = new SshManager(store, confirmHostKey, askSecret);
|
const ssh = new SshManager(store, confirmHostKey, askSecret);
|
||||||
const updater = new Updater(store, send);
|
const updater = new Updater(store, send);
|
||||||
const vpn = new VpnManager(store, app.getPath('userData'));
|
const vpn = new VpnManager(store, app.getPath('userData'));
|
||||||
|
const docker = new DockerManager(ssh);
|
||||||
|
const firewall = new FirewallManager(ssh);
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
win = new BrowserWindow({
|
win = new BrowserWindow({
|
||||||
@@ -194,6 +198,29 @@ handle('vault:remove', async (col, id) => {
|
|||||||
return store.remove(col, id);
|
return store.remove(col, id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- Docker ----------
|
||||||
|
handle('docker:open', async (id, hostRef) => {
|
||||||
|
const host = hostWithOverrides(hostRef);
|
||||||
|
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||||
|
return docker.open(id, host, () => send('docker:closed', id));
|
||||||
|
});
|
||||||
|
handle('docker:list', (id) => docker.list(id));
|
||||||
|
handle('docker:stats', (id) => docker.stats(id));
|
||||||
|
handle('docker:action', (id, action, cid) => docker.action(id, action, cid));
|
||||||
|
handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid));
|
||||||
|
handle('docker:close', (id) => docker.close(id));
|
||||||
|
|
||||||
|
// ---------- Firewall ----------
|
||||||
|
handle('firewall:open', async (id, hostRef) => {
|
||||||
|
const host = hostWithOverrides(hostRef);
|
||||||
|
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||||
|
return firewall.open(id, host, () => send('firewall:closed', id));
|
||||||
|
});
|
||||||
|
handle('firewall:list', (id, backend) => firewall.list(id, backend));
|
||||||
|
handle('firewall:ufw', (id, op, args) => firewall.ufw(id, op, args));
|
||||||
|
handle('firewall:ipt', (id, op, args) => firewall.ipt(id, op, args));
|
||||||
|
handle('firewall:close', (id) => firewall.close(id));
|
||||||
|
|
||||||
// ---------- VPN ----------
|
// ---------- VPN ----------
|
||||||
handle('vpn:status', () => vpn.status());
|
handle('vpn:status', () => vpn.status());
|
||||||
handle('vpn:up', (id) => vpn.up(id));
|
handle('vpn:up', (id) => vpn.up(id));
|
||||||
@@ -288,12 +315,14 @@ function hostWithOverrides(hostOrId) {
|
|||||||
if (!h) throw new Error(i18n.t('Host not found'));
|
if (!h) throw new Error(i18n.t('Host not found'));
|
||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
// Gespeicherter Host mit Zusätzen, z. B. { ref, execCommand } für Container-Shells
|
||||||
|
if (hostOrId?.ref) return { ...hostWithOverrides(hostOrId.ref), execCommand: hostOrId.execCommand, label: hostOrId.label };
|
||||||
return hostOrId; // Quick-Connect: temporärer Host
|
return hostOrId; // Quick-Connect: temporärer Host
|
||||||
}
|
}
|
||||||
|
|
||||||
handle('ssh:open', async (sessionId, hostRef, size) => {
|
handle('ssh:open', async (sessionId, hostRef, size) => {
|
||||||
const host = hostWithOverrides(hostRef);
|
const host = hostWithOverrides(hostRef);
|
||||||
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
if (host.id && !host.execCommand) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||||
const onEvent = (type, payload) => send('ssh:event', sessionId, type, payload);
|
const onEvent = (type, payload) => send('ssh:event', sessionId, type, payload);
|
||||||
if (await vpn.ensureForHost(host, (m) => onEvent('status', m))) send('vpn:changed');
|
if (await vpn.ensureForHost(host, (m) => onEvent('status', m))) send('vpn:changed');
|
||||||
await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
|
await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
|
||||||
|
|||||||
+7
-3
@@ -137,15 +137,19 @@ class SshManager {
|
|||||||
|
|
||||||
const env = host.env ? Object.fromEntries(host.env.split('\n').filter(Boolean).map((l) => l.split('=').map((s) => s.trim()))) : undefined;
|
const env = host.env ? Object.fromEntries(host.env.split('\n').filter(Boolean).map((l) => l.split('=').map((s) => s.trim()))) : undefined;
|
||||||
await new Promise((res, rej) => {
|
await new Promise((res, rej) => {
|
||||||
conn.shell({ term: 'xterm-256color', cols, rows }, { env }, (err, stream) => {
|
const pty = { term: 'xterm-256color', cols, rows };
|
||||||
|
const onStream = (err, stream) => {
|
||||||
if (err) return rej(err);
|
if (err) return rej(err);
|
||||||
sess.stream = stream;
|
sess.stream = stream;
|
||||||
stream.on('data', (d) => send('data', d.toString('utf8')));
|
stream.on('data', (d) => send('data', d.toString('utf8')));
|
||||||
stream.stderr.on('data', (d) => send('data', d.toString('utf8')));
|
stream.stderr.on('data', (d) => send('data', d.toString('utf8')));
|
||||||
stream.on('close', () => conn.end());
|
stream.on('close', () => conn.end());
|
||||||
if (host.startupCommand) stream.write(host.startupCommand + '\n');
|
if (host.startupCommand && !host.execCommand) stream.write(host.startupCommand + '\n');
|
||||||
res();
|
res();
|
||||||
});
|
};
|
||||||
|
// execCommand: statt Login-Shell einen Befehl mit PTY starten (z. B. docker exec -it …)
|
||||||
|
if (host.execCommand) conn.exec(host.execCommand, { pty, env }, onStream);
|
||||||
|
else conn.shell(pty, { env }, onStream);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+312
-3
@@ -40,6 +40,9 @@ const ICONS = {
|
|||||||
clock: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
|
clock: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
|
||||||
download: '<svg viewBox="0 0 24 24"><path d="M12 4v12M6 10l6 6 6-6M4 20h16"/></svg>',
|
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>',
|
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'];
|
const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100'];
|
||||||
function colorFor(h) {
|
function colorFor(h) {
|
||||||
@@ -387,7 +390,7 @@ function hostMenu(hst) {
|
|||||||
return [
|
return [
|
||||||
{ label: hst.protocol === 'rdp' ? T('Connect in tab') : T('Connect'), icon: 'play', run: () => connectHost(hst) },
|
{ 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 in separate window'), icon: 'screen', run: () => launchRdp(hst) }] : []),
|
||||||
...(hst.protocol !== 'rdp' ? [{ label: T('Open SFTP'), icon: 'folder', run: () => openSftp(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('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(); } },
|
{ 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(); } },
|
||||||
@@ -1158,7 +1161,7 @@ function addTab(session) {
|
|||||||
tab.onclick = (e) => { if (e.target.closest('.x')) return closeTab(session.id); activateTab(session.id); };
|
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.onauxclick = (e) => { if (e.button === 1) closeTab(session.id); };
|
||||||
tab.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
|
tab.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
|
||||||
...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => (session.kind === 'sftp' ? openSftp(session.host) : 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() }] : []),
|
...(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('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) },
|
'-', { label: T('Close'), icon: 'close', run: () => closeTab(session.id) },
|
||||||
@@ -1205,6 +1208,7 @@ class TerminalSession {
|
|||||||
this.el = h(`<div class="session">
|
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>
|
<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>
|
<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><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="snip" title="Snippets">${ICONS.code}Snippets</button>
|
||||||
<button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button>
|
<button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1262,6 +1266,8 @@ class TerminalSession {
|
|||||||
this.el.addEventListener('click', (e) => {
|
this.el.addEventListener('click', (e) => {
|
||||||
const a = e.target.closest('[data-a]')?.dataset.a;
|
const a = e.target.closest('[data-a]')?.dataset.a;
|
||||||
if (a === 'sftp') openSftp(this.host);
|
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 === 'snip') { $('.snip-panel', this.el).classList.toggle('open'); this.renderSnips(); this.doFit(); }
|
||||||
if (a === 'snipnew') editSnippet();
|
if (a === 'snipnew') editSnippet();
|
||||||
if (a === 'find') this.toggleFind();
|
if (a === 'find') this.toggleFind();
|
||||||
@@ -1331,7 +1337,7 @@ class TerminalSession {
|
|||||||
this.showOverlay(T('Connecting to {host} …', { host: this.host.address }));
|
this.showOverlay(T('Connecting to {host} …', { host: this.host.address }));
|
||||||
this.doFit();
|
this.doFit();
|
||||||
try {
|
try {
|
||||||
await api.call('ssh:open', this.id, this.host.id || this.host, { cols: this.term.cols, rows: this.term.rows });
|
await api.call('ssh:open', this.id, hostRef(this.host), { cols: this.term.cols, rows: this.term.rows });
|
||||||
this.alive = true;
|
this.alive = true;
|
||||||
this.hideOverlay();
|
this.hideOverlay();
|
||||||
setTabState(this, 'on');
|
setTabState(this, 'on');
|
||||||
@@ -1664,6 +1670,307 @@ class SftpSession {
|
|||||||
}
|
}
|
||||||
function openSftp(host) { return new SftpSession(host); }
|
function openSftp(host) { return new SftpSession(host); }
|
||||||
|
|
||||||
|
// Gespeicherte Hosts per ID übergeben; Container-Shells mit Zusatzbefehl
|
||||||
|
const hostRef = (host) => (host.id ? (host.execCommand ? { ref: host.id, execCommand: host.execCommand, label: host.label } : host.id) : host);
|
||||||
|
|
||||||
|
// ============================================================ Docker (Container über SSH)
|
||||||
|
class DockerSession {
|
||||||
|
constructor(host) {
|
||||||
|
this.kind = 'docker'; this.id = uid(); this.host = host;
|
||||||
|
this.title = `Docker · ${host.label || host.address}`;
|
||||||
|
this.containers = []; this.stats = {}; this.filter = ''; this.showStopped = true;
|
||||||
|
this.el = h(`<div class="session docker">
|
||||||
|
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
|
||||||
|
<label class="search sm">${ICONS.search}<input placeholder="${esc(T('Search containers …'))}"/></label>
|
||||||
|
<label class="check" style="margin:0 6px"><input type="checkbox" data-a="stopped" checked/>${T('Show stopped')}</label>
|
||||||
|
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
||||||
|
</div>
|
||||||
|
<div class="docker-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 = $('.docker-body', this.el);
|
||||||
|
$('.search input', this.el).oninput = (e) => { this.filter = e.target.value.toLowerCase(); this.draw(); };
|
||||||
|
this.el.addEventListener('click', (e) => { if (e.target.closest('[data-a=refresh]')) this.refresh(true); });
|
||||||
|
$('[data-a=stopped]', this.el).onchange = (e) => { this.showStopped = e.target.checked; this.draw(); };
|
||||||
|
this.unsub = api.on('docker:closed', (sid) => { if (sid === this.id) { setTabState(this, 'err'); this.error(T('Connection closed.')); } });
|
||||||
|
addTab(this);
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
async open() {
|
||||||
|
try {
|
||||||
|
this.apply(await api.call('docker:open', this.id, hostRef(this.host)));
|
||||||
|
setTabState(this, 'on');
|
||||||
|
this.loadStats();
|
||||||
|
this.timer = setInterval(() => { if (S.active === this.id && !$('.modal-bg')) this.refresh(); }, 10000);
|
||||||
|
} 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:520px;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); openDocker(this.host); };
|
||||||
|
$('.pane-empty', this.body).append(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(r) {
|
||||||
|
this.containers = r.containers;
|
||||||
|
$('.rt', this.el).textContent = ` · ${r.runtime === 'podman' ? 'Podman' : 'Docker'}${r.sudo ? ' (sudo)' : ''}`;
|
||||||
|
this.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(withStats) {
|
||||||
|
if (this.busy) return;
|
||||||
|
this.busy = true;
|
||||||
|
try { this.apply(await api.call('docker:list', this.id)); if (withStats) this.loadStats(); } catch (e) { if (withStats) toast(e.message, 'error'); }
|
||||||
|
this.busy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadStats() {
|
||||||
|
try { this.stats = await api.call('docker:stats', this.id); this.draw(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
const running = (c) => c.state === 'running';
|
||||||
|
const list = this.containers
|
||||||
|
.filter((c) => (this.showStopped || running(c)) && (!this.filter || [c.name, c.image, c.id, c.ports].some((f) => String(f || '').toLowerCase().includes(this.filter))))
|
||||||
|
.sort((a, b) => (running(b) - running(a)) || a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||||||
|
if (!this.containers.length) { this.body.innerHTML = `<div class="pane-empty"><div style="color:var(--muted)">${T('No containers on this host.')}</div></div>`; return; }
|
||||||
|
const scroll = this.body.scrollTop;
|
||||||
|
this.body.innerHTML = `<table class="docker-table"><thead><tr><th></th><th>${T('Name')}</th><th>${T('Status')}</th><th>${T('Ports')}</th><th class="num">CPU</th><th class="num">${T('Memory')}</th><th></th></tr></thead><tbody>
|
||||||
|
${list.map((c) => {
|
||||||
|
const st = this.stats[c.id];
|
||||||
|
const on = running(c);
|
||||||
|
return `<tr data-id="${esc(c.id)}"><td><span class="dot ${on ? 'on' : c.state === 'paused' || c.state === 'restarting' ? 'wait' : ''}"></span></td>
|
||||||
|
<td class="name"><div><b>${esc(c.name)}</b><span class="img">${esc(c.image)}</span></div></td>
|
||||||
|
<td class="muted">${esc(c.status)}</td><td class="muted ports" title="${esc(c.ports)}">${esc(c.ports)}</td>
|
||||||
|
<td class="num">${on && st ? esc(st.cpu) : ''}</td><td class="num">${on && st ? esc(st.mem.split('/')[0].trim()) : ''}</td>
|
||||||
|
<td class="acts">
|
||||||
|
${on ? `<button class="btn ghost sm" data-c="shell" title="${T('Open shell')}">${ICONS.term}</button>` : ''}
|
||||||
|
<button class="btn ghost sm" data-c="logs" title="${T('Logs')}">${ICONS.logs}</button>
|
||||||
|
${on ? `<button class="btn ghost sm" data-c="restart" title="${T('Restart')}">${ICONS.refresh}</button><button class="btn ghost sm" data-c="stop" title="${T('Stop')}">${ICONS.stop}</button>`
|
||||||
|
: `<button class="btn ghost sm" data-c="start" title="${T('Start')}">${ICONS.play}</button><button class="btn ghost sm" data-c="remove" title="${T('Delete')}">${ICONS.trash}</button>`}
|
||||||
|
</td></tr>`;
|
||||||
|
}).join('')}</tbody></table>`;
|
||||||
|
this.body.scrollTop = scroll;
|
||||||
|
$$('tr[data-id]', this.body).forEach((tr) => {
|
||||||
|
const c = this.containers.find((x) => x.id === tr.dataset.id);
|
||||||
|
tr.onclick = (e) => { const a = e.target.closest('[data-c]')?.dataset.c; if (a) this.act(a, c, e.target.closest('button')); };
|
||||||
|
tr.ondblclick = (e) => { if (!e.target.closest('[data-c]') && running(c)) this.act('shell', c); };
|
||||||
|
tr.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
|
||||||
|
...(running(c) ? [{ label: T('Open shell'), icon: 'term', run: () => this.act('shell', c) }] : []),
|
||||||
|
{ label: T('Logs'), icon: 'logs', run: () => this.act('logs', c) },
|
||||||
|
'-',
|
||||||
|
...(running(c) ? [{ label: T('Restart'), icon: 'refresh', run: () => this.act('restart', c) }, { label: T('Stop'), icon: 'stop', run: () => this.act('stop', c) }]
|
||||||
|
: [{ label: T('Start'), icon: 'play', run: () => this.act('start', c) }]),
|
||||||
|
{ label: T('Copy ID'), icon: 'copy', run: () => api.call('clipboard:write', c.id) },
|
||||||
|
'-',
|
||||||
|
{ label: T('Delete'), icon: 'trash', danger: true, run: () => this.act('remove', c) },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async act(a, c, btn) {
|
||||||
|
if (a === 'shell' || a === 'logs') {
|
||||||
|
const cmd = await call('docker:command', this.id, a, c.id);
|
||||||
|
openTerminal({ ...this.host, execCommand: cmd, label: `${a === 'logs' ? T('Logs') : '🐳'} ${c.name}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (a === 'remove' && !(await confirmBox(T('Delete container?'), T('“{name}” will be removed including its writable layer. Volumes are kept.', { name: c.name })))) return;
|
||||||
|
if (a === 'stop' && !(await confirmBox(T('Stop container?'), c.name, T('Stop')))) return;
|
||||||
|
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner sm"></span>'; }
|
||||||
|
try { await call('docker:action', this.id, a, c.id); toast(T('{action}: {name}', { action: { start: T('Started'), stop: T('Stopped'), restart: T('Restarted'), remove: T('Deleted') }[a], name: c.name }), 'ok'); } catch {}
|
||||||
|
await this.refresh(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow() { if (this.containers.length) this.refresh(); }
|
||||||
|
dispose() { clearInterval(this.timer); this.unsub(); api.call('docker:close', this.id).catch(() => {}); }
|
||||||
|
}
|
||||||
|
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
|
// ============================================================ Command Palette / Quick Connect
|
||||||
function openPalette() {
|
function openPalette() {
|
||||||
if ($('.palette')) return;
|
if ($('.palette')) return;
|
||||||
@@ -1682,6 +1989,8 @@ function openPalette() {
|
|||||||
hosts.slice(0, 12).forEach((x) => {
|
hosts.slice(0, 12).forEach((x) => {
|
||||||
items.push({ grp: 'Hosts', icon: avatar(x), label: x.label || x.address, sub: hostSub(x), run: () => connectHost(x) });
|
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(--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) }));
|
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()]]
|
[[T('New host'), () => { activateTab('home'); editHost({}); }], [T('Generate key'), generateKey], [T('Settings'), () => $('[data-view=settings]').click()]]
|
||||||
|
|||||||
@@ -364,3 +364,41 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
|
|||||||
/* VPN */
|
/* VPN */
|
||||||
.vpn-hosts { display: flex; flex-direction: column; gap: 2px; max-height: 260px; overflow: auto; }
|
.vpn-hosts { display: flex; flex-direction: column; gap: 2px; max-height: 260px; overflow: auto; }
|
||||||
.spinner.sm { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .9s linear infinite; margin-right: 6px; vertical-align: -2px; }
|
.spinner.sm { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .9s linear infinite; margin-right: 6px; vertical-align: -2px; }
|
||||||
|
|
||||||
|
/* Docker */
|
||||||
|
.session.docker .sbar .search.sm { flex: 0 1 260px; height: 28px; margin: 0; }
|
||||||
|
.session.docker .rt { color: var(--faint); }
|
||||||
|
.docker-body { flex: 1; overflow: auto; position: relative; }
|
||||||
|
.docker-body .pane-empty { height: 100%; display: flex; flex-direction: column; gap: 14px; align-items: center; justify-content: center; }
|
||||||
|
.docker-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.docker-table th { position: sticky; top: 0; background: var(--bg); text-align: left; font-size: 11px; color: var(--faint); font-weight: 600; padding: 8px 10px; text-transform: uppercase; letter-spacing: .4px; border-bottom: 1px solid var(--border); z-index: 1; }
|
||||||
|
.docker-table td { padding: 7px 10px; border-bottom: 1px solid var(--row-line); white-space: nowrap; vertical-align: middle; }
|
||||||
|
.docker-table tr:hover td { background: rgb(var(--tint) / 0.024); }
|
||||||
|
.docker-table td.name { width: 30%; max-width: 0; }
|
||||||
|
.docker-table td.name > div { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.docker-table td.name b, .docker-table td.name .img { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.docker-table td.name .img { color: var(--faint); font-size: 11px; }
|
||||||
|
.docker-table td.muted { color: var(--muted); font-size: 12px; }
|
||||||
|
.docker-table td.ports { max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.docker-table .num { text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); }
|
||||||
|
.docker-table td.acts { text-align: right; }
|
||||||
|
.docker-table td.acts .btn { padding: 4px 6px; }
|
||||||
|
.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