Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
12b344d
docs: keyword highlighting design spec
thangnm93 Jun 7, 2026
df1a1ec
feat(xterm): KeywordHighlightRule type for render-layer keyword highl…
thangnm93 Jun 7, 2026
0eae0dd
fix(xterm): correct export filename keyboard→keyword in ui.dart; add …
thangnm93 Jun 7, 2026
6d0f41e
feat(xterm): paintKeywordForeground — re-render cells with override c…
thangnm93 Jun 7, 2026
c2e151a
feat(xterm): render keyword highlights on visible lines at paint time
thangnm93 Jun 7, 2026
b817363
feat(xterm): thread keywordRules through TerminalView → RenderTerminal
thangnm93 Jun 7, 2026
a4ed67f
feat: AppKeywordHighlightRule model with toXtermRule, JSON roundtrip,…
thangnm93 Jun 7, 2026
fc60e23
feat: SettingsProvider — keywordHighlightingEnabled + keywordHighligh…
thangnm93 Jun 7, 2026
a773757
feat: wire keyword highlight rules from SettingsProvider into Termina…
thangnm93 Jun 7, 2026
28f4d28
feat: keyword highlighting settings UI — rule list, add/edit dialog, …
thangnm93 Jun 7, 2026
01e617d
feat: terminal config panel — compact keyword highlight toggle section
thangnm93 Jun 7, 2026
21d2656
fix(keyword-highlight): semi-transparent default bg colors, lineY tru…
thangnm93 Jun 7, 2026
f7b096e
docs: server monitor panel design spec
thangnm93 Jun 8, 2026
41d25de
docs: server monitor panel implementation plan
thangnm93 Jun 8, 2026
48102bf
feat(monitor): SystemSnapshot model with proc/stat+meminfo+df+ss parser
thangnm93 Jun 8, 2026
5c93a03
feat(monitor): FirewallStatus model with ufw/iptables/nftables parser
thangnm93 Jun 8, 2026
296bb58
feat(monitor): SystemStatsService — 5s polling via SSH exec
thangnm93 Jun 8, 2026
abbc77b
feat(monitor): FirewallStatusService — 30s polling for ufw/iptables/n…
thangnm93 Jun 8, 2026
c3b1a0f
feat(monitor): ServerMonitorSheet — draggable bottom sheet with CPU/m…
thangnm93 Jun 8, 2026
f950fd4
feat(monitor): wire ServerMonitorSheet into host card hover button + …
thangnm93 Jun 8, 2026
fe14784
docs(roadmap): mark keyword highlighting + server monitor panel as sh…
thangnm93 Jun 8, 2026
7aefd75
docs(spec): discover local devices — mDNS + TCP scan design
thangnm93 Jun 8, 2026
0d98afe
fix(keyword-highlighting): address code-review findings
thangnm93 Jun 8, 2026
3a4e049
Merge branch 'master' into develop
thangnm93 Jun 8, 2026
20dcd74
docs(plan): discover local devices implementation plan
thangnm93 Jun 8, 2026
3e79c26
feat(discover): add DiscoveredHost model + multicast_dns dep
thangnm93 Jun 8, 2026
821dbc3
feat(discover): NetworkDiscoveryService — TCP scan + mDNS
thangnm93 Jun 8, 2026
87be236
test(discover): NetworkDiscoveryService unit tests
thangnm93 Jun 8, 2026
e877395
feat(discover): NetworkDiscoverySheet — bottom sheet UI
thangnm93 Jun 8, 2026
8d70283
feat(discover): add initialHost/Port/Label/Protocol to HostDetailPanel
thangnm93 Jun 8, 2026
f8262b1
feat(discover): Discover button on Hosts Dashboard toolbar
thangnm93 Jun 8, 2026
01b6243
feat(discover): Scan network link in Add Host panel
thangnm93 Jun 8, 2026
dd37b80
docs(spec): import sources expansion — 9-source picker + parser archi…
thangnm93 Jun 8, 2026
477f915
docs(plan): import sources expansion — 10-task TDD implementation plan
thangnm93 Jun 8, 2026
28dfde5
fix(discover): address 10 code-review findings
thangnm93 Jun 8, 2026
34bcff0
refactor(import): extract SshConfigParser + CsvParser into import_par…
thangnm93 Jun 8, 2026
d3c1945
fix(known-hosts): style Import button to match toolbar outlined butto…
thangnm93 Jun 8, 2026
514e19a
refactor(import): remove unused dart:convert import from import_parse…
thangnm93 Jun 8, 2026
403e243
fix(monitor): use df -Pk to fix mount point labels on macOS servers
thangnm93 Jun 8, 2026
d9c8be0
feat(import): PuTTY .reg parser
thangnm93 Jun 8, 2026
4e78d59
feat(import): MobaXterm .mxtsessions parser
thangnm93 Jun 8, 2026
f1f2b62
feat(import): SecureCRT XML parser
thangnm93 Jun 8, 2026
e2ca406
feat(import): Ansible INI inventory parser
thangnm93 Jun 8, 2026
cfae9bb
fix(import): md5 fingerprint in known_hosts importer; WinSCP + Secure…
thangnm93 Jun 8, 2026
c4b1026
feat(import): WinSCP .ini parser
thangnm93 Jun 8, 2026
183473d
feat(import): Termius JSON parser
thangnm93 Jun 8, 2026
165875e
docs: changelog [Unreleased] + roadmap for keyword highlighting, serv…
thangnm93 Jun 8, 2026
866c185
feat(import): SSH URI parser
thangnm93 Jun 8, 2026
0cfe2c1
feat(import): source-picker UI + ImportSourceDef registry
thangnm93 Jun 8, 2026
17afec0
fix(import): remove unnecessary cast in TermiusParser fallback path
thangnm93 Jun 8, 2026
695bf1e
fix(known-hosts): add importHosts to KnownHostsProvider; add missing …
thangnm93 Jun 8, 2026
25652b6
fix(monitor): add in-flight guard, debugPrint, onError callbacks to p…
thangnm93 Jun 8, 2026
113ed78
fix(monitor): add error state to ServerMonitorSheet
thangnm93 Jun 8, 2026
7456e3f
fix(discover): add debugPrint to silent catches in NetworkDiscoverySe…
thangnm93 Jun 8, 2026
0e78661
fix(discover): add error handling to _loadSubnets and scan onError in…
thangnm93 Jun 8, 2026
008cbb4
fix(discover): fix merge() source computation; fix vacuously-true can…
thangnm93 Jun 8, 2026
8896472
fix(import): correct PuTTY BOM comment from UTF-16 to UTF-8
thangnm93 Jun 8, 2026
1c70b89
fix(keyword-highlight): fix list identity check in keyword rules setter
thangnm93 Jun 8, 2026
5795871
test: add missing tests for KnownHostsImporter, preferredPort, interf…
thangnm93 Jun 8, 2026
0ce7848
chore: bump version to 0.1.34+1; finalize changelog for release
thangnm93 Jun 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.34] — 2026-06-08

