kctl-rustdesk 0.6.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """kctl-rustdesk: Kodemeio RustDesk server management CLI."""
2
+
3
+ __version__ = "0.6.3"
@@ -0,0 +1,3 @@
1
+ from kctl_rustdesk.cli import _run
2
+
3
+ _run()
kctl_rustdesk/cli.py ADDED
@@ -0,0 +1,133 @@
1
+ """kctl-rustdesk CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from kctl_lib import handle_cli_error
11
+ from kctl_lib.exceptions import KctlError
12
+
13
+ from kctl_rustdesk import __version__
14
+ from kctl_rustdesk.commands.audit import app as audit_app
15
+ from kctl_rustdesk.commands.backup import app as backup_app
16
+ from kctl_rustdesk.commands.config_cmd import app as config_app
17
+ from kctl_rustdesk.commands.dashboard import app as dashboard_app
18
+ from kctl_rustdesk.commands.health import app as health_app
19
+ from kctl_rustdesk.commands.maintenance import app as maintenance_app
20
+ from kctl_rustdesk.commands.peers import app as peers_app
21
+ from kctl_rustdesk.commands.setup import app as setup_app
22
+ from kctl_rustdesk.commands.users import app as users_app
23
+ from kctl_rustdesk.core.plugins import discover_and_load_plugins
24
+ from kctl_rustdesk.commands.skill_cmd import app as skill_app
25
+ from kctl_rustdesk.commands.doctor_cmd import app as doctor_app
26
+ from kctl_lib.self_update import notify_if_outdated
27
+ from kctl_lib.tui import add_tui_command
28
+
29
+
30
+ def version_callback(value: bool) -> None:
31
+ if value:
32
+ typer.echo(f"kctl-rustdesk {__version__}")
33
+ raise typer.Exit()
34
+
35
+
36
+ app = typer.Typer(
37
+ name="kctl-rustdesk",
38
+ help="Kodemeio RustDesk CLI — manage RustDesk server infrastructure.",
39
+ no_args_is_help=True,
40
+ rich_markup_mode="rich",
41
+ pretty_exceptions_enable=False,
42
+ )
43
+
44
+
45
+ @app.callback()
46
+ def main(
47
+ ctx: typer.Context,
48
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
49
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress info messages")] = False,
50
+ profile: Annotated[str | None, typer.Option("--profile", "-p", help="Config profile")] = None,
51
+ format: Annotated[str, typer.Option("--format", "-f", help="Output format")] = "pretty",
52
+ no_header: Annotated[bool, typer.Option("--no-header", help="Omit column headers")] = False,
53
+ host: Annotated[str | None, typer.Option("--host", help="Server host override")] = None,
54
+ version: Annotated[bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True)] = False,
55
+ ) -> None:
56
+ """Manage RustDesk server infrastructure."""
57
+ from kctl_rustdesk.core.callbacks import AppContext
58
+
59
+ ctx.ensure_object(dict)
60
+ ctx.obj = AppContext(
61
+ json_mode=json_output,
62
+ quiet=quiet,
63
+ profile=profile,
64
+ format=format,
65
+ no_header=no_header,
66
+ host_override=host,
67
+ )
68
+ notify_if_outdated(ctx.obj.output, "kctl-rustdesk", __version__)
69
+
70
+
71
+ app.add_typer(config_app, name="config")
72
+ app.add_typer(health_app, name="health")
73
+ app.add_typer(dashboard_app, name="dashboard")
74
+ app.add_typer(peers_app, name="peers")
75
+ app.add_typer(users_app, name="users")
76
+ app.add_typer(audit_app, name="audit")
77
+ app.add_typer(backup_app, name="backup")
78
+ app.add_typer(setup_app, name="setup")
79
+ app.add_typer(maintenance_app, name="maintenance")
80
+ app.add_typer(skill_app, name="skill", hidden=True)
81
+ app.add_typer(doctor_app, name="doctor")
82
+
83
+ discover_and_load_plugins(app)
84
+ add_tui_command(app, service_key="rustdesk", version=__version__)
85
+
86
+
87
+ @app.command("self-update")
88
+ def self_update_cmd(ctx: typer.Context) -> None:
89
+ """Check for updates and upgrade kctl-rustdesk."""
90
+ actx = ctx.obj
91
+ out = actx.output
92
+
93
+ from kctl_lib.self_update import check_update
94
+ from kctl_lib.self_update import update as do_update
95
+
96
+ latest = check_update("kctl-rustdesk", __version__)
97
+ if latest:
98
+ out.info(f"Updating to {latest}...")
99
+ do_update("kctl-rustdesk")
100
+ out.success(f"Updated to {latest}")
101
+ else:
102
+ out.success("Already up to date")
103
+
104
+
105
+ @app.command()
106
+ def completions(
107
+ shell: Annotated[str, typer.Argument(help="Shell type: zsh, bash, fish")] = "zsh",
108
+ install: Annotated[bool, typer.Option("--install", help="Install completions")] = False,
109
+ ) -> None:
110
+ """Generate or install shell completions."""
111
+ from kctl_lib.completions import get_completion_script, install_completions
112
+
113
+ if install:
114
+ path = install_completions("kctl-rustdesk", shell)
115
+ if path:
116
+ typer.echo(f"Completions installed to {path}")
117
+ else:
118
+ typer.echo(f"Could not install completions for {shell}", err=True)
119
+ raise typer.Exit(code=1)
120
+ else:
121
+ script = get_completion_script("kctl-rustdesk", shell)
122
+ typer.echo(script)
123
+
124
+
125
+ def _run() -> None:
126
+ try:
127
+ app()
128
+ except KctlError as e:
129
+ handle_cli_error(e)
130
+
131
+
132
+ if __name__ == "__main__":
133
+ _run()
File without changes
@@ -0,0 +1,143 @@
1
+ """Audit log and connection history commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from kctl_rustdesk.core.callbacks import AppContext
10
+
11
+ app = typer.Typer(help="Audit logs and connection history.")
12
+
13
+
14
+ @app.command()
15
+ def connections(
16
+ ctx: typer.Context,
17
+ today: Annotated[bool, typer.Option("--today", help="Only today")] = False,
18
+ limit: Annotated[int, typer.Option("--limit", "-n", help="Max rows")] = 50,
19
+ ) -> None:
20
+ """Show connection history."""
21
+ c: AppContext = ctx.obj
22
+ ex = c.executor
23
+
24
+ where = "WHERE date(created_at) = date('now')" if today else ""
25
+ sql = f"SELECT peer_id, ip, created_at FROM conn_log {where} ORDER BY created_at DESC LIMIT {limit};"
26
+
27
+ rows_data = ex.query_db(sql)
28
+ rows = [[r.get("peer_id", ""), r.get("ip", ""), r.get("created_at", "")] for r in rows_data]
29
+
30
+ title = "Connections (today)" if today else f"Connections (last {limit})"
31
+ c.output.table(
32
+ title,
33
+ [("Peer ID", "cyan"), ("IP", ""), ("Time", "dim")],
34
+ rows,
35
+ data_for_json=rows_data,
36
+ )
37
+
38
+
39
+ @app.command()
40
+ def logins(
41
+ ctx: typer.Context,
42
+ failed: Annotated[bool, typer.Option("--failed", help="Only failed logins")] = False,
43
+ limit: Annotated[int, typer.Option("--limit", "-n", help="Max rows")] = 50,
44
+ ) -> None:
45
+ """Show login history."""
46
+ c: AppContext = ctx.obj
47
+ ex = c.executor
48
+
49
+ where = "WHERE type != 0" if failed else ""
50
+ sql = f"SELECT user_id, ip, type, created_at FROM login_log {where} ORDER BY created_at DESC LIMIT {limit};"
51
+
52
+ rows_data = ex.query_db(sql)
53
+ rows = [
54
+ [
55
+ r.get("user_id", ""),
56
+ r.get("ip", ""),
57
+ "failed" if r.get("type", "0") != "0" else "ok",
58
+ r.get("created_at", ""),
59
+ ]
60
+ for r in rows_data
61
+ ]
62
+
63
+ title = "Failed Logins" if failed else f"Logins (last {limit})"
64
+ c.output.table(
65
+ title,
66
+ [("User", "cyan"), ("IP", ""), ("Status", ""), ("Time", "dim")],
67
+ rows,
68
+ data_for_json=rows_data,
69
+ )
70
+
71
+
72
+ @app.command()
73
+ def stats(ctx: typer.Context) -> None:
74
+ """Show connection statistics."""
75
+ c: AppContext = ctx.obj
76
+ ex = c.executor
77
+
78
+ total_conns = ex.query_db_scalar("SELECT count(*) FROM conn_log;")
79
+ today_conns = ex.query_db_scalar("SELECT count(*) FROM conn_log WHERE date(created_at) = date('now');")
80
+ unique_peers = ex.query_db_scalar("SELECT count(DISTINCT peer_id) FROM conn_log;")
81
+ unique_ips = ex.query_db_scalar("SELECT count(DISTINCT ip) FROM conn_log;")
82
+ total_logins = ex.query_db_scalar("SELECT count(*) FROM login_log;")
83
+ failed_logins = ex.query_db_scalar("SELECT count(*) FROM login_log WHERE type != 0;")
84
+
85
+ top_peers = ex.query_db("SELECT peer_id, count(*) as cnt FROM conn_log GROUP BY peer_id ORDER BY cnt DESC LIMIT 5;")
86
+
87
+ sections = [
88
+ (
89
+ "Connection Stats",
90
+ [
91
+ ("Total connections", total_conns),
92
+ ("Today", today_conns),
93
+ ("Unique peers", unique_peers),
94
+ ("Unique IPs", unique_ips),
95
+ ],
96
+ ),
97
+ (
98
+ "Login Stats",
99
+ [
100
+ ("Total logins", total_logins),
101
+ ("Failed logins", failed_logins),
102
+ ],
103
+ ),
104
+ ("Top Peers", [(p.get("peer_id", ""), f"{p.get('cnt', 0)} connections") for p in top_peers]),
105
+ ]
106
+
107
+ c.output.detail(
108
+ "Audit Statistics",
109
+ sections,
110
+ data_for_json={
111
+ "connections": {
112
+ "total": int(total_conns),
113
+ "today": int(today_conns),
114
+ "unique_peers": int(unique_peers),
115
+ "unique_ips": int(unique_ips),
116
+ },
117
+ "logins": {"total": int(total_logins), "failed": int(failed_logins)},
118
+ "top_peers": top_peers,
119
+ },
120
+ )
121
+
122
+
123
+ @app.command()
124
+ def active(ctx: typer.Context) -> None:
125
+ """Show currently active sessions."""
126
+ c: AppContext = ctx.obj
127
+ ex = c.executor
128
+
129
+ sql = (
130
+ "SELECT peer_id, ip, created_at FROM conn_log "
131
+ "WHERE created_at > datetime('now', '-5 minutes') "
132
+ "ORDER BY created_at DESC;"
133
+ )
134
+
135
+ rows_data = ex.query_db(sql)
136
+ rows = [[r.get("peer_id", ""), r.get("ip", ""), r.get("created_at", "")] for r in rows_data]
137
+
138
+ c.output.table(
139
+ "Active Sessions (last 5m)",
140
+ [("Peer ID", "cyan"), ("IP", ""), ("Connected", "dim")],
141
+ rows,
142
+ data_for_json=rows_data,
143
+ )
@@ -0,0 +1,151 @@
1
+ """Backup and restore commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from kctl_rustdesk.core.callbacks import AppContext
10
+
11
+ app = typer.Typer(help="Backup and restore RustDesk server data.")
12
+
13
+ BACKUP_DIR = "/opt/kodemeio-rustdesk/backups"
14
+ DATA_DIR = "/root"
15
+
16
+
17
+ @app.command()
18
+ def create(ctx: typer.Context) -> None:
19
+ """Create a backup of keys and database."""
20
+ c: AppContext = ctx.obj
21
+ out = c.output
22
+ ex = c.executor
23
+
24
+ out.info("Creating backup...")
25
+ ex.shell(["mkdir", "-p", BACKUP_DIR])
26
+
27
+ timestamp = ex.shell(["date", "+%Y%m%d-%H%M%S"])
28
+ backup_name = f"rustdesk-backup-{timestamp}.tar.gz"
29
+ backup_path = f"{BACKUP_DIR}/{backup_name}"
30
+
31
+ ex.exec_hbbs(
32
+ [
33
+ "tar",
34
+ "czf",
35
+ f"/tmp/{backup_name}",
36
+ "-C",
37
+ DATA_DIR,
38
+ "id_ed25519",
39
+ "id_ed25519.pub",
40
+ "db_v2.sqlite3",
41
+ ]
42
+ )
43
+
44
+ container_name = f"{ex.config.project_name}-hbbs-1"
45
+ ex.shell(["docker", "cp", f"{container_name}:/tmp/{backup_name}", backup_path])
46
+ ex.exec_hbbs(["rm", "-f", f"/tmp/{backup_name}"], check=False)
47
+
48
+ out.success(f"Backup created: {backup_path}")
49
+
50
+
51
+ @app.command("list")
52
+ def list_(ctx: typer.Context) -> None:
53
+ """List available backups."""
54
+ c: AppContext = ctx.obj
55
+ ex = c.executor
56
+
57
+ output = ex.shell(
58
+ ["find", BACKUP_DIR, "-name", "rustdesk-backup-*.tar.gz", "-printf", r"%f\t%s\t%T+\n"],
59
+ check=False,
60
+ )
61
+
62
+ if not output.strip():
63
+ c.output.info("No backups found.")
64
+ return
65
+
66
+ rows: list[list[str]] = []
67
+ for line in output.strip().splitlines():
68
+ parts = line.split("\t")
69
+ if len(parts) >= 3:
70
+ name = parts[0]
71
+ size_bytes = int(parts[1]) if parts[1].isdigit() else 0
72
+ if size_bytes < 1048576:
73
+ size = f"{size_bytes / 1024:.1f} KB"
74
+ else:
75
+ size = f"{size_bytes / 1048576:.1f} MB"
76
+ date = parts[2][:19].replace("T", " ")
77
+ rows.append([name, size, date])
78
+
79
+ c.output.table(
80
+ "Backups",
81
+ [("File", "cyan"), ("Size", ""), ("Date", "dim")],
82
+ rows,
83
+ )
84
+
85
+
86
+ @app.command()
87
+ def restore(
88
+ ctx: typer.Context,
89
+ backup_file: Annotated[str, typer.Argument(help="Backup filename or full path")],
90
+ ) -> None:
91
+ """Restore from a backup file."""
92
+ c: AppContext = ctx.obj
93
+ out = c.output
94
+ ex = c.executor
95
+
96
+ if "/" not in backup_file:
97
+ backup_file = f"{BACKUP_DIR}/{backup_file}"
98
+
99
+ if not typer.confirm(f"Restore from {backup_file}? This will overwrite current data."):
100
+ out.info("Restore cancelled.")
101
+ raise typer.Exit()
102
+
103
+ out.info(f"Restoring from {backup_file}...")
104
+
105
+ container_name = f"{ex.config.project_name}-hbbs-1"
106
+ ex.shell(["docker", "cp", backup_file, f"{container_name}:/tmp/restore.tar.gz"])
107
+ ex.exec_hbbs(["tar", "xzf", "/tmp/restore.tar.gz", "-C", DATA_DIR])
108
+ ex.exec_hbbs(["rm", "-f", "/tmp/restore.tar.gz"])
109
+
110
+ out.info("Restarting services...")
111
+ ex.shell([*ex._dc_cmd(), "restart"])
112
+
113
+ out.success("Restore complete. Services restarted.")
114
+
115
+
116
+ @app.command()
117
+ def clean(
118
+ ctx: typer.Context,
119
+ days: Annotated[int, typer.Option("--days", "-d", help="Delete backups older than N days")] = 30,
120
+ ) -> None:
121
+ """Remove old backups."""
122
+ c: AppContext = ctx.obj
123
+ out = c.output
124
+ ex = c.executor
125
+
126
+ old_files = ex.shell(
127
+ ["find", BACKUP_DIR, "-name", "rustdesk-backup-*.tar.gz", "-mtime", f"+{days}"],
128
+ check=False,
129
+ )
130
+
131
+ if not old_files.strip():
132
+ out.info(f"No backups older than {days} days.")
133
+ return
134
+
135
+ file_count = len(old_files.strip().splitlines())
136
+ if not typer.confirm(f"Delete {file_count} backup(s) older than {days} days?"):
137
+ out.info("Cleanup cancelled.")
138
+ raise typer.Exit()
139
+
140
+ ex.shell(
141
+ [
142
+ "find",
143
+ BACKUP_DIR,
144
+ "-name",
145
+ "rustdesk-backup-*.tar.gz",
146
+ "-mtime",
147
+ f"+{days}",
148
+ "-delete",
149
+ ]
150
+ )
151
+ out.success(f"Deleted {file_count} old backup(s).")
@@ -0,0 +1,173 @@
1
+ """Configuration management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from kctl_lib.config import (
10
+ CONFIG_FILE,
11
+ get_default_profile,
12
+ get_profile_names,
13
+ set_default_profile,
14
+ )
15
+
16
+ from kctl_rustdesk.core.callbacks import AppContext
17
+ from kctl_rustdesk.core.config import (
18
+ SERVICE_KEY,
19
+ ServiceConfig,
20
+ get_rustdesk_config,
21
+ resolve_active_profile,
22
+ set_rustdesk_config,
23
+ )
24
+
25
+ app = typer.Typer(help="Manage CLI configuration and profiles.")
26
+
27
+
28
+ @app.command()
29
+ def init(
30
+ ctx: typer.Context,
31
+ host: Annotated[str | None, typer.Option("--host", help="Server hostname")] = None,
32
+ ssh_user: Annotated[str | None, typer.Option("--ssh-user", help="SSH username")] = None,
33
+ compose_file: Annotated[str | None, typer.Option("--compose-file")] = None,
34
+ env_file: Annotated[str | None, typer.Option("--env-file")] = None,
35
+ project_name: Annotated[str | None, typer.Option("--project-name")] = None,
36
+ domain: Annotated[str | None, typer.Option("--domain")] = None,
37
+ name: Annotated[str | None, typer.Option("--name", "-n", help="Profile name")] = None,
38
+ ) -> None:
39
+ """Initialize CLI configuration with a new profile."""
40
+ c: AppContext = ctx.obj
41
+ out = c.output
42
+
43
+ profile_name = name or typer.prompt("Profile name", default="production")
44
+ h = host or typer.prompt("Server host", default="dokploy.kodeme.io")
45
+ u = ssh_user or typer.prompt("SSH user", default="root")
46
+ cf = compose_file or typer.prompt("Compose file path", default="/opt/kodemeio-rustdesk/docker-compose.prod.yml")
47
+ ef = env_file or typer.prompt("Env file path", default="/opt/kodemeio-rustdesk/.env.prod")
48
+ pn = project_name or typer.prompt("Compose project name", default="kodemeio-rustdesk")
49
+ d = domain or typer.prompt("Domain", default="rustdesk.kodeme.io")
50
+
51
+ svc = ServiceConfig(
52
+ host=h,
53
+ ssh_user=u,
54
+ compose_file=cf,
55
+ env_file=ef,
56
+ project_name=pn,
57
+ domain=d,
58
+ )
59
+ set_rustdesk_config(profile_name, svc)
60
+
61
+ if len(get_profile_names()) <= 1:
62
+ set_default_profile(profile_name)
63
+
64
+ out.success(f"Profile '{profile_name}' saved to {CONFIG_FILE}")
65
+
66
+
67
+ @app.command()
68
+ def show(ctx: typer.Context) -> None:
69
+ """Show current configuration."""
70
+ c: AppContext = ctx.obj
71
+ out = c.output
72
+ default = get_default_profile()
73
+ active = resolve_active_profile(c.profile)
74
+
75
+ sections: list[tuple[str, list[tuple[str, str]]]] = [
76
+ (
77
+ "General",
78
+ [
79
+ ("Config file", str(CONFIG_FILE)),
80
+ ("Default profile", default),
81
+ ("Active profile", active),
82
+ ("Service key", SERVICE_KEY),
83
+ ],
84
+ ),
85
+ ]
86
+
87
+ for pname in get_profile_names():
88
+ marker = " (default)" if pname == default else ""
89
+ svc = get_rustdesk_config(pname)
90
+ sections.append(
91
+ (
92
+ f"Profile: {pname}{marker}",
93
+ [
94
+ ("Host", svc.host),
95
+ ("SSH user", svc.ssh_user),
96
+ ("Compose file", svc.compose_file),
97
+ ("Env file", svc.env_file),
98
+ ("Project name", svc.project_name),
99
+ ("Domain", svc.domain),
100
+ ],
101
+ )
102
+ )
103
+
104
+ out.detail(
105
+ "RustDesk Configuration",
106
+ sections,
107
+ data_for_json={
108
+ "config_file": str(CONFIG_FILE),
109
+ "default_profile": default,
110
+ "active_profile": active,
111
+ "profiles": {pname: get_rustdesk_config(pname).model_dump() for pname in get_profile_names()},
112
+ },
113
+ )
114
+
115
+
116
+ @app.command()
117
+ def profiles(ctx: typer.Context) -> None:
118
+ """List all profiles."""
119
+ c: AppContext = ctx.obj
120
+ default = get_default_profile()
121
+ rows: list[list[str]] = []
122
+ for pname in get_profile_names():
123
+ svc = get_rustdesk_config(pname)
124
+ is_default = "yes" if pname == default else ""
125
+ rows.append([pname, svc.host, svc.domain, is_default])
126
+
127
+ c.output.table(
128
+ "Profiles",
129
+ [("Name", "cyan"), ("Host", ""), ("Domain", ""), ("Default", "green")],
130
+ rows,
131
+ )
132
+
133
+
134
+ @app.command()
135
+ def use(
136
+ ctx: typer.Context,
137
+ name: Annotated[str, typer.Argument(help="Profile name to set as default")],
138
+ ) -> None:
139
+ """Set the default profile."""
140
+ c: AppContext = ctx.obj
141
+ if name not in get_profile_names():
142
+ c.output.error(f"Profile '{name}' not found")
143
+ raise typer.Exit(1)
144
+ set_default_profile(name)
145
+ c.output.success(f"Default profile set to '{name}'")
146
+
147
+
148
+ @app.command()
149
+ def test(ctx: typer.Context) -> None:
150
+ """Test connection to the RustDesk server."""
151
+ c: AppContext = ctx.obj
152
+ out = c.output
153
+ ex = c.executor
154
+
155
+ out.info(f"Testing connection to {ex.config.host}...")
156
+
157
+ try:
158
+ version = ex.get_compose_version()
159
+ out.success(f"Docker Compose: {version}")
160
+ except Exception as e:
161
+ out.error(f"Cannot reach server: {e}")
162
+ raise typer.Exit(1)
163
+
164
+ hbbs_ok = ex.container_running("hbbs")
165
+ hbbr_ok = ex.container_running("hbbr")
166
+ out.success(f"hbbs container: {'running' if hbbs_ok else 'NOT running'}")
167
+ out.success(f"hbbr container: {'running' if hbbr_ok else 'NOT running'}")
168
+
169
+ try:
170
+ count = ex.query_db_scalar("SELECT count(*) FROM peer;")
171
+ out.success(f"Database accessible: {count} peers")
172
+ except Exception as e:
173
+ out.warn(f"Database check failed: {e}")