splunkctl 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (44) hide show
  1. {splunkctl-0.1.0 → splunkctl-0.2.0}/PKG-INFO +77 -11
  2. splunkctl-0.2.0/README.md +157 -0
  3. {splunkctl-0.1.0 → splunkctl-0.2.0}/pyproject.toml +1 -1
  4. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/__init__.py +1 -1
  5. splunkctl-0.2.0/splunkctl/client.py +334 -0
  6. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/commands/apps.py +15 -14
  7. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/commands/dashboards.py +55 -68
  8. splunkctl-0.2.0/splunkctl/commands/doctor.py +206 -0
  9. splunkctl-0.2.0/splunkctl/commands/hec.py +151 -0
  10. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/commands/indexes.py +22 -8
  11. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/commands/lookups.py +47 -82
  12. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/commands/rules.py +5 -0
  13. splunkctl-0.2.0/splunkctl/commands/rules_io.py +222 -0
  14. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/commands/search.py +62 -1
  15. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/commands/users.py +39 -5
  16. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/main.py +4 -0
  17. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/skill/SKILL.md +54 -5
  18. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl.egg-info/PKG-INFO +77 -11
  19. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl.egg-info/SOURCES.txt +3 -0
  20. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl.egg-info/requires.txt +1 -1
  21. {splunkctl-0.1.0 → splunkctl-0.2.0}/tests/test_version.py +1 -1
  22. splunkctl-0.1.0/README.md +0 -91
  23. splunkctl-0.1.0/splunkctl/client.py +0 -103
  24. {splunkctl-0.1.0 → splunkctl-0.2.0}/LICENSE +0 -0
  25. {splunkctl-0.1.0 → splunkctl-0.2.0}/setup.cfg +0 -0
  26. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/__main__.py +0 -0
  27. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/commands/__init__.py +0 -0
  28. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/commands/alerts.py +0 -0
  29. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/commands/commands_meta.py +0 -0
  30. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/commands/config_cmd.py +0 -0
  31. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/commands/info.py +0 -0
  32. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/commands/inputs.py +0 -0
  33. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/commands/parsers.py +0 -0
  34. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/commands/skill_cmd.py +0 -0
  35. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/config.py +0 -0
  36. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/guard.py +0 -0
  37. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl/output.py +0 -0
  38. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl.egg-info/dependency_links.txt +0 -0
  39. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl.egg-info/entry_points.txt +0 -0
  40. {splunkctl-0.1.0 → splunkctl-0.2.0}/splunkctl.egg-info/top_level.txt +0 -0
  41. {splunkctl-0.1.0 → splunkctl-0.2.0}/tests/test_client.py +0 -0
  42. {splunkctl-0.1.0 → splunkctl-0.2.0}/tests/test_config.py +0 -0
  43. {splunkctl-0.1.0 → splunkctl-0.2.0}/tests/test_guard.py +0 -0
  44. {splunkctl-0.1.0 → splunkctl-0.2.0}/tests/test_output.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splunkctl
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: CLI tool for Splunk Enterprise SIEM operations.
5
5
  Author: dannyota
6
6
  License-Expression: Apache-2.0
@@ -19,7 +19,7 @@ Classifier: Topic :: System :: Systems Administration
19
19
  Requires-Python: >=3.13
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
- Requires-Dist: splunk-sdk>=2.1.0
22
+ Requires-Dist: splunk-sdk>=2.0
23
23
  Requires-Dist: click>=8.0
24
24
  Requires-Dist: pyyaml>=6.0
25
25
  Requires-Dist: tabulate>=0.9
@@ -29,47 +29,96 @@ Dynamic: license-file
29
29
 
30
30
  CLI tool for Splunk Enterprise SIEM operations.
31
31
 
