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.
- kctl_rustdesk/__init__.py +3 -0
- kctl_rustdesk/__main__.py +3 -0
- kctl_rustdesk/cli.py +133 -0
- kctl_rustdesk/commands/__init__.py +0 -0
- kctl_rustdesk/commands/audit.py +143 -0
- kctl_rustdesk/commands/backup.py +151 -0
- kctl_rustdesk/commands/config_cmd.py +173 -0
- kctl_rustdesk/commands/dashboard.py +98 -0
- kctl_rustdesk/commands/doctor_cmd.py +57 -0
- kctl_rustdesk/commands/health.py +125 -0
- kctl_rustdesk/commands/maintenance.py +170 -0
- kctl_rustdesk/commands/peers.py +112 -0
- kctl_rustdesk/commands/setup.py +114 -0
- kctl_rustdesk/commands/skill_cmd.py +76 -0
- kctl_rustdesk/commands/users.py +115 -0
- kctl_rustdesk/core/__init__.py +0 -0
- kctl_rustdesk/core/callbacks.py +29 -0
- kctl_rustdesk/core/config.py +69 -0
- kctl_rustdesk/core/executor.py +162 -0
- kctl_rustdesk/core/plugins.py +39 -0
- kctl_rustdesk-0.6.3.dist-info/METADATA +16 -0
- kctl_rustdesk-0.6.3.dist-info/RECORD +24 -0
- kctl_rustdesk-0.6.3.dist-info/WHEEL +4 -0
- kctl_rustdesk-0.6.3.dist-info/entry_points.txt +2 -0
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}")
|