mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
* feat: let the login page send the token back to the mobile app * feat: add host-scripts entry to the plugin sdk * feat: let plugins say how they show in the mobile app * feat: frame host for running one plugin view outside the shell * fix: build the SDK host modules in production mode * chore: note the SDK host build fix in the changelog * feat: list plugin engine ranges for the mobile app * feat: host color and icon columns that sync * chore: update translations and plugin pins * fix: test * fix: show source address when rejecting proxy auth * fix: deleting your own account removes all of its data * fix(deps): update body-parser to 2.4.0 (#1736) * fix: body-parser 보안 패치 반영 * chore: 자동 검사를 막던 기존 JSON 서식 정리 --------- Co-authored-by: renovys <180682734+renovys@users.noreply.github.com> * fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735) * fix: honor shared credentials on shared hosts (#1730) * fix: honor shared credentials on shared hosts and in personal SSH auth * fix: snapshot shared SSH auth from a credential shared with the host owner * fix: size DataView list columns by the table's own width (#1713) * fix: size DataView list columns by the table's own width The list grid was built from every column, so columns hidden below md/lg left empty tracks and the visible cells landed under the wrong headers. Each width now gets a template of only its visible columns (CSS variables + container queries on the table, so a narrow panel counts, not the window), the header uses the same gap as the rows, and rows use a minimum height so wrapped cells no longer overlap. * test: wait for the title bar offset effect before asserting The bar shows once getTitleBarVisible resolves, but --termix-titlebar-height is set in a later passive effect, so reading it right after findByText raced (3-5 of 40 runs under load, on main too). * chore: rerun CI against dev-26.10.6 * fix: unlock passphrase keys once per connect burst (#1737) * feat: give ssh transports the host origin and sync id (#1717) * fix: wake other clients after a sync push (Plugin-Sync#1) * feat: look up local row ids by sync id for the sync plugin (Plugin-Sync#4) * feat: rail items can show a status dot (Plugin-Sync#5) * fix: add the desktop shared copy banner text (#1709) * feat: sidebar preference for alternating rows (#1712) * fix: striped rows text * feat: shell can open a saved host's editor for plugins (Plugin-SSH-Terminal#36) * fix: show the logo in the macos menu bar (#1728) * fix: number shortcuts skip the home tab (#1724) * fix: show plugins that could not be downloaded as failed (#1714) * fix: pass the host id as text when opening its editor * fix: retry a plugin bundle that was not ready yet (#1708) * fix: let commonjs plugins fall back to core packages on the desktop * fix: let non docker installs switch nginx to https (#1711) * feat: remove a folder share from its hosts too (#1719) * chore: changelog for merged pull requests * fix: accept the release .sig when installing a plugin from a file * feat: folder logins for shared hosts, read only plugin sync, full desktop builds * fix: test * fix: stop dev runner restart loops and narrow plugin watchers * chore: update translations and plugin pins * chore: lint, format, and bump version to 26.10.6 --------- Co-authored-by: renovys <renovy@gmail.com> Co-authored-by: renovys <180682734+renovys@users.noreply.github.com> Co-authored-by: Sebastian Cao <cycsmail@gmail.com> Co-authored-by: Jonathan Rehuel Lewerissa <jonathan.rehuel16@gmail.com> Co-authored-by: Rhiz3K <33246262+Rhiz3K@users.noreply.github.com>
243 lines
7.3 KiB
TypeScript
243 lines
7.3 KiB
TypeScript
/** journalctl scripts and parsers. */
|
|
import { isUnit } from "./systemd.js";
|
|
import { HostScriptError, shellQuote as q, utf8Decode } from "./text.js";
|
|
|
|
export const MAX_JOURNAL_LINES = 5000;
|
|
const FIELDS = "MESSAGE,PRIORITY,_SYSTEMD_UNIT,SYSLOG_IDENTIFIER,_PID,_COMM";
|
|
|
|
export interface JournalQuery {
|
|
units: string[];
|
|
priority?: number;
|
|
since?: string;
|
|
until?: string;
|
|
boot?: string;
|
|
grep?: string;
|
|
lines: number;
|
|
kernel: boolean;
|
|
}
|
|
|
|
export interface JournalEntry {
|
|
/** Unix ms. */
|
|
time: number;
|
|
priority: number;
|
|
unit: string;
|
|
ident: string;
|
|
pid: string;
|
|
message: string;
|
|
}
|
|
|
|
export interface BootView {
|
|
offset: number;
|
|
id: string;
|
|
first: string;
|
|
last: string;
|
|
}
|
|
|
|
const RELATIVE_RE = /^-\d{1,5}(s|m|min|h|d|w)$/;
|
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/;
|
|
const WORDS = ["today", "yesterday", "now"];
|
|
|
|
export function isJournalTime(value: string): boolean {
|
|
return (
|
|
RELATIVE_RE.test(value) || DATE_RE.test(value) || WORDS.includes(value)
|
|
);
|
|
}
|
|
|
|
function str(value: unknown): string {
|
|
return typeof value === "string" ? value.trim() : "";
|
|
}
|
|
|
|
/** Reads journal filters from a query string. Anything odd throws a HostScriptError. */
|
|
export function parseJournalQuery(
|
|
input: Record<string, unknown>,
|
|
): JournalQuery {
|
|
const rawUnits = Array.isArray(input.unit) ? input.unit : [input.unit];
|
|
const units = rawUnits.map(str).filter(Boolean);
|
|
for (const unit of units) {
|
|
if (!isUnit(unit))
|
|
throw new HostScriptError("That is not a valid unit name");
|
|
}
|
|
if (units.length > 20) throw new HostScriptError("Too many units");
|
|
|
|
const query: JournalQuery = {
|
|
units,
|
|
lines: 300,
|
|
kernel: input.kernel === "true" || input.kernel === true,
|
|
};
|
|
const priority = str(input.priority);
|
|
if (priority) {
|
|
const value = Number(priority);
|
|
if (!Number.isInteger(value) || value < 0 || value > 7) {
|
|
throw new HostScriptError("priority must be 0 to 7");
|
|
}
|
|
query.priority = value;
|
|
}
|
|
for (const key of ["since", "until"] as const) {
|
|
const value = str(input[key]);
|
|
if (!value) continue;
|
|
if (!isJournalTime(value))
|
|
throw new HostScriptError(`${key} is not a valid time`);
|
|
query[key] = value;
|
|
}
|
|
const boot = str(input.boot);
|
|
if (boot) {
|
|
if (!/^(-?\d{1,4}|[0-9a-f]{32})$/.test(boot))
|
|
throw new HostScriptError("boot is not valid");
|
|
query.boot = boot;
|
|
}
|
|
const grep = typeof input.grep === "string" ? input.grep : "";
|
|
if (grep) {
|
|
if (grep.length > 200 || /[\r\n\0]/.test(grep)) {
|
|
throw new HostScriptError("The search is too long");
|
|
}
|
|
query.grep = grep;
|
|
}
|
|
const lines = Number(str(input.lines) || 300);
|
|
if (Number.isFinite(lines) && lines > 0) {
|
|
query.lines = Math.min(MAX_JOURNAL_LINES, Math.round(lines));
|
|
}
|
|
return query;
|
|
}
|
|
|
|
/** journalctl arguments for a query, each one quoted. */
|
|
export function journalArgs(
|
|
query: Omit<JournalQuery, "lines"> & { lines?: number },
|
|
options: { follow?: boolean; useGrep?: boolean } = {},
|
|
): string {
|
|
const args = ["journalctl", "--no-pager", "-q", "-o", "json"];
|
|
args.push(`--output-fields=${FIELDS}`);
|
|
if (options.follow) args.push("-f");
|
|
if (query.lines) args.push("-n", String(Math.floor(query.lines)));
|
|
for (const unit of query.units) {
|
|
if (!isUnit(unit))
|
|
throw new HostScriptError("That is not a valid unit name");
|
|
args.push("-u", q(unit));
|
|
}
|
|
if (query.kernel) args.push("-k");
|
|
if (query.priority !== undefined)
|
|
args.push("-p", String(Math.floor(query.priority)));
|
|
if (query.since) args.push("--since", q(query.since));
|
|
if (query.until) args.push("--until", q(query.until));
|
|
if (query.boot) args.push("-b", q(query.boot));
|
|
if (query.grep && options.useGrep !== false) {
|
|
args.push("--case-sensitive=false", "-g", q(query.grep));
|
|
}
|
|
return args.join(" ");
|
|
}
|
|
|
|
export function journalScript(query: JournalQuery): string {
|
|
// Older journalctl builds have no -g. Then the search runs on our side instead.
|
|
const noGrep = journalArgs(
|
|
{ ...query, lines: query.grep ? MAX_JOURNAL_LINES : query.lines },
|
|
{ useGrep: false },
|
|
);
|
|
return `
|
|
command -v journalctl >/dev/null 2>&1 || { echo "SCRIPT_ERR:This host has no journal"; exit 1; }
|
|
${journalArgs(query)} 2>/tmp/termix-journal.$$
|
|
RC=$?
|
|
if [ $RC -ne 0 ] && grep -qi "pattern" /tmp/termix-journal.$$; then
|
|
rm -f /tmp/termix-journal.$$
|
|
echo "@@TERMIX_NOGREP@@"
|
|
${noGrep}
|
|
exit 0
|
|
fi
|
|
cat /tmp/termix-journal.$$ >&2; rm -f /tmp/termix-journal.$$
|
|
exit $RC
|
|
`;
|
|
}
|
|
|
|
function messageOf(value: unknown): string {
|
|
if (typeof value === "string") return value;
|
|
if (Array.isArray(value)) {
|
|
return utf8Decode(value.filter((n): n is number => typeof n === "number"));
|
|
}
|
|
return value == null ? "" : String(value);
|
|
}
|
|
|
|
/** One `journalctl -o json` line, or null when it is not an entry. */
|
|
export function parseEntry(line: string): JournalEntry | null {
|
|
const text = line.trim();
|
|
if (!text.startsWith("{")) return null;
|
|
let raw: Record<string, unknown>;
|
|
try {
|
|
raw = JSON.parse(text) as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
const micros = Number(raw.__REALTIME_TIMESTAMP);
|
|
const priority = Number(raw.PRIORITY);
|
|
return {
|
|
time: Number.isFinite(micros) ? Math.floor(micros / 1000) : 0,
|
|
priority: Number.isInteger(priority) ? priority : 6,
|
|
unit: messageOf(raw._SYSTEMD_UNIT),
|
|
ident: messageOf(raw.SYSLOG_IDENTIFIER ?? raw._COMM),
|
|
pid: messageOf(raw._PID),
|
|
message: messageOf(raw.MESSAGE),
|
|
};
|
|
}
|
|
|
|
export function parseJournal(
|
|
output: string,
|
|
query: JournalQuery,
|
|
): JournalEntry[] {
|
|
const fallback = output.includes("@@TERMIX_NOGREP@@");
|
|
const body = fallback ? output.split("@@TERMIX_NOGREP@@")[1] : output;
|
|
let entries = body
|
|
.split("\n")
|
|
.map(parseEntry)
|
|
.filter((entry): entry is JournalEntry => entry !== null);
|
|
if (fallback && query.grep) {
|
|
const needle = query.grep.toLowerCase();
|
|
entries = entries
|
|
.filter((entry) => entry.message.toLowerCase().includes(needle))
|
|
.slice(-query.lines);
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
export const BOOTS_SCRIPT = `journalctl --list-boots --no-pager 2>/dev/null | tail -n 50; exit 0\n`;
|
|
|
|
export function parseBoots(output: string): BootView[] {
|
|
const boots: BootView[] = [];
|
|
for (const line of output.split("\n")) {
|
|
const match =
|
|
/^\s*(-?\d+)\s+([0-9a-f]{32})\s+(\w{3} \S+ \S+ \S+)\s*.?\s*(\w{3} \S+ \S+ \S+)?/.exec(
|
|
line,
|
|
);
|
|
if (!match) continue;
|
|
boots.push({
|
|
offset: Number(match[1]),
|
|
id: match[2],
|
|
first: match[3],
|
|
last: match[4] ?? "",
|
|
});
|
|
}
|
|
return boots.reverse();
|
|
}
|
|
|
|
export const USAGE_SCRIPT = `journalctl --disk-usage 2>&1; exit 0\n`;
|
|
|
|
export function parseUsage(output: string): string {
|
|
return /take up (\S+)/.exec(output)?.[1] ?? "";
|
|
}
|
|
|
|
const SIZE_RE = /^\d{1,6}(K|M|G)$/;
|
|
const AGE_RE = /^\d{1,4}(d|w|month|months|year|years)$/;
|
|
|
|
export function vacuumScript(body: Record<string, unknown>): string {
|
|
const size = str(body.size);
|
|
const time = str(body.time);
|
|
if (!size && !time) throw new HostScriptError("Give a size or an age");
|
|
if (size && !SIZE_RE.test(size))
|
|
throw new HostScriptError("Size looks like 500M or 2G");
|
|
if (time && !AGE_RE.test(time))
|
|
throw new HostScriptError("Age looks like 7d or 4w");
|
|
const flags = [
|
|
size ? `--vacuum-size=${size}` : "",
|
|
time ? `--vacuum-time=${time}` : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
return `journalctl ${flags} 2>&1 || { echo "SCRIPT_ERR:journalctl vacuum failed"; exit 1; }\n`;
|
|
}
|