### Added
- **Keyword highlighting** — user-defined regex rules tint matching terminal output at paint time in the xterm fork; defaults ship with Error/Warning/Fail rules (red/yellow/cyan); toggle + rule list + add/edit dialog + color picker in Settings → Terminal and the terminal config side panel; rules persisted in `SettingsProvider`
- **Server monitor panel** — per-host live dashboard (CPU / memory / disk / uptime / listening ports / firewall status) in a draggable bottom sheet; access from the host card hover button or right-click context menu; `SystemStatsService` polls every 5 s via a single compound SSH exec with sentinel markers; `FirewallStatusService` polls every 30 s and auto-detects ufw / iptables / nftables; requires an active SSH session
- **Network discovery** — scan the local network for SSH/RDP hosts without typing an IP; `NetworkDiscoveryService` combines mDNS (`_ssh._tcp`, `_rdp._tcp`) and a configurable TCP port scan on the local subnet; results appear in a bottom sheet with one-tap **Add Host**; also reachable from a **Scan network** link in the Add Host panel
- **Import sources expansion** — nine import sources with a source-picker grid UI; five new formats alongside the existing SSH config / JSON / CSV:
- **PuTTY** `.reg` registry export (hex port decoding, URL-decoded session names)
- **MobaXterm** `.mxtsessions` (SSH sessions only, type `0`; handles multi-section `[Bookmarks_N]` files)
- **SecureCRT** XML session files (recursive folder traversal → group path)
- **Ansible** INI inventory (`ansible_host`, `ansible_user` / `ansible_ssh_user`, `ansible_port` with validation; `:vars` and `:children` sections skipped)
- **WinSCP** `.ini` session export (URL-decoded names, nested path → label + group)
- **Termius** JSON export (`address` → host, `group.label` → group; falls back to YourSSH JSON format)
- **SSH URI** — one `ssh://user@host:port` per line
- **Known hosts import** — import `~/.ssh/known_hosts` into the app's known-hosts store via an IMPORT button on the Known Hosts screen; skips duplicates (host:port:keyType) and hashed entries; fingerprints computed as MD5(key\_blob) to match what dartssh2 passes to the host-key verifier

