Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
408b4bd9d0 | ||
|
|
7d4991c99c | ||
|
|
10acdaeb0b | ||
|
|
daff240aa6 | ||
|
|
66d4350221 | ||
|
|
6c6e25df36 | ||
|
|
32bf502ffa |
@@ -101,6 +101,21 @@ Then open `http://<your-server>:8080` and create the administrator account. As a
|
|||||||
- **Data** lives in the `mrterm-data` volume (`/data` in the container). Back it up regularly. A restored vault still needs its user's password to open.
|
- **Data** lives in the `mrterm-data` volume (`/data` in the container). Back it up regularly. A restored vault still needs its user's password to open.
|
||||||
- **SSH connections** start from the server, so the server must be able to reach your hosts.
|
- **SSH connections** start from the server, so the server must be able to reach your hosts.
|
||||||
|
|
||||||
|
### Updating the web version
|
||||||
|
|
||||||
|
Run these commands in the folder that contains your `docker-compose.yml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build --pull --no-cache
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This fetches the latest MrTerm from the repository, rebuilds the image and restarts the container. Your users and vaults stay in the `mrterm-data` volume and are kept. Everyone has to sign in again after the restart.
|
||||||
|
|
||||||
|
To check which version is running, look at the bottom of the sign-in page. If a release note mentions changes to `docker-compose.yml`, download it again first (`curl -O …` as above) and copy over your own changes, such as ports or `TRUST_PROXY`.
|
||||||
|
|
||||||
|
To remove images left over from earlier builds: `docker image prune`.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
1. Click **New host**, enter the address, username and password or key, and click **Save**.
|
1. Click **New host**, enter the address, username and password or key, and click **Save**.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"version": "0.13.0",
|
"version": "0.14.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"version": "0.13.0",
|
"version": "0.14.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.13.0",
|
"version": "0.14.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",
|
||||||
|
|||||||
+21
@@ -351,6 +351,27 @@
|
|||||||
'Touch your security key to unlock MrTerm.': 'Berühre deinen Sicherheitsschlüssel, um MrTerm zu entsperren.',
|
'Touch your security key to unlock MrTerm.': 'Berühre deinen Sicherheitsschlüssel, um MrTerm zu entsperren.',
|
||||||
'Security key prompt was cancelled or timed out.': 'Die Abfrage des Sicherheitsschlüssels wurde abgebrochen oder ist abgelaufen.',
|
'Security key prompt was cancelled or timed out.': 'Die Abfrage des Sicherheitsschlüssels wurde abgebrochen oder ist abgelaufen.',
|
||||||
'This security key does not support the hmac-secret/PRF extension.': 'Dieser Sicherheitsschlüssel unterstützt die hmac-secret/PRF-Erweiterung nicht.',
|
'This security key does not support the hmac-secret/PRF extension.': 'Dieser Sicherheitsschlüssel unterstützt die hmac-secret/PRF-Erweiterung nicht.',
|
||||||
|
'Templates': 'Vorlagen',
|
||||||
|
'Run': 'Ausführen',
|
||||||
|
'Authorize SSH key for a user': 'SSH-Key für Benutzer freischalten',
|
||||||
|
'Adds a public key from the keychain to ~/.ssh/authorized_keys of the user.': 'Trägt einen öffentlichen Schlüssel aus dem Schlüsselbund in ~/.ssh/authorized_keys des Benutzers ein.',
|
||||||
|
'Key': 'Schlüssel',
|
||||||
|
'Public key is missing.': 'Öffentlicher Schlüssel fehlt.',
|
||||||
|
'User is missing.': 'Benutzer fehlt.',
|
||||||
|
'Install QEMU guest agent': 'QEMU Guest Agent installieren',
|
||||||
|
'Installs and starts qemu-guest-agent (apt, dnf, yum, pacman, zypper or apk).': 'Installiert und startet qemu-guest-agent (apt, dnf, yum, pacman, zypper oder apk).',
|
||||||
|
'Use APT cache server': 'APT-Cache-Server verwenden',
|
||||||
|
'Routes APT downloads through a cache proxy such as apt-cacher-ng (/etc/apt/apt.conf.d/00proxy).': 'Leitet APT-Downloads über einen Cache-Proxy wie apt-cacher-ng (/etc/apt/apt.conf.d/00proxy).',
|
||||||
|
'Proxy URL': 'Proxy-URL',
|
||||||
|
'Invalid URL.': 'Ungültige URL.',
|
||||||
|
'Remove APT cache server': 'APT-Cache-Server entfernen',
|
||||||
|
'Removes the APT proxy setting again.': 'Entfernt die APT-Proxy-Einstellung wieder.',
|
||||||
|
'Updates all packages with the system package manager.': 'Aktualisiert alle Pakete mit dem Paketmanager des Systems.',
|
||||||
|
'Connecting to security key …': 'Verbinde mit dem Sicherheitsschlüssel …',
|
||||||
|
'Enter the PIN of your security key.': 'Gib die PIN deines Sicherheitsschlüssels ein.',
|
||||||
|
'Wrong PIN.': 'Falsche PIN.',
|
||||||
|
'{n} attempts left.': 'Noch {n} Versuche.',
|
||||||
|
'The PIN of this security key is blocked. Remove and reinsert the key, or reset it.': 'Die PIN dieses Sicherheitsschlüssels ist gesperrt. Ziehe den Schlüssel ab und stecke ihn neu ein oder setze ihn zurück.',
|
||||||
|
|
||||||
// VPN
|
// VPN
|
||||||
'— No VPN —': '— Kein VPN —',
|
'— No VPN —': '— Kein VPN —',
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Minimaler CTAP2-Client über Linux-hidraw (ohne native Module).
|
||||||
|
// Nötig, weil Chromium/Electron keine PIN-Eingabe für WebAuthn hat: Schlüssel mit gesetzter FIDO2-PIN
|
||||||
|
// (z. B. YubiKey 5 ohne makeCredUvNotRqd) verlangen die PIN beim Registrieren, und Chromium bricht dann
|
||||||
|
// nach dem Berühren mit NotAllowedError ab. Hier läuft makeCredential direkt, mit PIN-Token (Protokoll 1).
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// ---- CBOR (nur was CTAP2 braucht; Map-Schlüssel kanonisch sortiert) ----
|
||||||
|
function head(major, n) {
|
||||||
|
if (n < 24) return Buffer.from([(major << 5) | n]);
|
||||||
|
if (n < 0x100) return Buffer.from([(major << 5) | 24, n]);
|
||||||
|
if (n < 0x10000) { const b = Buffer.alloc(3); b[0] = (major << 5) | 25; b.writeUInt16BE(n, 1); return b; }
|
||||||
|
const b = Buffer.alloc(5); b[0] = (major << 5) | 26; b.writeUInt32BE(n, 1); return b;
|
||||||
|
}
|
||||||
|
function enc(v) {
|
||||||
|
if (typeof v === 'number') return v >= 0 ? head(0, v) : head(1, -1 - v);
|
||||||
|
if (typeof v === 'boolean') return Buffer.from([v ? 0xf5 : 0xf4]);
|
||||||
|
if (typeof v === 'string') { const s = Buffer.from(v, 'utf8'); return Buffer.concat([head(3, s.length), s]); }
|
||||||
|
if (Buffer.isBuffer(v) || v instanceof Uint8Array) return Buffer.concat([head(2, v.length), Buffer.from(v)]);
|
||||||
|
if (Array.isArray(v)) return Buffer.concat([head(4, v.length), ...v.map(enc)]);
|
||||||
|
if (v instanceof Map) {
|
||||||
|
const items = [...v].map(([k, x]) => [enc(k), enc(x)])
|
||||||
|
.sort(([a], [b]) => a.length - b.length || Buffer.compare(a, b));
|
||||||
|
return Buffer.concat([head(5, items.length), ...items.flat()]);
|
||||||
|
}
|
||||||
|
throw new Error('CBOR: unsupported value');
|
||||||
|
}
|
||||||
|
function dec(buf, pos = 0) {
|
||||||
|
const ib = buf[pos++], major = ib >> 5, ai = ib & 31;
|
||||||
|
let n = ai;
|
||||||
|
if (ai === 24) n = buf[pos++];
|
||||||
|
else if (ai === 25) { n = buf.readUInt16BE(pos); pos += 2; }
|
||||||
|
else if (ai === 26) { n = buf.readUInt32BE(pos); pos += 4; }
|
||||||
|
else if (ai === 27) { n = Number(buf.readBigUInt64BE(pos)); pos += 8; }
|
||||||
|
switch (major) {
|
||||||
|
case 0: return [n, pos];
|
||||||
|
case 1: return [-1 - n, pos];
|
||||||
|
case 2: return [buf.subarray(pos, pos + n), pos + n];
|
||||||
|
case 3: return [buf.toString('utf8', pos, pos + n), pos + n];
|
||||||
|
case 4: { const a = []; for (let i = 0; i < n; i++) { let x; [x, pos] = dec(buf, pos); a.push(x); } return [a, pos]; }
|
||||||
|
case 5: { const m = new Map(); for (let i = 0; i < n; i++) { let k, x; [k, pos] = dec(buf, pos); [x, pos] = dec(buf, pos); m.set(k, x); } return [m, pos]; }
|
||||||
|
case 6: return dec(buf, pos);
|
||||||
|
default: return [ai === 20 ? false : ai === 21 ? true : null, pos];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CTAPHID ----
|
||||||
|
const CMD = { CBOR: 0x10, INIT: 0x06, CANCEL: 0x11, KEEPALIVE: 0x3b, ERROR: 0x3f };
|
||||||
|
|
||||||
|
class CtapError extends Error {
|
||||||
|
constructor(code) { super(`CTAP2 error 0x${code.toString(16).padStart(2, '0')}`); this.code = code; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDevice() {
|
||||||
|
const base = '/sys/class/hidraw';
|
||||||
|
let names = [];
|
||||||
|
try { names = fs.readdirSync(base); } catch { return null; }
|
||||||
|
for (const n of names) {
|
||||||
|
try {
|
||||||
|
const rd = fs.readFileSync(path.join(base, n, 'device', 'report_descriptor'));
|
||||||
|
if (rd.includes(Buffer.from([0x06, 0xd0, 0xf1]))) return '/dev/' + n; // Usage Page 0xF1D0 (FIDO)
|
||||||
|
} catch { /* ignorieren */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Device {
|
||||||
|
static open() {
|
||||||
|
const p = findDevice();
|
||||||
|
if (!p) return null;
|
||||||
|
const d = new Device();
|
||||||
|
d.fd = fs.openSync(p, 'r+');
|
||||||
|
d.cid = Buffer.from([0xff, 0xff, 0xff, 0xff]);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
this.closed = true;
|
||||||
|
try { fs.closeSync(this.fd); } catch { /* ignorieren */ }
|
||||||
|
}
|
||||||
|
// Nur lesen, während eine Antwort erwartet wird – so hängt nach close() kein blockierender Read im Threadpool
|
||||||
|
readPacket() {
|
||||||
|
const b = Buffer.alloc(64);
|
||||||
|
return new Promise((resolve, reject) => fs.read(this.fd, b, 0, 64, null, (e, n) => (e ? reject(e) : resolve(b.subarray(0, n)))));
|
||||||
|
}
|
||||||
|
write(cmd, data) {
|
||||||
|
const pkt = (b) => { const r = Buffer.alloc(65); b.copy(r, 1); fs.writeSync(this.fd, r); };
|
||||||
|
const first = Buffer.alloc(64);
|
||||||
|
this.cid.copy(first, 0); first[4] = 0x80 | cmd; first.writeUInt16BE(data.length, 5);
|
||||||
|
data.copy(first, 7, 0, 57); pkt(first);
|
||||||
|
for (let off = 57, seq = 0; off < data.length; off += 59, seq++) {
|
||||||
|
const c = Buffer.alloc(64);
|
||||||
|
this.cid.copy(c, 0); c[4] = seq; data.copy(c, 5, off, off + 59); pkt(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async transact(cmd, data) {
|
||||||
|
this.write(cmd, data);
|
||||||
|
for (;;) {
|
||||||
|
const r = await this.readPacket();
|
||||||
|
if (!r.subarray(0, 4).equals(this.cid)) continue;
|
||||||
|
const rcmd = r[4] & 0x7f;
|
||||||
|
if (rcmd === CMD.KEEPALIVE) continue;
|
||||||
|
const len = r.readUInt16BE(5);
|
||||||
|
const parts = [r.subarray(7)];
|
||||||
|
let got = r.length - 7;
|
||||||
|
while (got < len) { const c = await this.readPacket(); parts.push(c.subarray(5)); got += c.length - 5; }
|
||||||
|
const body = Buffer.concat(parts).subarray(0, len);
|
||||||
|
if (rcmd === CMD.ERROR) throw new Error(`CTAPHID error 0x${body[0].toString(16)}`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async init() {
|
||||||
|
const nonce = crypto.randomBytes(8);
|
||||||
|
for (;;) {
|
||||||
|
const r = await this.transact(CMD.INIT, nonce);
|
||||||
|
if (r.subarray(0, 8).equals(nonce)) { this.cid = Buffer.from(r.subarray(8, 12)); return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancel() { try { if (!this.closed) this.write(CMD.CANCEL, Buffer.alloc(0)); } catch { /* ignorieren */ } }
|
||||||
|
async cbor(cmd, params) {
|
||||||
|
const r = await this.transact(CMD.CBOR, Buffer.concat([Buffer.from([cmd]), params ? enc(params) : Buffer.alloc(0)]));
|
||||||
|
if (r[0] !== 0) throw new CtapError(r[0]);
|
||||||
|
return r.length > 1 ? dec(r, 1)[0] : new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PIN-Protokoll 1 ----
|
||||||
|
const aes = (mode, key, data) => {
|
||||||
|
const c = mode === 'enc' ? crypto.createCipheriv('aes-256-cbc', key, Buffer.alloc(16)) : crypto.createDecipheriv('aes-256-cbc', key, Buffer.alloc(16));
|
||||||
|
c.setAutoPadding(false);
|
||||||
|
return Buffer.concat([c.update(data), c.final()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function sharedSecret(dev) {
|
||||||
|
const ka = (await dev.cbor(0x06, new Map([[1, 1], [2, 2]]))).get(1);
|
||||||
|
const ecdh = crypto.createECDH('prime256v1');
|
||||||
|
ecdh.generateKeys();
|
||||||
|
const z = ecdh.computeSecret(Buffer.concat([Buffer.from([4]), ka.get(-2), ka.get(-3)]));
|
||||||
|
const pub = ecdh.getPublicKey();
|
||||||
|
const platformKey = new Map([[1, 2], [3, -25], [-1, 1], [-2, pub.subarray(1, 33)], [-3, pub.subarray(33, 65)]]);
|
||||||
|
return { key: crypto.createHash('sha256').update(z).digest(), platformKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pinToken(dev, pin) {
|
||||||
|
const { key, platformKey } = await sharedSecret(dev);
|
||||||
|
const pinHash = crypto.createHash('sha256').update(pin, 'utf8').digest().subarray(0, 16);
|
||||||
|
const r = await dev.cbor(0x06, new Map([[1, 1], [2, 5], [3, platformKey], [6, aes('enc', key, pinHash)]]));
|
||||||
|
return aes('dec', key, r.get(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pinRetries(dev) {
|
||||||
|
try { return (await dev.cbor(0x06, new Map([[1, 1], [2, 1]]))).get(3); } catch { return undefined; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status-Codes, die für "abgebrochen / nicht bestätigt / Zeit abgelaufen" stehen
|
||||||
|
const CANCEL_CODES = new Set([0x27, 0x2d, 0x2f, 0x3a]);
|
||||||
|
const PIN = { INVALID: 0x31, BLOCKED: 0x32, AUTH_BLOCKED: 0x34 };
|
||||||
|
|
||||||
|
// Registriert ein nicht-residentes Credential mit hmac-secret für rpId "localhost" (kompatibel zur
|
||||||
|
// WebAuthn-PRF-Abfrage in fido.js). ui: { askPin(retries, wrong) -> Promise<string|null>, touch() }
|
||||||
|
// Liefert die Credential-ID als Buffer, oder null, wenn kein FIDO2-Gerät per hidraw erreichbar ist.
|
||||||
|
async function makeCredential(ui, { timeoutMs = 60000 } = {}) {
|
||||||
|
let dev;
|
||||||
|
try { dev = Device.open(); } catch { return null; }
|
||||||
|
if (!dev) return null;
|
||||||
|
let timer;
|
||||||
|
const abort = () => dev.cancel();
|
||||||
|
try {
|
||||||
|
await dev.init();
|
||||||
|
const info = await dev.cbor(0x04);
|
||||||
|
if (!(info.get(1) || []).some((v) => String(v).startsWith('FIDO_2'))) return null;
|
||||||
|
if (!(info.get(2) || []).includes('hmac-secret')) throw new CtapError(-1);
|
||||||
|
const opts = info.get(4) || new Map();
|
||||||
|
|
||||||
|
const params = new Map([
|
||||||
|
[2, new Map([['id', 'localhost'], ['name', 'MrTerm']])],
|
||||||
|
[3, new Map([['id', crypto.randomBytes(16)], ['name', 'MrTerm'], ['displayName', 'MrTerm']])],
|
||||||
|
[4, [-7, -8, -257].map((alg) => new Map([['alg', alg], ['type', 'public-key']]))],
|
||||||
|
[6, new Map([['hmac-secret', true]])],
|
||||||
|
]);
|
||||||
|
const cdh = crypto.randomBytes(32);
|
||||||
|
params.set(1, cdh);
|
||||||
|
|
||||||
|
if (opts.get('clientPin') === true && opts.get('makeCredUvNotRqd') !== true) {
|
||||||
|
let wrong = false;
|
||||||
|
for (;;) {
|
||||||
|
const pin = await ui.askPin(await pinRetries(dev), wrong);
|
||||||
|
if (pin == null) throw new CtapError(0x2d);
|
||||||
|
try {
|
||||||
|
const tok = await pinToken(dev, pin);
|
||||||
|
params.set(8, crypto.createHmac('sha256', tok).update(cdh).digest().subarray(0, 16));
|
||||||
|
params.set(9, 1);
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === PIN.INVALID) { wrong = true; continue; }
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.touch(abort);
|
||||||
|
timer = setTimeout(abort, timeoutMs);
|
||||||
|
const r = await dev.cbor(0x01, params);
|
||||||
|
const authData = r.get(2);
|
||||||
|
const idLen = authData.readUInt16BE(53);
|
||||||
|
return Buffer.from(authData.subarray(55, 55 + idLen));
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
dev.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entspricht WebAuthn-PRF (evalByCredential) über hmac-secret, ohne PIN (wie Chromium mit
|
||||||
|
// userVerification "discouraged"). creds: [{ credId: Buffer, salt: Buffer }] – salt ist der rohe PRF-Eingabewert.
|
||||||
|
// Liefert { credId: Buffer, secret: Buffer(32) }, oder null, wenn kein FIDO2-Gerät per hidraw erreichbar ist.
|
||||||
|
async function getHmacSecret(ui, creds, { timeoutMs = 60000 } = {}) {
|
||||||
|
let dev;
|
||||||
|
try { dev = Device.open(); } catch { return null; }
|
||||||
|
if (!dev) return null;
|
||||||
|
let timer;
|
||||||
|
try {
|
||||||
|
await dev.init();
|
||||||
|
const info = await dev.cbor(0x04);
|
||||||
|
if (!(info.get(1) || []).some((v) => String(v).startsWith('FIDO_2'))) return null;
|
||||||
|
const rpId = 'localhost';
|
||||||
|
const desc = (c) => new Map([['id', c.credId], ['type', 'public-key']]);
|
||||||
|
// Welches Credential liegt auf diesem Schlüssel? (Vorabprüfung ohne Berühren, up=false)
|
||||||
|
let cred = null;
|
||||||
|
for (const c of creds) {
|
||||||
|
try {
|
||||||
|
await dev.cbor(0x02, new Map([[1, rpId], [2, crypto.randomBytes(32)], [3, [desc(c)]], [5, new Map([['up', false]])]]));
|
||||||
|
cred = c; break;
|
||||||
|
} catch (e) { if (e.code !== 0x2e) throw e; }
|
||||||
|
}
|
||||||
|
if (!cred) throw new CtapError(0x2e);
|
||||||
|
const { key, platformKey } = await sharedSecret(dev);
|
||||||
|
const salt = crypto.createHash('sha256').update(Buffer.concat([Buffer.from('WebAuthn PRF\0', 'latin1'), cred.salt])).digest();
|
||||||
|
const saltEnc = aes('enc', key, salt);
|
||||||
|
const saltAuth = crypto.createHmac('sha256', key).update(saltEnc).digest().subarray(0, 16);
|
||||||
|
ui.touch(() => dev.cancel());
|
||||||
|
timer = setTimeout(() => dev.cancel(), timeoutMs);
|
||||||
|
const r = await dev.cbor(0x02, new Map([
|
||||||
|
[1, rpId], [2, crypto.randomBytes(32)], [3, [desc(cred)]],
|
||||||
|
[4, new Map([['hmac-secret', new Map([[1, platformKey], [2, saltEnc], [3, saltAuth]])]])],
|
||||||
|
]));
|
||||||
|
const authData = r.get(2);
|
||||||
|
if (!(authData[32] & 0x80)) throw new CtapError(-1);
|
||||||
|
const ext = dec(authData, 37)[0].get('hmac-secret');
|
||||||
|
if (!ext) throw new CtapError(-1);
|
||||||
|
return { credId: Buffer.from(cred.credId), secret: aes('dec', key, ext).subarray(0, 32) };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
dev.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { makeCredential, getHmacSecret, CtapError, CANCEL_CODES, PIN };
|
||||||
+87
-8
@@ -4,8 +4,11 @@
|
|||||||
// abgefangen wird (kein echter Server). rpId ist damit immer "localhost".
|
// abgefangen wird (kein echter Server). rpId ist damit immer "localhost".
|
||||||
// Aus dem Schlüssel wird per PRF-Erweiterung (CTAP hmac-secret) ein geheimer Wert abgeleitet, der den
|
// Aus dem Schlüssel wird per PRF-Erweiterung (CTAP hmac-secret) ein geheimer Wert abgeleitet, der den
|
||||||
// Datenschlüssel des Vaults verpackt. userVerification "discouraged": Berühren genügt (Electron hat keine PIN-Eingabe).
|
// Datenschlüssel des Vaults verpackt. userVerification "discouraged": Berühren genügt (Electron hat keine PIN-Eingabe).
|
||||||
|
// Registrieren läuft unter Linux direkt per CTAP2 (ctap2.js), weil Electron keine PIN-Abfrage kennt und
|
||||||
|
// Schlüssel mit gesetzter PIN sonst nach dem Berühren mit NotAllowedError scheitern.
|
||||||
const { BrowserWindow, session } = require('electron');
|
const { BrowserWindow, session } = require('electron');
|
||||||
const i18n = require('../i18n');
|
const i18n = require('../i18n');
|
||||||
|
const ctap2 = require('./ctap2');
|
||||||
|
|
||||||
const PAGE = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
const PAGE = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
||||||
html,body{margin:0;height:100%;background:#1a1d27;color:#e6e8ef;font:14px system-ui,sans-serif;-webkit-app-region:drag}
|
html,body{margin:0;height:100%;background:#1a1d27;color:#e6e8ef;font:14px system-ui,sans-serif;-webkit-app-region:drag}
|
||||||
@@ -13,7 +16,10 @@ const PAGE = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
|||||||
.ic{font-size:34px} #t{max-width:340px;line-height:1.4}
|
.ic{font-size:34px} #t{max-width:340px;line-height:1.4}
|
||||||
button{-webkit-app-region:no-drag;background:#2a2f40;color:#e6e8ef;border:1px solid #3a4054;border-radius:8px;padding:7px 16px;font:inherit;cursor:pointer}
|
button{-webkit-app-region:no-drag;background:#2a2f40;color:#e6e8ef;border:1px solid #3a4054;border-radius:8px;padding:7px 16px;font:inherit;cursor:pointer}
|
||||||
button:hover{background:#343a4f}
|
button:hover{background:#343a4f}
|
||||||
</style></head><body><div class="ic">🔑</div><div id="t"></div><button id="c"></button></body></html>`;
|
form{display:flex;gap:8px;-webkit-app-region:no-drag} form[hidden]{display:none}
|
||||||
|
input{background:#10131b;color:#e6e8ef;border:1px solid #3a4054;border-radius:8px;padding:7px 10px;font:inherit;width:170px}
|
||||||
|
.row{display:flex;gap:8px}
|
||||||
|
</style></head><body><div class="ic">🔑</div><div id="t"></div><form id="f" hidden><input id="p" type="password" autocomplete="off"><button id="ok"></button></form><div class="row"><button id="c"></button></div></body></html>`;
|
||||||
|
|
||||||
let fidoSession;
|
let fidoSession;
|
||||||
function getSession() {
|
function getSession() {
|
||||||
@@ -29,17 +35,23 @@ const HELPERS = `
|
|||||||
const unb64 = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
|
const unb64 = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
|
||||||
`;
|
`;
|
||||||
|
|
||||||
async function ceremony(parent, text, script) {
|
async function openWindow(parent, text) {
|
||||||
const w = new BrowserWindow({
|
const w = new BrowserWindow({
|
||||||
parent, modal: !!parent, width: 420, height: 210, frame: false, resizable: false, show: false,
|
parent, modal: !!parent, width: 420, height: 230, frame: false, resizable: false, show: false,
|
||||||
backgroundColor: '#1a1d27', webPreferences: { session: getSession(), contextIsolation: true, sandbox: true },
|
backgroundColor: '#1a1d27', webPreferences: { session: getSession(), contextIsolation: true, sandbox: true },
|
||||||
});
|
});
|
||||||
|
await w.loadURL('http://localhost/');
|
||||||
|
await w.webContents.executeJavaScript(`document.getElementById('t').textContent = ${JSON.stringify(text)};
|
||||||
|
document.getElementById('ok').textContent = ${JSON.stringify(i18n.t('OK'))};
|
||||||
|
const c = document.getElementById('c'); c.textContent = ${JSON.stringify(i18n.t('Cancel'))}; c.onclick = () => window.close(); 0;`);
|
||||||
|
w.show();
|
||||||
|
w.focus();
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ceremony(parent, text, script) {
|
||||||
|
const w = await openWindow(parent, text);
|
||||||
try {
|
try {
|
||||||
await w.loadURL('http://localhost/');
|
|
||||||
await w.webContents.executeJavaScript(`document.getElementById('t').textContent = ${JSON.stringify(text)};
|
|
||||||
const c = document.getElementById('c'); c.textContent = ${JSON.stringify(i18n.t('Cancel'))}; c.onclick = () => window.close(); 0;`);
|
|
||||||
w.show();
|
|
||||||
w.focus();
|
|
||||||
const closed = new Promise((resolve) => w.once('closed', () => resolve({ error: 'cancelled' })));
|
const closed = new Promise((resolve) => w.once('closed', () => resolve({ error: 'cancelled' })));
|
||||||
const r = await Promise.race([w.webContents.executeJavaScript(`(async () => { try { ${HELPERS} ${script} } catch (e) { return { error: e.name + ': ' + e.message }; } })()`, true), closed]);
|
const r = await Promise.race([w.webContents.executeJavaScript(`(async () => { try { ${HELPERS} ${script} } catch (e) { return { error: e.name + ': ' + e.message }; } })()`, true), closed]);
|
||||||
if (r?.error === 'cancelled' || /NotAllowedError|AbortError/.test(r?.error || '')) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
|
if (r?.error === 'cancelled' || /NotAllowedError|AbortError/.test(r?.error || '')) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
|
||||||
@@ -50,8 +62,71 @@ async function ceremony(parent, text, script) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registrierung direkt per CTAP2 (Linux). Liefert die Credential-ID (base64url) oder null, wenn kein
|
||||||
|
// FIDO2-Gerät per hidraw erreichbar ist (dann übernimmt Chromium).
|
||||||
|
async function registerNative(parent) {
|
||||||
|
const w = await openWindow(parent, i18n.t('Connecting to security key …'));
|
||||||
|
let abort = () => {};
|
||||||
|
let cancelled = false;
|
||||||
|
w.once('closed', () => { cancelled = true; abort(); });
|
||||||
|
const js = (code) => (w.isDestroyed() ? Promise.resolve(null) : w.webContents.executeJavaScript(code).catch(() => null));
|
||||||
|
const setText = (t) => js(`document.getElementById('t').textContent = ${JSON.stringify(t)}; 0;`);
|
||||||
|
const ui = {
|
||||||
|
async askPin(retries, wrong) {
|
||||||
|
let t = i18n.t('Enter the PIN of your security key.');
|
||||||
|
if (wrong) t = i18n.t('Wrong PIN.') + ' ' + t;
|
||||||
|
if (retries != null) t += ' ' + i18n.t('{n} attempts left.', { n: retries });
|
||||||
|
await setText(t);
|
||||||
|
const closed = new Promise((resolve) => w.once('closed', () => resolve(null)));
|
||||||
|
return Promise.race([closed, js(`new Promise((resolve) => {
|
||||||
|
const f = document.getElementById('f'), p = document.getElementById('p');
|
||||||
|
f.hidden = false; p.value = ''; p.focus();
|
||||||
|
f.onsubmit = (e) => { e.preventDefault(); f.hidden = true; resolve(p.value); };
|
||||||
|
})`)]);
|
||||||
|
},
|
||||||
|
touch(a) { abort = a; if (cancelled) a(); setText(i18n.t('Touch your security key to register it.')); },
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const id = await ctap2.makeCredential(ui);
|
||||||
|
return id && b64url(id);
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled || ctap2.CANCEL_CODES.has(e.code)) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
|
||||||
|
if (e.code === ctap2.PIN.BLOCKED || e.code === ctap2.PIN.AUTH_BLOCKED) throw new Error(i18n.t('The PIN of this security key is blocked. Remove and reinsert the key, or reset it.'));
|
||||||
|
if (e.code === -1) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
if (!w.isDestroyed()) w.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRF-Abfrage direkt per CTAP2 (Linux); null, wenn kein FIDO2-Gerät per hidraw erreichbar ist
|
||||||
|
async function deriveNative(parent, creds, text) {
|
||||||
|
const w = await openWindow(parent, text || i18n.t('Touch your security key to unlock MrTerm.'));
|
||||||
|
let abort = () => {};
|
||||||
|
let cancelled = false;
|
||||||
|
w.once('closed', () => { cancelled = true; abort(); });
|
||||||
|
try {
|
||||||
|
const r = await ctap2.getHmacSecret({ touch(a) { abort = a; if (cancelled) a(); } },
|
||||||
|
creds.map((c) => ({ credId: Buffer.from(c.credId, 'base64url'), salt: Buffer.from(c.prfSalt, 'base64url') })));
|
||||||
|
return r && { credId: b64url(r.credId), secret: r.secret };
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled || ctap2.CANCEL_CODES.has(e.code)) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
|
||||||
|
if (e.code === 0x2e) throw new Error(i18n.t('Unknown security key.'));
|
||||||
|
if (e.code === -1) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
if (!w.isDestroyed()) w.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const b64url = (buf) => Buffer.from(buf).toString('base64url');
|
||||||
|
|
||||||
// Neuen Schlüssel registrieren; liefert die Credential-ID (base64url)
|
// Neuen Schlüssel registrieren; liefert die Credential-ID (base64url)
|
||||||
async function register(parent) {
|
async function register(parent) {
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
const id = await registerNative(parent);
|
||||||
|
if (id) return id;
|
||||||
|
}
|
||||||
const r = await ceremony(parent, i18n.t('Touch your security key to register it.'), `
|
const r = await ceremony(parent, i18n.t('Touch your security key to register it.'), `
|
||||||
const cred = await navigator.credentials.create({ publicKey: {
|
const cred = await navigator.credentials.create({ publicKey: {
|
||||||
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||||
@@ -69,6 +144,10 @@ async function register(parent) {
|
|||||||
// PRF-Wert für einen der Schlüssel abfragen. creds: [{ credId, prfSalt }] (base64url)
|
// PRF-Wert für einen der Schlüssel abfragen. creds: [{ credId, prfSalt }] (base64url)
|
||||||
// Liefert { credId, secret: Buffer(32) }
|
// Liefert { credId, secret: Buffer(32) }
|
||||||
async function derive(parent, creds, text) {
|
async function derive(parent, creds, text) {
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
const r = await deriveNative(parent, creds, text);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
const r = await ceremony(parent, text || i18n.t('Touch your security key to unlock MrTerm.'), `
|
const r = await ceremony(parent, text || i18n.t('Touch your security key to unlock MrTerm.'), `
|
||||||
const creds = ${JSON.stringify(creds)};
|
const creds = ${JSON.stringify(creds)};
|
||||||
const evalByCredential = Object.fromEntries(creds.map((c) => [c.credId, { first: unb64(c.prfSalt) }]));
|
const evalByCredential = Object.fromEntries(creds.map((c) => [c.credId, { first: unb64(c.prfSalt) }]));
|
||||||
|
|||||||
+89
-4
@@ -5,7 +5,8 @@
|
|||||||
const $ = (sel, root = document) => root.querySelector(sel);
|
const $ = (sel, root = document) => root.querySelector(sel);
|
||||||
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
||||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
const uid = () => crypto.randomUUID();
|
// crypto.randomUUID gibt es nur in sicheren Kontexten (HTTPS/localhost) – die Web-Version läuft oft per http://<server>
|
||||||
|
const uid = () => (crypto.randomUUID ? crypto.randomUUID() : ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)));
|
||||||
function h(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; }
|
function h(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; }
|
||||||
const T = I18N.t;
|
const T = I18N.t;
|
||||||
// Web-Version (Docker, src/web): gleiche Oberfläche im Browser; ohne RDP, VPN, Port-Forwarding und lokale Dateien
|
// Web-Version (Docker, src/web): gleiche Oberfläche im Browser; ohne RDP, VPN, Port-Forwarding und lokale Dateien
|
||||||
@@ -657,7 +658,7 @@ function viewSnippets(page) {
|
|||||||
page.append(toolbar(T('Search snippets …'), [{ label: T('New snippet'), icon: 'plus', cls: 'primary', run: () => editSnippet() }]));
|
page.append(toolbar(T('Search snippets …'), [{ label: T('New snippet'), icon: 'plus', cls: 'primary', run: () => editSnippet() }]));
|
||||||
const c = h('<div class="content"></div>'); page.append(c);
|
const c = h('<div class="content"></div>'); page.append(c);
|
||||||
const list = S.vault.snippets.filter((s) => matches(s.label, s.command));
|
const list = S.vault.snippets.filter((s) => matches(s.label, s.command));
|
||||||
if (!list.length) return c.append(emptyState('code', T('No snippets'), T('Save frequently used commands and send them to a terminal with one click.'), T('New snippet'), () => editSnippet()));
|
if (!list.length) { c.append(emptyState('code', T('No snippets'), T('Save frequently used commands and send them to a terminal with one click.'), T('New snippet'), () => editSnippet())); c.append(templateList()); return; }
|
||||||
const l = h('<div class="list"></div>');
|
const l = h('<div class="list"></div>');
|
||||||
for (const s of list) {
|
for (const s of list) {
|
||||||
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div><div class="meta"><div class="title">${esc(s.label)}</div><div class="sub mono">${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}</div></div><div class="actions"><button data-a="run" title="${T('Run in active terminal')}">${ICONS.play}</button><button data-a="copy" title="${T('Copy')}">${ICONS.copy}</button><button data-a="del" title="${T('Delete')}">${ICONS.trash}</button></div></div>`);
|
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div><div class="meta"><div class="title">${esc(s.label)}</div><div class="sub mono">${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}</div></div><div class="actions"><button data-a="run" title="${T('Run in active terminal')}">${ICONS.play}</button><button data-a="copy" title="${T('Copy')}">${ICONS.copy}</button><button data-a="del" title="${T('Delete')}">${ICONS.trash}</button></div></div>`);
|
||||||
@@ -670,7 +671,7 @@ function viewSnippets(page) {
|
|||||||
};
|
};
|
||||||
l.append(card);
|
l.append(card);
|
||||||
}
|
}
|
||||||
c.append(l);
|
c.append(l, templateList());
|
||||||
}
|
}
|
||||||
function editSnippet(s = {}) {
|
function editSnippet(s = {}) {
|
||||||
const form = openDrawer(s.id ? T('Edit snippet') : T('New snippet'), [
|
const form = openDrawer(s.id ? T('Edit snippet') : T('New snippet'), [
|
||||||
@@ -684,6 +685,86 @@ function editSnippet(s = {}) {
|
|||||||
reload();
|
reload();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Eingebaute Vorlagen (nicht im Vault). Parameter werden vor dem Ausführen abgefragt; build() liefert eine Zeile.
|
||||||
|
// $S = sudo, außer man ist bereits root.
|
||||||
|
const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
|
||||||
|
const SUDO = 'S=$([ "$(id -u)" = 0 ] || echo sudo);';
|
||||||
|
const SNIPPET_TEMPLATES = [
|
||||||
|
{
|
||||||
|
label: () => T('Authorize SSH key for a user'),
|
||||||
|
desc: () => T('Adds a public key from the keychain to ~/.ssh/authorized_keys of the user.'),
|
||||||
|
params: () => [
|
||||||
|
S.vault.keys.some((k) => k.publicKey)
|
||||||
|
? field(T('Key'), 'key', '', { type: 'select', options: S.vault.keys.filter((k) => k.publicKey).map((k) => [k.id, k.label]) })
|
||||||
|
: field('Public Key', 'pub', '', { type: 'textarea', placeholder: 'ssh-ed25519 AAAA…' }),
|
||||||
|
field(T('User'), 'user', 'root'),
|
||||||
|
],
|
||||||
|
build: (v) => {
|
||||||
|
const pub = (v.key ? S.vault.keys.find((k) => k.id === v.key)?.publicKey : v.pub || '').trim();
|
||||||
|
if (!pub) throw new Error(T('Public key is missing.'));
|
||||||
|
if (!v.user.trim()) throw new Error(T('User is missing.'));
|
||||||
|
return `${SUDO} u=${shq(v.user.trim())}; k=${shq(pub)}; d="$(getent passwd "$u" | cut -d: -f6)/.ssh"; `
|
||||||
|
+ `$S install -d -m 700 -o "$u" -g "$(id -gn "$u")" "$d" && { $S grep -qxF "$k" "$d/authorized_keys" 2>/dev/null || echo "$k" | $S tee -a "$d/authorized_keys" >/dev/null; } `
|
||||||
|
+ `&& $S chown "$u": "$d/authorized_keys" && $S chmod 600 "$d/authorized_keys" && echo "OK: $d/authorized_keys"`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: () => T('Install QEMU guest agent'),
|
||||||
|
desc: () => T('Installs and starts qemu-guest-agent (apt, dnf, yum, pacman, zypper or apk).'),
|
||||||
|
build: () => `${SUDO} if command -v apt-get >/dev/null; then $S apt-get update && $S apt-get install -y qemu-guest-agent; `
|
||||||
|
+ 'elif command -v dnf >/dev/null; then $S dnf install -y qemu-guest-agent; elif command -v yum >/dev/null; then $S yum install -y qemu-guest-agent; '
|
||||||
|
+ 'elif command -v pacman >/dev/null; then $S pacman -S --needed --noconfirm qemu-guest-agent; elif command -v zypper >/dev/null; then $S zypper -n install qemu-guest-agent; '
|
||||||
|
+ 'elif command -v apk >/dev/null; then $S apk add qemu-guest-agent && $S rc-update add qemu-guest-agent && $S rc-service qemu-guest-agent start; fi; '
|
||||||
|
+ 'command -v systemctl >/dev/null && { $S systemctl enable --now qemu-guest-agent 2>/dev/null || $S systemctl start qemu-guest-agent; }; '
|
||||||
|
+ 'command -v systemctl >/dev/null && systemctl is-active qemu-guest-agent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: () => T('Use APT cache server'),
|
||||||
|
desc: () => T('Routes APT downloads through a cache proxy such as apt-cacher-ng (/etc/apt/apt.conf.d/00proxy).'),
|
||||||
|
params: () => [field(T('Proxy URL'), 'url', 'http://', { placeholder: 'http://192.168.1.10:3142' })],
|
||||||
|
build: (v) => {
|
||||||
|
const url = v.url.trim();
|
||||||
|
if (!/^https?:\/\/[^\s/]+/.test(url)) throw new Error(T('Invalid URL.'));
|
||||||
|
return `${SUDO} echo ${shq(`Acquire::http::Proxy "${url}";`)} | $S tee /etc/apt/apt.conf.d/00proxy && $S apt-get update`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: () => T('Remove APT cache server'),
|
||||||
|
desc: () => T('Removes the APT proxy setting again.'),
|
||||||
|
build: () => `${SUDO} $S rm -f /etc/apt/apt.conf.d/00proxy && $S apt-get update`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: () => T('System update'),
|
||||||
|
desc: () => T('Updates all packages with the system package manager.'),
|
||||||
|
build: () => `${SUDO} if command -v apt-get >/dev/null; then $S apt-get update && $S apt-get -y full-upgrade; elif command -v dnf >/dev/null; then $S dnf -y upgrade; `
|
||||||
|
+ 'elif command -v pacman >/dev/null; then $S pacman -Syu --noconfirm; elif command -v zypper >/dev/null; then $S zypper -n update; elif command -v apk >/dev/null; then $S apk upgrade --update; fi',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fragt die Parameter einer Vorlage ab und schickt den Befehl ins Terminal
|
||||||
|
async function runTemplate(t, session) {
|
||||||
|
const params = t.params ? t.params() : [];
|
||||||
|
let v = {};
|
||||||
|
if (params.length) {
|
||||||
|
const body = `<p style="margin-top:0;color:var(--muted)">${esc(t.desc())}</p>${params.map((el) => el.outerHTML).join('')}`;
|
||||||
|
v = await modal({ title: t.label(), body, buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Run'), value: 'form', cls: 'primary' }] });
|
||||||
|
if (!v) return;
|
||||||
|
}
|
||||||
|
let command;
|
||||||
|
try { command = t.build(v); } catch (e) { return toast(e.message, 'error'); }
|
||||||
|
runSnippet({ command, autoRun: true }, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateList(session) {
|
||||||
|
const d = h(`<details class="snip-tpl"><summary>${T('Templates')}</summary><div class="tpl-items"></div></details>`);
|
||||||
|
for (const t of SNIPPET_TEMPLATES) {
|
||||||
|
const it = h(`<div class="it"><div>${esc(t.label())}</div><div class="hint">${esc(t.desc())}</div></div>`);
|
||||||
|
it.onclick = () => runTemplate(t, session);
|
||||||
|
$('.tpl-items', d).append(it);
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
function runSnippet(s, session) {
|
function runSnippet(s, session) {
|
||||||
const t = session || S.tabs.find((x) => x.id === S.active && x.kind === 'ssh') || [...S.tabs].reverse().find((x) => x.kind === 'ssh');
|
const t = session || S.tabs.find((x) => x.id === S.active && x.kind === 'ssh') || [...S.tabs].reverse().find((x) => x.kind === 'ssh');
|
||||||
if (!t) return toast(T('No open terminal'), 'error');
|
if (!t) return toast(T('No open terminal'), 'error');
|
||||||
@@ -1337,11 +1418,14 @@ async function importRdm() {
|
|||||||
|
|
||||||
// ============================================================ Updates
|
// ============================================================ Updates
|
||||||
let updateInfo = null;
|
let updateInfo = null;
|
||||||
|
let updateDialogOpen = false; // Auto-Prüfung beim Start und manuelle Prüfung sollen keine zwei Dialoge stapeln
|
||||||
async function showUpdate(info) {
|
async function showUpdate(info) {
|
||||||
updateInfo = info;
|
updateInfo = info;
|
||||||
const b = $('#updateBadge');
|
const b = $('#updateBadge');
|
||||||
b.style.display = '';
|
b.style.display = '';
|
||||||
b.querySelector('span').textContent = `Update ${info.version}`;
|
b.querySelector('span').textContent = `Update ${info.version}`;
|
||||||
|
if (updateDialogOpen) return;
|
||||||
|
updateDialogOpen = true;
|
||||||
const r = await modal({
|
const r = await modal({
|
||||||
title: T('MrTerm {v} is available', { v: info.version }),
|
title: T('MrTerm {v} is available', { v: info.version }),
|
||||||
text: T('Installed: {v}', { v: info.current }) + (info.asset ? ' · ' + T('Package: {name} ({size})', { name: info.asset.name, size: fmtSize(info.asset.size) }) : ''),
|
text: T('Installed: {v}', { v: info.current }) + (info.asset ? ' · ' + T('Package: {name} ({size})', { name: info.asset.name, size: fmtSize(info.asset.size) }) : ''),
|
||||||
@@ -1349,7 +1433,7 @@ async function showUpdate(info) {
|
|||||||
${info.kind === 'dev' ? `<p>${T('Development mode: please update via <code>git pull</code>.')}</p>` : !info.asset ? `<p>${T('The release contains no package for this system.')}</p>` : ''}
|
${info.kind === 'dev' ? `<p>${T('Development mode: please update via <code>git pull</code>.')}</p>` : !info.asset ? `<p>${T('The release contains no package for this system.')}</p>` : ''}
|
||||||
<div class="upd-prog" style="display:none"><div class="transfer" style="padding:6px 0"><span class="nm">${T('Downloading …')}</span><span class="pct"></span><div class="bar"><i></i></div></div></div>`,
|
<div class="upd-prog" style="display:none"><div class="transfer" style="padding:6px 0"><span class="nm">${T('Downloading …')}</span><span class="pct"></span><div class="bar"><i></i></div></div></div>`,
|
||||||
buttons: [{ label: T('Later'), value: false, cls: 'ghost' }, { label: T('Release page'), value: 'web', cls: '' }, ...(info.asset && info.kind !== 'dev' ? [{ label: T('Install now'), value: true, cls: 'primary' }] : [])],
|
buttons: [{ label: T('Later'), value: false, cls: 'ghost' }, { label: T('Release page'), value: 'web', cls: '' }, ...(info.asset && info.kind !== 'dev' ? [{ label: T('Install now'), value: true, cls: 'primary' }] : [])],
|
||||||
});
|
}).finally(() => { updateDialogOpen = false; });
|
||||||
if (r === 'web') return api.call('shell:open', info.url);
|
if (r === 'web') return api.call('shell:open', info.url);
|
||||||
if (r !== true) return;
|
if (r !== true) return;
|
||||||
toast(T('Downloading update …'));
|
toast(T('Downloading update …'));
|
||||||
@@ -1500,6 +1584,7 @@ class TerminalSession {
|
|||||||
it.onclick = () => runSnippet(s, this);
|
it.onclick = () => runSnippet(s, this);
|
||||||
box.append(it);
|
box.append(it);
|
||||||
});
|
});
|
||||||
|
box.append(templateList(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleFind(open) {
|
toggleFind(open) {
|
||||||
|
|||||||
@@ -191,6 +191,12 @@ body.in-session #sidebar { display: none; }
|
|||||||
.snip-panel .items { overflow: auto; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
|
.snip-panel .items { overflow: auto; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
.snip-panel .it { background: var(--card); border-radius: 8px; padding: 9px 10px; cursor: pointer; }
|
.snip-panel .it { background: var(--card); border-radius: 8px; padding: 9px 10px; cursor: pointer; }
|
||||||
.snip-panel .it:hover { background: var(--card-hover); }
|
.snip-panel .it:hover { background: var(--card-hover); }
|
||||||
|
.snip-tpl { margin-top: 10px; }
|
||||||
|
.snip-tpl summary { cursor: pointer; color: var(--muted); font-weight: 600; padding: 6px 2px; user-select: none; }
|
||||||
|
.snip-tpl .tpl-items { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
|
||||||
|
.snip-tpl .it { background: var(--card); border-radius: 8px; padding: 9px 10px; cursor: pointer; }
|
||||||
|
.snip-tpl .it:hover { background: var(--card-hover); }
|
||||||
|
.snip-tpl .hint { color: var(--muted); font-size: 12px; margin-top: 3px; }
|
||||||
.snip-panel .it .mono { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; }
|
.snip-panel .it .mono { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; }
|
||||||
.findbar { position: absolute; top: 8px; right: 24px; z-index: 4; display: none; gap: 4px; align-items: center; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px #0008; }
|
.findbar { position: absolute; top: 8px; right: 24px; z-index: 4; display: none; gap: 4px; align-items: center; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px #0008; }
|
||||||
.findbar.open { display: flex; }
|
.findbar.open { display: flex; }
|
||||||
|
|||||||
@@ -67,8 +67,17 @@
|
|||||||
|
|
||||||
// Kanäle, die im Browser selbst erledigt werden
|
// Kanäle, die im Browser selbst erledigt werden
|
||||||
const local = {
|
const local = {
|
||||||
'clipboard:write': (t) => navigator.clipboard.writeText(String(t)),
|
// Ohne HTTPS gibt es navigator.clipboard nicht: Kopieren per execCommand, Einfügen dann nur per Strg+V
|
||||||
'clipboard:read': () => navigator.clipboard.readText(),
|
'clipboard:write': (t) => {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(String(t));
|
||||||
|
const ta = Object.assign(document.createElement('textarea'), { value: String(t) });
|
||||||
|
ta.style.cssText = 'position:fixed;opacity:0';
|
||||||
|
document.body.append(ta); ta.select(); document.execCommand('copy'); ta.remove();
|
||||||
|
},
|
||||||
|
'clipboard:read': () => {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.readText();
|
||||||
|
throw new Error('Pasting from the menu needs HTTPS. Use Ctrl+Shift+V or Ctrl+V instead.');
|
||||||
|
},
|
||||||
'shell:open': (url) => { if (/^https?:\/\//i.test(url)) window.open(url, '_blank', 'noopener'); },
|
'shell:open': (url) => { if (/^https?:\/\//i.test(url)) window.open(url, '_blank', 'noopener'); },
|
||||||
'vault:export': async () => { downloadText('mrterm-backup.json', JSON.stringify(await remote('vault:exportData', []), null, 2)); return 'mrterm-backup.json'; },
|
'vault:export': async () => { downloadText('mrterm-backup.json', JSON.stringify(await remote('vault:exportData', []), null, 2)); return 'mrterm-backup.json'; },
|
||||||
'vault:import': async () => { const f = await pickFile('.json,application/json'); if (!f) return false; return remote('vault:importData', [JSON.parse(f.content)]); },
|
'vault:import': async () => { const f = await pickFile('.json,application/json'); if (!f) return false; return remote('vault:importData', [JSON.parse(f.content)]); },
|
||||||
|
|||||||
Reference in New Issue
Block a user