32
- Query, inspect, and manage a Splunk Enterprise instance from the terminal.
33
- Built on the [splunk-sdk-python](https://github.com/dannyota/splunk-sdk-python)
32
+ Query, inspect, and manage a remote Splunk Enterprise instance from your
33
+ laptop. Built on the [splunk-sdk-python](https://github.com/dannyota/splunk-sdk-python/tree/splunkctl)
34
34
  fork with [Click](https://click.palletsprojects.com/).
35
35
 
36
36
  ## Install
37
37
 
38
38
  ```bash
39
39
  pip install splunkctl
40
+ pip install git+https://github.com/dannyota/splunk-sdk-python@splunkctl
40
41
  ```
41
42
 
42
- Requires Python 3.13+.
43
+ Requires Python 3.13+. The second line installs the
44
+ [forked SDK](https://github.com/dannyota/splunk-sdk-python/tree/splunkctl)
45
+ which adds dashboard, lookup, and HEC token support. Without it, core
46
+ commands (search, rules, alerts, indexes, inputs, apps, users) still work.
47
+
48
+ ### Development
49
+
50
+ ```bash
51
+ git clone https://github.com/dannyota/splunkctl
52
+ cd splunkctl
53
+ pip install -e .
54
+ splunkctl --version
55
+ ```
43
56
 
44
57
  ## Quick start
45
58
 
46
59
  ```bash
47
60
  splunkctl config init # interactive setup
48
- splunkctl config test # verify connectivity
61
+ splunkctl doctor # check connection, auth, permissions
49
62
  splunkctl search run 'index=main | head 10' # run a search
50
63
  splunkctl rules list # list detection rules
51
- splunkctl dashboards list # list dashboards
52
64
  ```
53
65
 
54
66
  ## Commands
55
67
 
56
68
  | Group | Description |
57
69
  |---|---|
70
+ | `doctor` | Connection, auth, health, and permissions check |
58
71
  | `config` | Setup, show config, test connectivity |
59
72
  | `info` | Server info (version, OS, license) |
60
- | `search` | Run, export, oneshot, job management |
61
- | `rules` | Detection rules (saved searches) full CRUD |
73
+ | `search` | Run, export, oneshot, upload, job management |
74
+ | `rules` | Detection rules CRUD, import/export (YAML) |
62
75
  | `alerts` | Fired alerts, alert actions, suppression |
63
76
  | `dashboards` | Dashboard CRUD (XML) |
64
77
  | `indexes` | Index management |
65
78
  | `inputs` | Data inputs (monitor, tcp, udp, script, http) |
66
- | `lookups` | Lookup table CRUD (CSV) |
79
+ | `lookups` | Lookup table CRUD (CSV, mmdb) |
80
+ | `hec` | HEC token management |
67
81
  | `parsers` | Source types and field extractions |
68
- | `apps` | App install, uninstall, update |
82
+ | `apps` | App install (.spl/.tar.gz), uninstall, update |
69
83
  | `users` | User and role management |
70
84
  | `commands` | Machine-readable command tree (JSON) |
71
85
  | `skill` | Embedded agent operating guide |
72
86
 
87
+ ## Key features
88
+
89
+ ### Detection-as-code
90
+
91
+ Export existing rules to YAML, version control them, deploy across
92
+ instances:
93
+
94
+ ```bash
95
+ splunkctl rules export --path detections.yml
96
+ splunkctl rules import --path detections.yml # dry-run preview
97
+ splunkctl --yes rules import --path detections.yml # apply
98
+ ```
99
+
100
+ ### Remote file operations
101
+
102
+ Upload files from your laptop without SSH access to the server:
103
+
104
+ ```bash
105
+ # Upload threat intel, logs, or sample data for indexing
106
+ splunkctl --yes search upload --path threats.csv --index threat_intel --sourcetype csv
107
+
108
+ # Upload lookup tables (CSV or GeoIP mmdb)
109
+ splunkctl --yes lookups upload --name threats.csv --path threats.csv
110
+
111
+ # Install apps from local .spl/.tar.gz packages
112
+ splunkctl --yes apps install --path TA_windows.spl
113
+ ```
114
+
115
+ ### Diagnostics
116
+
117
+ ```bash
118
+ splunkctl doctor # check everything: connection, auth, health, permissions
119
+ splunkctl doctor --json # machine-readable output
120
+ ```
121
+
73
122
  ## Global flags
74
123
 
75
124
  ```
@@ -102,6 +151,23 @@ splunkctl rules list --fields name,cron # project fields
102
151
  splunkctl rules list --out rules.json # write to file
103
152
  ```
104
153
 
154
+ ## SDK fork
155
+
156
+ splunkctl depends on a [fork of splunk-sdk-python](https://github.com/dannyota/splunk-sdk-python/tree/splunkctl)
157
+ that adds entity classes missing from the upstream SDK:
158
+
159
+ | Entity | Service property | Purpose |
160
+ |---|---|---|
161
+ | `Dashboard` | `service.dashboards` | Dashboard CRUD |
162
+ | `LookupTableFile` | `service.lookup_table_files` | Lookup table metadata + download |
163
+ | `HECToken` | `service.hec_tokens` | HEC token management |
164
+
165
+ Install the fork directly:
166
+
167
+ ```bash
168
+ pip install git+https://github.com/dannyota/splunk-sdk-python@splunkctl
169
+ ```
170
+
105
171
  ## Agent integration
106
172
 
107
173
  splunkctl ships with an embedded operating guide for AI agents
@@ -0,0 +1,157 @@
1
+ # splunkctl
2
+
3
+ CLI tool for Splunk Enterprise SIEM operations.
4
+
5
+ Query, inspect, and manage a remote Splunk Enterprise instance from your
6
+ laptop. Built on the [splunk-sdk-python](https://github.com/dannyota/splunk-sdk-python/tree/splunkctl)
7
+ fork with [Click](https://click.palletsprojects.com/).
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install splunkctl
13
+ pip install git+https://github.com/dannyota/splunk-sdk-python@splunkctl
14
+ ```
15
+
16
+ Requires Python 3.13+. The second line installs the
17
+ [forked SDK](https://github.com/dannyota/splunk-sdk-python/tree/splunkctl)
18
+ which adds dashboard, lookup, and HEC token support. Without it, core
19
+ commands (search, rules, alerts, indexes, inputs, apps, users) still work.
20
+
21
+ ### Development
22
+
23
+ ```bash
24
+ git clone https://github.com/dannyota/splunkctl
25
+ cd splunkctl
26
+ pip install -e .
27
+ splunkctl --version
28
+ ```
29
+
30
+ ## Quick start
31
+
32
+ ```bash
33
+ splunkctl config init # interactive setup
34
+ splunkctl doctor # check connection, auth, permissions
35
+ splunkctl search run 'index=main | head 10' # run a search
36
+ splunkctl rules list # list detection rules
37
+ ```
38
+
39
+ ## Commands
40
+
41
+ | Group | Description |
42
+ |---|---|
43
+ | `doctor` | Connection, auth, health, and permissions check |
44
+ | `config` | Setup, show config, test connectivity |
45
+ | `info` | Server info (version, OS, license) |
46
+ | `search` | Run, export, oneshot, upload, job management |
47
+ | `rules` | Detection rules — CRUD, import/export (YAML) |
48
+ | `alerts` | Fired alerts, alert actions, suppression |
49
+ | `dashboards` | Dashboard CRUD (XML) |
50
+ | `indexes` | Index management |
51
+ | `inputs` | Data inputs (monitor, tcp, udp, script, http) |
52
+ | `lookups` | Lookup table CRUD (CSV, mmdb) |
53
+ | `hec` | HEC token management |
54
+ | `parsers` | Source types and field extractions |
55
+ | `apps` | App install (.spl/.tar.gz), uninstall, update |
56
+ | `users` | User and role management |
57
+ | `commands` | Machine-readable command tree (JSON) |
58
+ | `skill` | Embedded agent operating guide |
59
+
60
+ ## Key features
61
+
62
+ ### Detection-as-code
63
+
64
+ Export existing rules to YAML, version control them, deploy across
65
+ instances:
66
+
67
+ ```bash
68
+ splunkctl rules export --path detections.yml
69
+ splunkctl rules import --path detections.yml # dry-run preview
70
+ splunkctl --yes rules import --path detections.yml # apply
71
+ ```
72
+
73
+ ### Remote file operations
74
+
75
+ Upload files from your laptop without SSH access to the server:
76
+
77
+ ```bash
78
+ # Upload threat intel, logs, or sample data for indexing
79
+ splunkctl --yes search upload --path threats.csv --index threat_intel --sourcetype csv
80
+
81
+ # Upload lookup tables (CSV or GeoIP mmdb)
82
+ splunkctl --yes lookups upload --name threats.csv --path threats.csv
83
+
84
+ # Install apps from local .spl/.tar.gz packages
85
+ splunkctl --yes apps install --path TA_windows.spl
86
+ ```
87
+
88
+ ### Diagnostics
89
+
90
+ ```bash
91
+ splunkctl doctor # check everything: connection, auth, health, permissions
92
+ splunkctl doctor --json # machine-readable output
93
+ ```
94
+
95
+ ## Global flags
96
+
97
+ ```
98
+ --json Force JSON output
99
+ --format FMT Output format: table, json, csv, jsonl
100
+ --fields f1,f2 Project specific fields
101
+ --out FILE Write output to file
102
+ --yes / -y Apply mutations (skip dry-run preview)
103
+ --timeout N Request timeout in seconds (default 30)
104
+ --config FILE Config file path
105
+ --debug HTTP request/response logging
106
+ ```
107
+
108
+ ## Dry-run by default
109
+
110
+ All write operations preview what would change. Pass `--yes` to apply.
111
+
112
+ ```bash
113
+ splunkctl rules delete 'My Rule' # shows preview only
114
+ splunkctl rules delete 'My Rule' --yes # actually deletes
115
+ ```
116
+
117
+ ## Output formats
118
+
119
+ ```bash
120
+ splunkctl rules list # table (TTY) or JSON (pipe)
121
+ splunkctl rules list --json # force JSON
122
+ splunkctl rules list --format csv # CSV
123
+ splunkctl rules list --fields name,cron # project fields
124
+ splunkctl rules list --out rules.json # write to file
125
+ ```
126
+
127
+ ## SDK fork
128
+
129
+ splunkctl depends on a [fork of splunk-sdk-python](https://github.com/dannyota/splunk-sdk-python/tree/splunkctl)
130
+ that adds entity classes missing from the upstream SDK:
131
+
132
+ | Entity | Service property | Purpose |
133
+ |---|---|---|
134
+ | `Dashboard` | `service.dashboards` | Dashboard CRUD |
135
+ | `LookupTableFile` | `service.lookup_table_files` | Lookup table metadata + download |
136
+ | `HECToken` | `service.hec_tokens` | HEC token management |
137
+
138
+ Install the fork directly:
139
+
140
+ ```bash
141
+ pip install git+https://github.com/dannyota/splunk-sdk-python@splunkctl
142
+ ```
143
+
144
+ ## Agent integration
145
+
146
+ splunkctl ships with an embedded operating guide for AI agents
147
+ (Claude Code, etc.):
148
+
149
+ ```bash
150
+ splunkctl skill # print the guide
151
+ splunkctl skill install # install to ~/.claude/skills/
152
+ splunkctl commands # JSON command tree for discovery
153
+ ```
154
+
155
+ ## License
156
+
157
+ Apache-2.0
@@ -23,7 +23,7 @@ classifiers = [
23
23
  "Topic :: System :: Systems Administration",
24
24
  ]
25
25
  dependencies = [
26
- "splunk-sdk>=2.1.0",
26
+ "splunk-sdk>=2.0",
27
27
  "click>=8.0",
28
28
  "pyyaml>=6.0",
29
29
  "tabulate>=0.9",
@@ -1,3 +1,3 @@
1
1
  """CLI tool for Splunk Enterprise SIEM operations."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -0,0 +1,334 @@
1
+ """SDK wrapper — lazy connection and auth resolution.
2
+
3
+ Includes Web UI upload support for operations the REST API cannot
4
+ handle remotely (e.g. lookup file upload requires server-side staging).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import http.cookiejar
10
+ import json
11
+ import re
12
+ import ssl
13
+ import urllib.parse
14
+ import urllib.request
15
+ import uuid
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import click
20
+ import splunklib.client as splunk_client
21
+
22
+ from splunkctl import config as cfg_mod
23
+
24
+
25
+ class SplunkClient:
26
+ """Lazy-initializing Splunk SDK client.
27
+
28
+ Connection is established on first access to ``.service``.
29
+ Help, config, and offline commands never trigger auth.
30
+ """
31
+
32
+ def __init__( # noqa: D107
33
+ self,
34
+ *,
35
+ config_path: Path | None = None,
36
+ host: str | None = None,
37
+ port: int | None = None,
38
+ username: str | None = None,
39
+ password: str | None = None,
40
+ token: str | None = None,
41
+ scheme: str | None = None,
42
+ app: str | None = None,
43
+ owner: str | None = None,
44
+ verify: bool | None = None,
45
+ timeout: int = 30,
46
+ debug: bool = False,
47
+ ) -> None:
48
+ self._config_path = config_path
49
+ self._overrides: dict[str, Any] = {
50
+ k: v
51
+ for k, v in {
52
+ "host": host,
53
+ "port": port,
54
+ "username": username,
55
+ "password": password,
56
+ "token": token,
57
+ "scheme": scheme,
58
+ "app": app,
59
+ "owner": owner,
60
+ "verify": verify,
61
+ }.items()
62
+ if v is not None
63
+ }
64
+ self._timeout = timeout
65
+ self._debug = debug
66
+ self._service: Any = None
67
+ self._web_session: _WebSession | None = None
68
+
69
+ @property
70
+ def service(self) -> Any:
71
+ """Connect on first access."""
72
+ if self._service is None:
73
+ if self._debug:
74
+ import logging
75
+
76
+ logging.basicConfig(level=logging.DEBUG)
77
+ logging.getLogger("splunklib").setLevel(logging.DEBUG)
78
+
79
+ cfg = cfg_mod.load(self._config_path)
80
+ cfg.update(self._overrides)
81
+
82
+ connect_args: dict[str, Any] = {
83
+ "host": cfg.get("host", "localhost"),
84
+ "port": int(cfg.get("port", 8089)),
85
+ "scheme": cfg.get("scheme", "https"),
86
+ }
87
+
88
+ if cfg.get("token"):
89
+ connect_args["splunkToken"] = cfg["token"]
90
+ else:
91
+ connect_args["username"] = cfg.get("username", "")
92
+ connect_args["password"] = cfg.get("password", "")
93
+
94
+ if cfg.get("app"):
95
+ connect_args["app"] = cfg["app"]
96
+ if cfg.get("owner"):
97
+ connect_args["owner"] = cfg["owner"]
98
+
99
+ if not cfg.get("verify", False):
100
+ connect_args["verify"] = False
101
+
102
+ if self._timeout:
103
+ connect_args["timeout"] = self._timeout
104
+
105
+ self._service = splunk_client.connect(**connect_args)
106
+
107
+ return self._service
108
+
109
+ def _ensure_web_session(self) -> _WebSession:
110
+ if self._web_session is None:
111
+ cfg = cfg_mod.load(self._config_path)
112
+ cfg.update(self._overrides)
113
+ self._web_session = _WebSession(
114
+ self.service, verify=bool(cfg.get("verify", False))
115
+ )
116
+ return self._web_session
117
+
118
+ def upload_lookup(
119
+ self,
120
+ name: str,
121
+ file_path: Path,
122
+ *,
123
+ app: str = "search",
124
+ update: bool = False,
125
+ ) -> None:
126
+ """Upload a lookup CSV via the Splunk Web UI form handler."""
127
+ self._ensure_web_session().upload_lookup(
128
+ name, file_path, app=app, update=update
129
+ )
130
+
131
+ def install_app(
132
+ self,
133
+ file_path: Path,
134
+ *,
135
+ force: bool = False,
136
+ ) -> None:
137
+ """Install a .spl/.tar.gz app package via the Splunk Web UI."""
138
+ self._ensure_web_session().install_app(file_path, force=force)
139
+
140
+
141
+ class _WebSession:
142
+ """Manages authentication and uploads via Splunk Web UI."""
143
+
144
+ def __init__(self, service: Any, *, verify: bool = True) -> None:
145
+ self._host: str = service.host
146
+ self._username: str = service.username
147
+ self._password: str = service.password
148
+ if not self._username or not self._password:
149
+ raise RuntimeError(
150
+ "Lookup upload requires username/password authentication. "
151
+ "Token-only auth cannot authenticate to Splunk Web UI."
152
+ )
153
+
154
+ web_conf = service.confs["web"]["settings"]
155
+ self._web_port = int(web_conf["httpport"])
156
+ self._web_ssl = str(web_conf.content.get("enableSplunkWebSSL", "0")) == "1"
157
+
158
+ self._cookies = http.cookiejar.CookieJar()
159
+ ctx = ssl.create_default_context()
160
+ if not verify:
161
+ ctx.check_hostname = False
162
+ ctx.verify_mode = ssl.CERT_NONE # noqa: S501
163
+ self._opener = urllib.request.build_opener(
164
+ urllib.request.HTTPCookieProcessor(self._cookies),
165
+ urllib.request.HTTPSHandler(context=ctx),
166
+ )
167
+ self._csrf_token: str | None = None
168
+ self._logged_in = False
169
+
170
+ @property
171
+ def _base_url(self) -> str:
172
+ scheme = "https" if self._web_ssl else "http"
173
+ return f"{scheme}://{self._host}:{self._web_port}"
174
+
175
+ def _login(self) -> None:
176
+ """Authenticate to Splunk Web and obtain session cookies."""
177
+ login_url = f"{self._base_url}/en-US/account/login"
178
+ resp = self._opener.open(login_url) # noqa: S310
179
+ page = resp.read().decode("utf-8")
180
+
181
+ m = re.search(r'"cval"\s*:\s*(\d+)', page)
182
+ cval = m.group(1) if m else "0"
183
+
184
+ data = urllib.parse.urlencode(
185
+ {
186
+ "username": self._username,
187
+ "password": self._password,
188
+ "cval": cval,
189
+ }
190
+ ).encode()
191
+ resp = self._opener.open(login_url, data) # noqa: S310
192
+ body = json.loads(resp.read().decode("utf-8"))
193
+ if body.get("status") == "fail":
194
+ msg = body.get("msg", "unknown error")
195
+ raise RuntimeError(f"Splunk Web login failed: {msg}")
196
+
197
+ for cookie in self._cookies:
198
+ if cookie.name.startswith("splunkweb_csrf_token"):
199
+ self._csrf_token = cookie.value
200
+ break
201
+ if not self._csrf_token:
202
+ raise RuntimeError("Could not obtain CSRF token from Splunk Web")
203
+ self._logged_in = True
204
+
205
+ def _multipart_post(
206
+ self,
207
+ url: str,
208
+ fields: list[tuple[str, str]],
209
+ file_field: str,
210
+ file_name: str,
211
+ file_data: bytes,
212
+ content_type: str = "application/octet-stream",
213
+ ) -> bytes:
214
+ """Build and send a multipart/form-data POST."""
215
+ if not self._logged_in:
216
+ self._login()
217
+
218
+ boundary = uuid.uuid4().hex
219
+ body = b""
220
+ for field_name, value in fields:
221
+ body += f"--{boundary}\r\n".encode()
222
+ body += (
223
+ f'Content-Disposition: form-data; name="{field_name}"\r\n'
224
+ f"\r\n"
225
+ f"{value}\r\n"
226
+ ).encode()
227
+
228
+ body += f"--{boundary}\r\n".encode()
229
+ body += (
230
+ f'Content-Disposition: form-data; name="{file_field}";'
231
+ f' filename="{file_name}"\r\n'
232
+ f"Content-Type: {content_type}\r\n"
233
+ f"\r\n"
234
+ ).encode()
235
+ body += file_data
236
+ body += f"\r\n--{boundary}--\r\n".encode()
237
+
238
+ req = urllib.request.Request( # noqa: S310
239
+ url,
240
+ data=body,
241
+ headers={
242
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
243
+ },
244
+ method="POST",
245
+ )
246
+ resp = self._opener.open(req) # noqa: S310
247
+ return bytes(resp.read())
248
+
249
+ def upload_lookup(
250
+ self,
251
+ name: str,
252
+ file_path: Path,
253
+ *,
254
+ app: str = "search",
255
+ update: bool = False,
256
+ ) -> None:
257
+ """Upload a lookup file via multipart form POST."""
258
+ if update:
259
+ url = (
260
+ f"{self._base_url}/en-US/manager/{app}"
261
+ f"/data/lookup-table-files/{urllib.parse.quote(name)}"
262
+ )
263
+ action = "edit"
264
+ else:
265
+ url = f"{self._base_url}/en-US/manager/{app}/data/lookup-table-files/_new"
266
+ action = "new"
267
+
268
+ fields: list[tuple[str, str]] = [
269
+ ("__action", action),
270
+ ("__redirect", ""),
271
+ ("__ns", app),
272
+ ("splunk_form_key", self._csrf_token or ""),
273
+ ]
274
+ if not update:
275
+ fields.append(("name", name))
276
+
277
+ resp_body = self._multipart_post(
278
+ url,
279
+ fields,
280
+ file_field="spl-ctrl_lookupfile",
281
+ file_name=name,
282
+ file_data=file_path.read_bytes(),
283
+ content_type="text/csv",
284
+ )
285
+ result = json.loads(resp_body.decode("utf-8"))
286
+ if result.get("status") != "OK":
287
+ msg = result.get("msg", "unknown error")
288
+ raise RuntimeError(f"Lookup upload failed: {msg}")
289
+
290
+ def install_app(
291
+ self,
292
+ file_path: Path,
293
+ *,
294
+ force: bool = False,
295
+ ) -> None:
296
+ """Install a .spl/.tar.gz app via the Web UI upload handler."""
297
+ if not self._logged_in:
298
+ self._login()
299
+
300
+ upload_url = f"{self._base_url}/en-US/manager/appinstall/_upload"
301
+ resp = self._opener.open(upload_url) # noqa: S310
302
+ page = resp.read().decode("utf-8")
303
+
304
+ m = re.search(
305
+ r'name="state"\s+[^>]*value="([^"]*)"',
306
+ page,
307
+ )
308
+ state = m.group(1) if m else ""
309
+
310
+ fields: list[tuple[str, str]] = [
311
+ ("state", state),
312
+ ("splunk_form_key", self._csrf_token or ""),
313
+ ]
314
+ if force:
315
+ fields.append(("force", "1"))
316
+
317
+ self._multipart_post(
318
+ upload_url,
319
+ fields,
320
+ file_field="appfile",
321
+ file_name=file_path.name,
322
+ file_data=file_path.read_bytes(),
323
+ content_type="application/gzip",
324
+ )
325
+
326
+
327
+ def get_client(ctx: click.Context) -> SplunkClient:
328
+ """Build a SplunkClient from Click context. Does not connect."""
329
+ obj: dict[str, Any] = ctx.ensure_object(dict)
330
+ return SplunkClient(
331
+ config_path=Path(obj["config"]) if obj.get("config") else None,
332
+ timeout=obj.get("timeout", 30),
333
+ debug=obj.get("debug", False),
334
+ )