### Fixed
- Server monitor panel: disk mount-point labels showed raw inode-count numbers on macOS servers — fixed by using `df -Pk` (POSIX output format) instead of `df -k`
- Server monitor panel: polling services now guard against overlapping exec calls (in-flight guard); errors surface in the sheet instead of showing an infinite spinner
- Network discovery: silent error catches replaced with `debugPrint` logging; `_loadSubnets` now handles `NetworkInterface.list` failures gracefully; scan errors shown in the UI
- `DiscoveredHost.merge()` now preserves source when merging two hosts from the same discovery method

---

## [0.1.33] — 2026-06-07

### Added
Expand Down
120 changes: 120 additions & 0 deletions app/lib/models/discovered_host.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
enum DiscoverySource { mdns, tcpScan, both }

class DiscoveredHost {
final String ip;
final String? hostname;
final List<int> openPorts;
final DiscoverySource source;
final String? mdnsServiceType;

const DiscoveredHost({
required this.ip,
this.hostname,
required this.openPorts,
required this.source,
this.mdnsServiceType,
});

DiscoveredHost merge(DiscoveredHost other) {
final ports = {...openPorts, ...other.openPorts}.toList()..sort();
return DiscoveredHost(
ip: ip,
hostname: hostname ?? other.hostname,
openPorts: ports,
source: source == other.source ? source : DiscoverySource.both,
mdnsServiceType: mdnsServiceType ?? other.mdnsServiceType,
);
}

String get portLabel {
if (openPorts.isEmpty) return '?';
if (openPorts.contains(3389)) return 'RDP';
if (openPorts.contains(22)) return 'SSH';
if (openPorts.contains(2222)) return 'SSH:2222';
return openPorts.first.toString();
}

bool get isRdp => openPorts.contains(3389) && !openPorts.contains(22);

// fix #9: single source of truth for port selection
int get preferredPort {
if (isRdp) return 3389;
if (openPorts.contains(22)) return 22;
if (openPorts.contains(2222)) return 2222;
return openPorts.isEmpty ? 22 : openPorts.first;
}
}

class SubnetInfo {
final String interfaceName;
final String displayName;
final String address;
final String subnet;

const SubnetInfo({
required this.interfaceName,
required this.displayName,
required this.address,
required this.subnet,
});

// fix #10: single source of truth for interface display names (shared with P2PSyncService)
static String interfaceDisplayName(String name) {
final n = name.toLowerCase();
if (n == 'en0') return 'Wi-Fi';
if (n.startsWith('wlan') || n.startsWith('wlp')) return 'Wi-Fi';
if (n.startsWith('en')) return 'Ethernet';
if (n.startsWith('eth')) return 'Ethernet';
if (n.startsWith('utun') || n.startsWith('tun') || n.startsWith('tap')) {
return 'VPN / Tailscale';
}
if (n.startsWith('bridge')) return 'Bridge';
return name;
}

// fix #7: guard against non-IPv4 / malformed addresses
static String subnetFromAddress(String address) {
final parts = address.split('.');
if (parts.length < 4) return '192.168.1.0/24';
return '${parts[0]}.${parts[1]}.${parts[2]}.0/24';
}

// fix #2: respect prefix length instead of always generating 254 hosts
static List<String> hostsInSubnet(String subnet) {
final slash = subnet.indexOf('/');
if (slash < 0) return [];
final base = subnet.substring(0, slash);
final prefix = int.tryParse(subnet.substring(slash + 1));
if (prefix == null || prefix < 0 || prefix > 32) return [];
final parts = base.split('.').map(int.tryParse).toList();
if (parts.length != 4 || parts.any((o) => o == null)) return [];
final network = (parts[0]! << 24) | (parts[1]! << 16) | (parts[2]! << 8) | parts[3]!;
final hostBits = 32 - prefix;
final count = (1 << hostBits) - 2;
if (count <= 0) return [];
return List.generate(count, (i) {
final addr = network + i + 1;
return '${(addr >> 24) & 0xff}.${(addr >> 16) & 0xff}.${(addr >> 8) & 0xff}.${addr & 0xff}';
});
}

/// Returns null when [subnet] is a valid x.x.x.x/y string (prefix /16–/32).
static String? validateSubnet(String subnet) {
final parts = subnet.split('/');
if (parts.length != 2) return 'Expected format: 192.168.1.0/24';
final octets = parts[0].split('.');
if (octets.length != 4) return 'Expected 4 octets';
for (final o in octets) {
final n = int.tryParse(o);
if (n == null || n < 0 || n > 255) return 'Invalid octet: $o';
}
final prefix = int.tryParse(parts[1]);
if (prefix == null || prefix < 16 || prefix > 32) {
return 'Prefix must be /16–/32';
}
return null;
}

@override
String toString() => '$displayName ($address) — $subnet';
}
136 changes: 136 additions & 0 deletions app/lib/models/firewall_status.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
enum FirewallType { ufw, iptables, nftables, none }

