kctl-cf 0.3.0__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_cf/__init__.py +3 -0
- kctl_cf/__main__.py +5 -0
- kctl_cf/cli.py +189 -0
- kctl_cf/commands/__init__.py +0 -0
- kctl_cf/commands/access.py +447 -0
- kctl_cf/commands/aliases.py +107 -0
- kctl_cf/commands/analytics.py +194 -0
- kctl_cf/commands/argo.py +97 -0
- kctl_cf/commands/cache.py +77 -0
- kctl_cf/commands/config_cmd.py +379 -0
- kctl_cf/commands/custom_hostnames.py +148 -0
- kctl_cf/commands/doctor_cmd.py +82 -0
- kctl_cf/commands/email_routing.py +233 -0
- kctl_cf/commands/export.py +400 -0
- kctl_cf/commands/health.py +508 -0
- kctl_cf/commands/load_balancers.py +298 -0
- kctl_cf/commands/page_rules.py +169 -0
- kctl_cf/commands/pages.py +301 -0
- kctl_cf/commands/r2.py +171 -0
- kctl_cf/commands/records.py +256 -0
- kctl_cf/commands/redirects.py +183 -0
- kctl_cf/commands/selftest.py +59 -0
- kctl_cf/commands/skill_cmd.py +76 -0
- kctl_cf/commands/spectrum.py +178 -0
- kctl_cf/commands/speed.py +198 -0
- kctl_cf/commands/ssl.py +205 -0
- kctl_cf/commands/status.py +162 -0
- kctl_cf/commands/terraform.py +107 -0
- kctl_cf/commands/tokens.py +287 -0
- kctl_cf/commands/tunnels.py +211 -0
- kctl_cf/commands/waf.py +329 -0
- kctl_cf/commands/waiting_rooms.py +178 -0
- kctl_cf/commands/workers.py +381 -0
- kctl_cf/commands/zones.py +214 -0
- kctl_cf/core/__init__.py +0 -0
- kctl_cf/core/callbacks.py +28 -0
- kctl_cf/core/client.py +50 -0
- kctl_cf/core/config.py +144 -0
- kctl_cf/core/exceptions.py +25 -0
- kctl_cf/core/notify.py +113 -0
- kctl_cf/core/output.py +5 -0
- kctl_cf/core/plugins.py +57 -0
- kctl_cf/core/utils.py +75 -0
- kctl_cf-0.3.0.dist-info/METADATA +18 -0
- kctl_cf-0.3.0.dist-info/RECORD +47 -0
- kctl_cf-0.3.0.dist-info/WHEEL +4 -0
- kctl_cf-0.3.0.dist-info/entry_points.txt +2 -0
kctl_cf/__init__.py
ADDED
kctl_cf/__main__.py
ADDED
kctl_cf/cli.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Main CLI entry point for kctl-cf."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from kctl_cf import __version__
|
|
11
|
+
from kctl_cf.commands.access import app as access_app
|
|
12
|
+
from kctl_cf.commands.analytics import app as analytics_app
|
|
13
|
+
from kctl_cf.commands.argo import app as argo_app
|
|
14
|
+
from kctl_cf.commands.cache import app as cache_app
|
|
15
|
+
from kctl_cf.commands.config_cmd import app as config_app
|
|
16
|
+
from kctl_cf.commands.custom_hostnames import app as custom_hostnames_app
|
|
17
|
+
from kctl_cf.commands.email_routing import app as email_routing_app
|
|
18
|
+
from kctl_cf.commands.export import app as export_app
|
|
19
|
+
from kctl_cf.commands.health import app as health_app
|
|
20
|
+
from kctl_cf.commands.load_balancers import app as load_balancers_app
|
|
21
|
+
from kctl_cf.commands.page_rules import app as page_rules_app
|
|
22
|
+
from kctl_cf.commands.pages import app as pages_app
|
|
23
|
+
from kctl_cf.commands.r2 import app as r2_app
|
|
24
|
+
from kctl_cf.commands.records import app as records_app
|
|
25
|
+
from kctl_cf.commands.redirects import app as redirects_app
|
|
26
|
+
from kctl_cf.commands.selftest import app as selftest_app
|
|
27
|
+
from kctl_cf.commands.spectrum import app as spectrum_app
|
|
28
|
+
from kctl_cf.commands.speed import app as speed_app
|
|
29
|
+
from kctl_cf.commands.ssl import app as ssl_app
|
|
30
|
+
from kctl_cf.commands.status import app as status_app
|
|
31
|
+
from kctl_cf.commands.terraform import app as terraform_app
|
|
32
|
+
from kctl_cf.commands.tokens import app as tokens_app
|
|
33
|
+
from kctl_cf.commands.tunnels import app as tunnels_app
|
|
34
|
+
from kctl_cf.commands.waf import app as waf_app
|
|
35
|
+
from kctl_cf.commands.waiting_rooms import app as waiting_rooms_app
|
|
36
|
+
from kctl_cf.commands.workers import app as workers_app
|
|
37
|
+
from kctl_cf.commands.zones import app as zones_app
|
|
38
|
+
from kctl_cf.core.callbacks import AppContext
|
|
39
|
+
from kctl_cf.core.exceptions import APIError, AuthenticationError, ConfigError, KctlError
|
|
40
|
+
from kctl_cf.core.exceptions import ConnectionError as KctlConnectionError
|
|
41
|
+
from kctl_cf.core.plugins import discover_and_load_plugins
|
|
42
|
+
from kctl_lib.self_update import notify_if_outdated
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def version_callback(value: bool) -> None:
|
|
46
|
+
if value:
|
|
47
|
+
typer.echo(f"kctl-cf {__version__}")
|
|
48
|
+
raise typer.Exit()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
app = typer.Typer(
|
|
52
|
+
name="kctl-cf",
|
|
53
|
+
help="Kodemeio Cloudflare CLI - manage DNS, tunnels, WAF, cache, Workers, R2, email routing, access.",
|
|
54
|
+
no_args_is_help=True,
|
|
55
|
+
rich_markup_mode="rich",
|
|
56
|
+
pretty_exceptions_enable=False,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.callback()
|
|
61
|
+
def main(
|
|
62
|
+
ctx: typer.Context,
|
|
63
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
64
|
+
quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress info messages")] = False,
|
|
65
|
+
format: Annotated[str, typer.Option("--format", "-f", help="Output format: pretty/json/csv/yaml")] = "pretty",
|
|
66
|
+
no_header: Annotated[bool, typer.Option("--no-header", help="Omit header row in CSV output")] = False,
|
|
67
|
+
profile: Annotated[str | None, typer.Option("--profile", "-p", help="Config profile name")] = None,
|
|
68
|
+
api_token: Annotated[str | None, typer.Option("--api-token", help="API token override")] = None,
|
|
69
|
+
account_id: Annotated[str | None, typer.Option("--account-id", help="Account ID override")] = None,
|
|
70
|
+
version: Annotated[
|
|
71
|
+
bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="Show version")
|
|
72
|
+
] = False,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Kodemeio Cloudflare CLI."""
|
|
75
|
+
ctx.ensure_object(dict)
|
|
76
|
+
ctx.obj = AppContext(
|
|
77
|
+
json_mode=json_output,
|
|
78
|
+
quiet=quiet,
|
|
79
|
+
format=format,
|
|
80
|
+
no_header=no_header,
|
|
81
|
+
profile=profile,
|
|
82
|
+
api_token_override=api_token,
|
|
83
|
+
account_id_override=account_id,
|
|
84
|
+
)
|
|
85
|
+
notify_if_outdated(ctx.obj.output, "kctl-cf", __version__)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
app.add_typer(config_app, name="config")
|
|
89
|
+
app.add_typer(zones_app, name="zones")
|
|
90
|
+
app.add_typer(records_app, name="records")
|
|
91
|
+
app.add_typer(tokens_app, name="tokens")
|
|
92
|
+
app.add_typer(tunnels_app, name="tunnels")
|
|
93
|
+
app.add_typer(health_app, name="health")
|
|
94
|
+
app.add_typer(waf_app, name="waf")
|
|
95
|
+
app.add_typer(cache_app, name="cache")
|
|
96
|
+
app.add_typer(ssl_app, name="ssl")
|
|
97
|
+
app.add_typer(workers_app, name="workers")
|
|
98
|
+
app.add_typer(r2_app, name="r2")
|
|
99
|
+
app.add_typer(export_app, name="export")
|
|
100
|
+
app.add_typer(terraform_app, name="terraform")
|
|
101
|
+
app.add_typer(email_routing_app, name="email-routing")
|
|
102
|
+
app.add_typer(page_rules_app, name="page-rules")
|
|
103
|
+
app.add_typer(pages_app, name="pages")
|
|
104
|
+
app.add_typer(redirects_app, name="redirects")
|
|
105
|
+
app.add_typer(access_app, name="access")
|
|
106
|
+
app.add_typer(speed_app, name="speed")
|
|
107
|
+
app.add_typer(status_app, name="status")
|
|
108
|
+
app.add_typer(analytics_app, name="analytics")
|
|
109
|
+
app.add_typer(selftest_app, name="selftest")
|
|
110
|
+
app.add_typer(argo_app, name="argo")
|
|
111
|
+
app.add_typer(custom_hostnames_app, name="custom-hostnames")
|
|
112
|
+
app.add_typer(load_balancers_app, name="load-balancers")
|
|
113
|
+
app.add_typer(spectrum_app, name="spectrum")
|
|
114
|
+
app.add_typer(waiting_rooms_app, name="waiting-rooms")
|
|
115
|
+
# Load plugins from entry points
|
|
116
|
+
discover_and_load_plugins(app)
|
|
117
|
+
|
|
118
|
+
# Register short aliases and skill generation
|
|
119
|
+
from kctl_cf.commands.aliases import register_aliases # noqa: E402
|
|
120
|
+
from kctl_cf.commands.skill_cmd import app as skill_app # noqa: E402
|
|
121
|
+
|
|
122
|
+
register_aliases(app)
|
|
123
|
+
from kctl_cf.commands.doctor_cmd import app as doctor_app # noqa: E402
|
|
124
|
+
|
|
125
|
+
app.add_typer(doctor_app, name="doctor")
|
|
126
|
+
app.add_typer(skill_app, name="skill", hidden=True)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.command("self-update")
|
|
130
|
+
def self_update_cmd(ctx: typer.Context) -> None:
|
|
131
|
+
"""Check for updates and upgrade kctl-cf."""
|
|
132
|
+
actx = ctx.obj
|
|
133
|
+
out = actx.output
|
|
134
|
+
|
|
135
|
+
from kctl_lib.self_update import check_update
|
|
136
|
+
from kctl_lib.self_update import update as do_update
|
|
137
|
+
|
|
138
|
+
latest = check_update("kctl-cf", __version__)
|
|
139
|
+
if latest:
|
|
140
|
+
out.info(f"Updating to {latest}...")
|
|
141
|
+
do_update("kctl-cf")
|
|
142
|
+
out.success(f"Updated to {latest}")
|
|
143
|
+
else:
|
|
144
|
+
out.success("Already up to date")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@app.command()
|
|
148
|
+
def completions(
|
|
149
|
+
shell: Annotated[str, typer.Argument(help="Shell type: zsh, bash, fish")] = "zsh",
|
|
150
|
+
install: Annotated[bool, typer.Option("--install", help="Install completions")] = False,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Generate or install shell completions."""
|
|
153
|
+
from kctl_lib.completions import get_completion_script, install_completions
|
|
154
|
+
|
|
155
|
+
if install:
|
|
156
|
+
path = install_completions("kctl-cf", shell)
|
|
157
|
+
if path:
|
|
158
|
+
typer.echo(f"Completions installed to {path}")
|
|
159
|
+
else:
|
|
160
|
+
typer.echo(f"Could not install completions for {shell}", err=True)
|
|
161
|
+
raise typer.Exit(code=1)
|
|
162
|
+
else:
|
|
163
|
+
script = get_completion_script("kctl-cf", shell)
|
|
164
|
+
typer.echo(script)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _run() -> None:
|
|
168
|
+
try:
|
|
169
|
+
app()
|
|
170
|
+
except KctlConnectionError as e:
|
|
171
|
+
typer.echo(f"Connection error: {e}", err=True)
|
|
172
|
+
raise typer.Exit(1) from e
|
|
173
|
+
except AuthenticationError as e:
|
|
174
|
+
typer.echo(f"Auth error: {e}", err=True)
|
|
175
|
+
raise typer.Exit(1) from e
|
|
176
|
+
except APIError as e:
|
|
177
|
+
typer.echo(f"API error: {e}", err=True)
|
|
178
|
+
raise typer.Exit(1) from e
|
|
179
|
+
except ConfigError as e:
|
|
180
|
+
typer.echo(f"Config error: {e}", err=True)
|
|
181
|
+
raise typer.Exit(1) from e
|
|
182
|
+
except KctlError as e:
|
|
183
|
+
typer.echo(f"Error: {e}", err=True)
|
|
184
|
+
raise typer.Exit(1) from e
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
_run()
|
|
189
|
+
# test
|
|
File without changes
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""Zero Trust Access management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from kctl_cf.core.callbacks import AppContext
|
|
10
|
+
from kctl_cf.core.utils import require_account
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Manage Cloudflare Zero Trust Access.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_access_rules(c: AppContext, rules: list[str] | None) -> list[dict]:
|
|
16
|
+
"""Parse access rules from CLI format (email:user@x.com, email_domain:x.com, everyone:)."""
|
|
17
|
+
if not rules:
|
|
18
|
+
return []
|
|
19
|
+
parsed = []
|
|
20
|
+
for rule in rules:
|
|
21
|
+
if ":" not in rule:
|
|
22
|
+
c.output.error(
|
|
23
|
+
f"Invalid rule format: {rule!r} (expected type:value, e.g. 'email:user@x.com' or 'everyone:')"
|
|
24
|
+
)
|
|
25
|
+
raise typer.Exit(1)
|
|
26
|
+
rule_type, rule_value = rule.split(":", 1)
|
|
27
|
+
if rule_type == "email":
|
|
28
|
+
parsed.append({"email": {"email": rule_value}})
|
|
29
|
+
elif rule_type == "email_domain":
|
|
30
|
+
parsed.append({"email_domain": {"domain": rule_value}})
|
|
31
|
+
elif rule_type == "everyone":
|
|
32
|
+
parsed.append({"everyone": {}})
|
|
33
|
+
else:
|
|
34
|
+
parsed.append({rule_type: {"value": rule_value}})
|
|
35
|
+
return parsed
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command()
|
|
39
|
+
def apps(ctx: typer.Context) -> None:
|
|
40
|
+
"""List Access applications."""
|
|
41
|
+
c: AppContext = ctx.obj
|
|
42
|
+
acct = require_account(c)
|
|
43
|
+
result = c.client.get(f"/accounts/{acct}/access/apps")
|
|
44
|
+
if not isinstance(result, list):
|
|
45
|
+
result = []
|
|
46
|
+
rows = []
|
|
47
|
+
for a in result:
|
|
48
|
+
rows.append(
|
|
49
|
+
[
|
|
50
|
+
a.get("id", "")[:12],
|
|
51
|
+
a.get("name", ""),
|
|
52
|
+
a.get("domain", ""),
|
|
53
|
+
a.get("type", ""),
|
|
54
|
+
a.get("session_duration", ""),
|
|
55
|
+
a.get("created_at", "")[:19],
|
|
56
|
+
]
|
|
57
|
+
)
|
|
58
|
+
c.output.table(
|
|
59
|
+
"Access Applications",
|
|
60
|
+
[("ID", "dim"), ("Name", "cyan"), ("Domain", "green"), ("Type", ""), ("Session", "dim"), ("Created", "dim")],
|
|
61
|
+
rows,
|
|
62
|
+
data_for_json=result,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command("get-app")
|
|
67
|
+
def get_app(
|
|
68
|
+
ctx: typer.Context,
|
|
69
|
+
app_id: Annotated[str, typer.Argument(help="Application ID")],
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Get details for an Access application."""
|
|
72
|
+
c: AppContext = ctx.obj
|
|
73
|
+
acct = require_account(c)
|
|
74
|
+
result = c.client.get(f"/accounts/{acct}/access/apps/{app_id}")
|
|
75
|
+
if not isinstance(result, dict):
|
|
76
|
+
result = {}
|
|
77
|
+
policies_count = len(result.get("policies", [])) if isinstance(result.get("policies"), list) else 0
|
|
78
|
+
sections = [
|
|
79
|
+
(
|
|
80
|
+
"Application",
|
|
81
|
+
[
|
|
82
|
+
("ID", result.get("id", "")),
|
|
83
|
+
("Name", result.get("name", "")),
|
|
84
|
+
("Domain", result.get("domain", "")),
|
|
85
|
+
("Type", result.get("type", "")),
|
|
86
|
+
("AUD", result.get("aud", "")[:40]),
|
|
87
|
+
("Session Duration", result.get("session_duration", "")),
|
|
88
|
+
("Auto-Redirect to IdP", str(result.get("auto_redirect_to_identity", False))),
|
|
89
|
+
],
|
|
90
|
+
),
|
|
91
|
+
(
|
|
92
|
+
"Settings",
|
|
93
|
+
[
|
|
94
|
+
("Policies", str(policies_count)),
|
|
95
|
+
("App Launcher Visible", str(result.get("app_launcher_visible", True))),
|
|
96
|
+
("Skip Interstitial", str(result.get("skip_interstitial", False))),
|
|
97
|
+
("Created", result.get("created_at", "")[:19]),
|
|
98
|
+
("Updated", result.get("updated_at", "")[:19]),
|
|
99
|
+
],
|
|
100
|
+
),
|
|
101
|
+
]
|
|
102
|
+
c.output.detail(f"Access App — {result.get('name', app_id)}", sections, data_for_json=result)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command()
|
|
106
|
+
def policies(
|
|
107
|
+
ctx: typer.Context,
|
|
108
|
+
app_id: Annotated[str, typer.Argument(help="Application ID")],
|
|
109
|
+
) -> None:
|
|
110
|
+
"""List policies for an Access application."""
|
|
111
|
+
c: AppContext = ctx.obj
|
|
112
|
+
acct = require_account(c)
|
|
113
|
+
result = c.client.get(f"/accounts/{acct}/access/apps/{app_id}/policies")
|
|
114
|
+
if not isinstance(result, list):
|
|
115
|
+
result = []
|
|
116
|
+
rows = []
|
|
117
|
+
for p in result:
|
|
118
|
+
include = p.get("include", [])
|
|
119
|
+
include_str = ", ".join(str(i.get("email", {}).get("email", "") or i) for i in include[:3]) if include else ""
|
|
120
|
+
rows.append(
|
|
121
|
+
[
|
|
122
|
+
p.get("id", "")[:12],
|
|
123
|
+
p.get("name", ""),
|
|
124
|
+
p.get("decision", ""),
|
|
125
|
+
str(p.get("precedence", "")),
|
|
126
|
+
include_str[:40],
|
|
127
|
+
]
|
|
128
|
+
)
|
|
129
|
+
c.output.table(
|
|
130
|
+
f"Access Policies — {app_id[:12]}",
|
|
131
|
+
[("ID", "dim"), ("Name", "cyan"), ("Decision", "green"), ("Precedence", "dim"), ("Include", "")],
|
|
132
|
+
rows,
|
|
133
|
+
data_for_json=result,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command()
|
|
138
|
+
def groups(ctx: typer.Context) -> None:
|
|
139
|
+
"""List Access groups."""
|
|
140
|
+
c: AppContext = ctx.obj
|
|
141
|
+
acct = require_account(c)
|
|
142
|
+
result = c.client.get(f"/accounts/{acct}/access/groups")
|
|
143
|
+
if not isinstance(result, list):
|
|
144
|
+
result = []
|
|
145
|
+
rows = []
|
|
146
|
+
for g in result:
|
|
147
|
+
include = g.get("include", [])
|
|
148
|
+
include_count = len(include) if isinstance(include, list) else 0
|
|
149
|
+
rows.append(
|
|
150
|
+
[
|
|
151
|
+
g.get("id", "")[:12],
|
|
152
|
+
g.get("name", ""),
|
|
153
|
+
str(include_count),
|
|
154
|
+
g.get("created_at", "")[:19],
|
|
155
|
+
g.get("updated_at", "")[:19],
|
|
156
|
+
]
|
|
157
|
+
)
|
|
158
|
+
c.output.table(
|
|
159
|
+
"Access Groups",
|
|
160
|
+
[("ID", "dim"), ("Name", "cyan"), ("Include Rules", "green"), ("Created", "dim"), ("Updated", "dim")],
|
|
161
|
+
rows,
|
|
162
|
+
data_for_json=result,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@app.command("create-group")
|
|
167
|
+
def create_group(
|
|
168
|
+
ctx: typer.Context,
|
|
169
|
+
name: Annotated[str, typer.Option("--name", help="Group name")],
|
|
170
|
+
include: Annotated[
|
|
171
|
+
list[str] | None,
|
|
172
|
+
typer.Option("--include", help="Include rules (email:user@example.com or email_domain:example.com)"),
|
|
173
|
+
] = None,
|
|
174
|
+
require: Annotated[
|
|
175
|
+
list[str] | None, typer.Option("--require", help="Require rules (same format as include)")
|
|
176
|
+
] = None,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Create an Access group."""
|
|
179
|
+
c: AppContext = ctx.obj
|
|
180
|
+
acct = require_account(c)
|
|
181
|
+
payload: dict = {
|
|
182
|
+
"name": name,
|
|
183
|
+
"include": _parse_access_rules(c, include),
|
|
184
|
+
}
|
|
185
|
+
if require:
|
|
186
|
+
payload["require"] = _parse_access_rules(c, require)
|
|
187
|
+
|
|
188
|
+
result = c.client.post(f"/accounts/{acct}/access/groups", json=payload)
|
|
189
|
+
group_id = result.get("id", "") if isinstance(result, dict) else ""
|
|
190
|
+
c.output.success(f"Access group created: {group_id}")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@app.command("delete-group")
|
|
194
|
+
def delete_group(
|
|
195
|
+
ctx: typer.Context,
|
|
196
|
+
group_id: Annotated[str, typer.Argument(help="Access group ID to delete")],
|
|
197
|
+
force: Annotated[bool, typer.Option("--force", help="Required to confirm deletion")] = False,
|
|
198
|
+
) -> None:
|
|
199
|
+
"""Delete an Access group. Requires --force to confirm."""
|
|
200
|
+
c: AppContext = ctx.obj
|
|
201
|
+
if not force:
|
|
202
|
+
c.output.error("Delete blocked: pass --force to confirm deletion")
|
|
203
|
+
raise typer.Exit(1)
|
|
204
|
+
acct = require_account(c)
|
|
205
|
+
c.client.delete(f"/accounts/{acct}/access/groups/{group_id}")
|
|
206
|
+
c.output.success(f"Access group deleted: {group_id}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@app.command("create-app")
|
|
210
|
+
def create_app(
|
|
211
|
+
ctx: typer.Context,
|
|
212
|
+
name: Annotated[str, typer.Option("--name", help="Application name")],
|
|
213
|
+
domain: Annotated[str, typer.Option("--domain", help="Application domain")],
|
|
214
|
+
app_type: Annotated[
|
|
215
|
+
str, typer.Option("--type", help="Application type (self_hosted, ssh, vnc, bookmark)")
|
|
216
|
+
] = "self_hosted",
|
|
217
|
+
session_duration: Annotated[str, typer.Option("--session-duration", help="Session duration (e.g. 24h)")] = "24h",
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Create an Access application."""
|
|
220
|
+
c: AppContext = ctx.obj
|
|
221
|
+
acct = require_account(c)
|
|
222
|
+
payload = {
|
|
223
|
+
"name": name,
|
|
224
|
+
"domain": domain,
|
|
225
|
+
"type": app_type,
|
|
226
|
+
"session_duration": session_duration,
|
|
227
|
+
}
|
|
228
|
+
result = c.client.post(f"/accounts/{acct}/access/apps", json=payload)
|
|
229
|
+
app_id = result.get("id", "") if isinstance(result, dict) else ""
|
|
230
|
+
c.output.success(f"Access application created: {app_id}")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@app.command("update-app")
|
|
234
|
+
def update_app(
|
|
235
|
+
ctx: typer.Context,
|
|
236
|
+
app_id: Annotated[str, typer.Argument(help="Application ID")],
|
|
237
|
+
name: Annotated[str | None, typer.Option("--name", help="New name")] = None,
|
|
238
|
+
domain: Annotated[str | None, typer.Option("--domain", help="New domain")] = None,
|
|
239
|
+
session_duration: Annotated[str | None, typer.Option("--session-duration", help="Session duration")] = None,
|
|
240
|
+
) -> None:
|
|
241
|
+
"""Update an Access application (merges with existing settings)."""
|
|
242
|
+
c: AppContext = ctx.obj
|
|
243
|
+
acct = require_account(c)
|
|
244
|
+
if not any([name, domain, session_duration]):
|
|
245
|
+
c.output.warn("No fields to update")
|
|
246
|
+
return
|
|
247
|
+
existing = c.client.get(f"/accounts/{acct}/access/apps/{app_id}")
|
|
248
|
+
if not isinstance(existing, dict):
|
|
249
|
+
existing = {}
|
|
250
|
+
payload: dict = {
|
|
251
|
+
"name": name or existing.get("name", ""),
|
|
252
|
+
"domain": domain or existing.get("domain", ""),
|
|
253
|
+
"type": existing.get("type", "self_hosted"),
|
|
254
|
+
"session_duration": session_duration or existing.get("session_duration", "24h"),
|
|
255
|
+
}
|
|
256
|
+
c.client.put(f"/accounts/{acct}/access/apps/{app_id}", json=payload)
|
|
257
|
+
c.output.success(f"Access application updated: {app_id}")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@app.command("delete-app")
|
|
261
|
+
def delete_app(
|
|
262
|
+
ctx: typer.Context,
|
|
263
|
+
app_id: Annotated[str, typer.Argument(help="Application ID to delete")],
|
|
264
|
+
force: Annotated[bool, typer.Option("--force", help="Required to confirm deletion")] = False,
|
|
265
|
+
) -> None:
|
|
266
|
+
"""Delete an Access application. Requires --force to confirm."""
|
|
267
|
+
c: AppContext = ctx.obj
|
|
268
|
+
if not force:
|
|
269
|
+
c.output.error("Delete blocked: pass --force to confirm deletion")
|
|
270
|
+
raise typer.Exit(1)
|
|
271
|
+
acct = require_account(c)
|
|
272
|
+
c.client.delete(f"/accounts/{acct}/access/apps/{app_id}")
|
|
273
|
+
c.output.success(f"Access application deleted: {app_id}")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@app.command("create-policy")
|
|
277
|
+
def create_policy(
|
|
278
|
+
ctx: typer.Context,
|
|
279
|
+
app_id: Annotated[str, typer.Argument(help="Application ID")],
|
|
280
|
+
name: Annotated[str, typer.Option("--name", help="Policy name")],
|
|
281
|
+
decision: Annotated[str, typer.Option("--decision", help="Decision (allow, deny, non_identity, bypass)")] = "allow",
|
|
282
|
+
include: Annotated[
|
|
283
|
+
list[str] | None,
|
|
284
|
+
typer.Option("--include", help="Include rules (email:user@x.com or email_domain:x.com)"),
|
|
285
|
+
] = None,
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Create a policy for an Access application."""
|
|
288
|
+
c: AppContext = ctx.obj
|
|
289
|
+
acct = require_account(c)
|
|
290
|
+
include_rules = _parse_access_rules(c, include)
|
|
291
|
+
payload = {"name": name, "decision": decision, "include": include_rules}
|
|
292
|
+
result = c.client.post(f"/accounts/{acct}/access/apps/{app_id}/policies", json=payload)
|
|
293
|
+
policy_id = result.get("id", "") if isinstance(result, dict) else ""
|
|
294
|
+
c.output.success(f"Access policy created: {policy_id}")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@app.command("update-policy")
|
|
298
|
+
def update_policy(
|
|
299
|
+
ctx: typer.Context,
|
|
300
|
+
app_id: Annotated[str, typer.Argument(help="Application ID")],
|
|
301
|
+
policy_id: Annotated[str, typer.Argument(help="Policy ID")],
|
|
302
|
+
name: Annotated[str | None, typer.Option("--name", help="New policy name")] = None,
|
|
303
|
+
decision: Annotated[str | None, typer.Option("--decision", help="New decision")] = None,
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Update a policy for an Access application (merges with existing)."""
|
|
306
|
+
c: AppContext = ctx.obj
|
|
307
|
+
acct = require_account(c)
|
|
308
|
+
if not any([name, decision]):
|
|
309
|
+
c.output.warn("No fields to update")
|
|
310
|
+
return
|
|
311
|
+
existing = c.client.get(f"/accounts/{acct}/access/apps/{app_id}/policies/{policy_id}")
|
|
312
|
+
if not isinstance(existing, dict):
|
|
313
|
+
existing = {}
|
|
314
|
+
payload: dict = {
|
|
315
|
+
"name": name or existing.get("name", ""),
|
|
316
|
+
"decision": decision or existing.get("decision", "allow"),
|
|
317
|
+
"include": existing.get("include", []),
|
|
318
|
+
}
|
|
319
|
+
c.client.put(f"/accounts/{acct}/access/apps/{app_id}/policies/{policy_id}", json=payload)
|
|
320
|
+
c.output.success(f"Access policy updated: {policy_id}")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@app.command("delete-policy")
|
|
324
|
+
def delete_policy(
|
|
325
|
+
ctx: typer.Context,
|
|
326
|
+
app_id: Annotated[str, typer.Argument(help="Application ID")],
|
|
327
|
+
policy_id: Annotated[str, typer.Argument(help="Policy ID to delete")],
|
|
328
|
+
force: Annotated[bool, typer.Option("--force", help="Required to confirm deletion")] = False,
|
|
329
|
+
) -> None:
|
|
330
|
+
"""Delete a policy for an Access application. Requires --force to confirm."""
|
|
331
|
+
c: AppContext = ctx.obj
|
|
332
|
+
if not force:
|
|
333
|
+
c.output.error("Delete blocked: pass --force to confirm deletion")
|
|
334
|
+
raise typer.Exit(1)
|
|
335
|
+
acct = require_account(c)
|
|
336
|
+
c.client.delete(f"/accounts/{acct}/access/apps/{app_id}/policies/{policy_id}")
|
|
337
|
+
c.output.success(f"Access policy deleted: {policy_id}")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@app.command()
|
|
341
|
+
def idps(ctx: typer.Context) -> None:
|
|
342
|
+
"""List Access identity providers."""
|
|
343
|
+
c: AppContext = ctx.obj
|
|
344
|
+
acct = require_account(c)
|
|
345
|
+
result = c.client.get(f"/accounts/{acct}/access/identity_providers")
|
|
346
|
+
if not isinstance(result, list):
|
|
347
|
+
result = []
|
|
348
|
+
rows = []
|
|
349
|
+
for idp in result:
|
|
350
|
+
rows.append(
|
|
351
|
+
[
|
|
352
|
+
idp.get("id", "")[:12],
|
|
353
|
+
idp.get("name", ""),
|
|
354
|
+
idp.get("type", ""),
|
|
355
|
+
idp.get("created_at", "")[:19],
|
|
356
|
+
]
|
|
357
|
+
)
|
|
358
|
+
c.output.table(
|
|
359
|
+
"Identity Providers",
|
|
360
|
+
[("ID", "dim"), ("Name", "cyan"), ("Type", "green"), ("Created", "dim")],
|
|
361
|
+
rows,
|
|
362
|
+
data_for_json=result,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@app.command("service-tokens")
|
|
367
|
+
def service_tokens(ctx: typer.Context) -> None:
|
|
368
|
+
"""List Access service tokens."""
|
|
369
|
+
c: AppContext = ctx.obj
|
|
370
|
+
acct = require_account(c)
|
|
371
|
+
result = c.client.get(f"/accounts/{acct}/access/service_tokens")
|
|
372
|
+
if not isinstance(result, list):
|
|
373
|
+
result = []
|
|
374
|
+
rows = []
|
|
375
|
+
for t in result:
|
|
376
|
+
rows.append(
|
|
377
|
+
[
|
|
378
|
+
t.get("id", "")[:12],
|
|
379
|
+
t.get("name", ""),
|
|
380
|
+
t.get("client_id", "")[:20],
|
|
381
|
+
t.get("expires_at", "")[:19] if t.get("expires_at") else "never",
|
|
382
|
+
t.get("created_at", "")[:19],
|
|
383
|
+
]
|
|
384
|
+
)
|
|
385
|
+
c.output.table(
|
|
386
|
+
"Service Tokens",
|
|
387
|
+
[("ID", "dim"), ("Name", "cyan"), ("Client ID", ""), ("Expires", "green"), ("Created", "dim")],
|
|
388
|
+
rows,
|
|
389
|
+
data_for_json=result,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@app.command("create-service-token")
|
|
394
|
+
def create_service_token(
|
|
395
|
+
ctx: typer.Context,
|
|
396
|
+
name: Annotated[str, typer.Option("--name", help="Service token name")],
|
|
397
|
+
duration: Annotated[str, typer.Option("--duration", help="Token duration (e.g. 8760h for 1 year)")] = "8760h",
|
|
398
|
+
) -> None:
|
|
399
|
+
"""Create an Access service token."""
|
|
400
|
+
c: AppContext = ctx.obj
|
|
401
|
+
acct = require_account(c)
|
|
402
|
+
payload = {"name": name, "duration": duration}
|
|
403
|
+
result = c.client.post(f"/accounts/{acct}/access/service_tokens", json=payload)
|
|
404
|
+
if not isinstance(result, dict):
|
|
405
|
+
result = {}
|
|
406
|
+
if c.output.json_mode:
|
|
407
|
+
c.output.raw_json(result)
|
|
408
|
+
else:
|
|
409
|
+
c.output.success(f"Service token created: {result.get('id', '')}")
|
|
410
|
+
c.output.kv("Client ID", result.get("client_id", ""))
|
|
411
|
+
c.output.warn("Client Secret (save this — shown only once):")
|
|
412
|
+
c.output.text(result.get("client_secret", ""))
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@app.command("rotate-service-token")
|
|
416
|
+
def rotate_service_token(
|
|
417
|
+
ctx: typer.Context,
|
|
418
|
+
token_id: Annotated[str, typer.Argument(help="Service token ID")],
|
|
419
|
+
) -> None:
|
|
420
|
+
"""Rotate an Access service token (generates new secret)."""
|
|
421
|
+
c: AppContext = ctx.obj
|
|
422
|
+
acct = require_account(c)
|
|
423
|
+
result = c.client.post(f"/accounts/{acct}/access/service_tokens/{token_id}/rotate")
|
|
424
|
+
if not isinstance(result, dict):
|
|
425
|
+
result = {}
|
|
426
|
+
if c.output.json_mode:
|
|
427
|
+
c.output.raw_json(result)
|
|
428
|
+
else:
|
|
429
|
+
c.output.success(f"Service token rotated: {token_id}")
|
|
430
|
+
c.output.warn("New Client Secret (save this — shown only once):")
|
|
431
|
+
c.output.text(result.get("client_secret", ""))
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@app.command("delete-service-token")
|
|
435
|
+
def delete_service_token(
|
|
436
|
+
ctx: typer.Context,
|
|
437
|
+
token_id: Annotated[str, typer.Argument(help="Service token ID to delete")],
|
|
438
|
+
force: Annotated[bool, typer.Option("--force", help="Required to confirm deletion")] = False,
|
|
439
|
+
) -> None:
|
|
440
|
+
"""Delete an Access service token. Requires --force to confirm."""
|
|
441
|
+
c: AppContext = ctx.obj
|
|
442
|
+
if not force:
|
|
443
|
+
c.output.error("Delete blocked: pass --force to confirm deletion")
|
|
444
|
+
raise typer.Exit(1)
|
|
445
|
+
acct = require_account(c)
|
|
446
|
+
c.client.delete(f"/accounts/{acct}/access/service_tokens/{token_id}")
|
|
447
|
+
c.output.success(f"Service token deleted: {token_id}")
|