class FirewallStatus {
final FirewallType type;
final bool enabled;
final String? defaultInboundPolicy;
final List<FirewallRule> rules;

const FirewallStatus({
required this.type,
required this.enabled,
this.defaultInboundPolicy,
required this.rules,
});

static const _kNone = FirewallStatus(
type: FirewallType.none,
enabled: false,
rules: [],
);

factory FirewallStatus.fromShellOutput(String output) {
if (output.contains('__NO_FIREWALL__')) return _kNone;
if (output.contains('Status: active') || output.contains('Status: inactive')) {
return _parseUfw(output);
}
if (output.contains('*filter') ||
RegExp(r'-A (INPUT|OUTPUT|FORWARD)').hasMatch(output)) {
return _parseIptables(output);
}
if (output.contains('hook input') && output.contains('chain')) {
return _parseNft(output);
}
return _kNone;
}

static FirewallStatus _parseUfw(String output) {
final enabled = output.contains('Status: active');
String? defaultPolicy;
final rules = <FirewallRule>[];
for (final line in output.split('\n')) {
final t = line.trim();
if (t.startsWith('Default:')) {
final m = RegExp(r'Default: (\w+) \(incoming\)').firstMatch(t);
defaultPolicy = m?.group(1)?.toUpperCase();
}
final m = RegExp(r'^\[\s*\d+\]\s+(.+?)\s{2,}(ALLOW|DENY|LIMIT|REJECT)\s').firstMatch(t);
if (m != null) {
rules.add(FirewallRule(description: t, action: m.group(2), chain: null));
}
}
return FirewallStatus(
type: FirewallType.ufw,
enabled: enabled,
defaultInboundPolicy: defaultPolicy,
rules: rules,
);
}

static FirewallStatus _parseIptables(String output) {
String? defaultPolicy;
final rules = <FirewallRule>[];
bool inFilter = false;
for (final line in output.split('\n')) {
final t = line.trim();
if (t == '*filter') {
inFilter = true;
continue;
}
if (t == 'COMMIT') {
inFilter = false;
continue;
}
if (!inFilter) continue;
final chain = RegExp(r'^:INPUT (\w+)').firstMatch(t);
if (chain != null) defaultPolicy = chain.group(1);
final rule = RegExp(r'^-A (\w+) .+ -j (\w+)').firstMatch(t);
if (rule != null) {
rules.add(FirewallRule(
description: t,
action: rule.group(2),
chain: rule.group(1),
));
}
}
return FirewallStatus(
type: FirewallType.iptables,
enabled: true,
defaultInboundPolicy: defaultPolicy,
rules: rules,
);
}

static FirewallStatus _parseNft(String output) {
final policyMatch = RegExp(r'hook input[^;]*;\s*policy (\w+);').firstMatch(output);
final defaultPolicy = policyMatch?.group(1)?.toUpperCase();
final rules = <FirewallRule>[];
for (final line in output.split('\n')) {
final t = line.trim();
if (t.isEmpty ||
t.startsWith('table') ||
t.startsWith('chain') ||
t.startsWith('type') ||
t == '{' ||
t == '}') {
continue;
}
if (t.contains('accept') || t.contains('drop') || t.contains('reject')) {
final action = t.contains('accept')
? 'ACCEPT'
: t.contains('drop')
? 'DROP'
: 'REJECT';
rules.add(FirewallRule(description: t, action: action, chain: 'input'));
}
}
return FirewallStatus(
type: FirewallType.nftables,
enabled: true,
defaultInboundPolicy: defaultPolicy,
rules: rules,
);
}
}

class FirewallRule {
final String description;
final String? action;
final String? chain;

const FirewallRule({
required this.description,
this.action,
this.chain,
});
}
Loading
Loading