login-auth-tui 0.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.
- login_auth_tui/__init__.py +0 -0
- login_auth_tui/__main__.py +4 -0
- login_auth_tui/aws_mfa.py +61 -0
- login_auth_tui/cli.py +254 -0
- login_auth_tui/dbx_token_rotate.py +89 -0
- login_auth_tui/kion_auth_password.py +26 -0
- login_auth_tui/kion_cli_password.py +27 -0
- login_auth_tui/noop.py +17 -0
- login_auth_tui/tui.py +259 -0
- login_auth_tui/tui.tcss +11 -0
- login_auth_tui/tui_bootstrap.py +34 -0
- login_auth_tui/util.py +49 -0
- login_auth_tui-0.3.dist-info/METADATA +64 -0
- login_auth_tui-0.3.dist-info/RECORD +16 -0
- login_auth_tui-0.3.dist-info/WHEEL +4 -0
- login_auth_tui-0.3.dist-info/entry_points.txt +3 -0
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import configparser
|
|
2
|
+
import datetime
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from .util import log_command, log_command_output
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("aws")
|
|
9
|
+
|
|
10
|
+
HOMEDIR = str(os.getenv("HOME"))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def credential_is_valid(profile: str) -> bool:
|
|
14
|
+
now = datetime.datetime.now(datetime.UTC)
|
|
15
|
+
|
|
16
|
+
p = configparser.ConfigParser()
|
|
17
|
+
p.read(os.path.join(HOMEDIR, ".aws", "credentials"))
|
|
18
|
+
|
|
19
|
+
then_s = p.get(profile, "expiration")
|
|
20
|
+
then = datetime.datetime.strptime(f"{then_s} +0000", "%Y-%m-%d %H:%M:%S %z")
|
|
21
|
+
|
|
22
|
+
if now < then:
|
|
23
|
+
return True
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def aws_mfa(profile: str, force: bool) -> None:
|
|
28
|
+
logger.info(f"Running mfa process for {profile}")
|
|
29
|
+
|
|
30
|
+
no_mfa_file = os.path.join(HOMEDIR, f".no-mfa-{profile}")
|
|
31
|
+
if os.path.exists(no_mfa_file) and not force:
|
|
32
|
+
logger.info("Not doing mfa due to temp file existing. (Removing the file.)")
|
|
33
|
+
os.unlink(no_mfa_file)
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
if credential_is_valid(profile) and not force:
|
|
37
|
+
logger.info("Not doing mfa due to credentials still valid.")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
code = log_command_output(
|
|
41
|
+
logger, ["op", "read", "op://Private/AWS/one-time password?attribute=totp"]
|
|
42
|
+
)
|
|
43
|
+
logger.info(f"Using {code} for {profile}")
|
|
44
|
+
rval = log_command(
|
|
45
|
+
logger,
|
|
46
|
+
[f"{os.getenv('HOMEBREW_PREFIX')}/bin/aws", "mfa", profile],
|
|
47
|
+
input=code.encode("utf-8"),
|
|
48
|
+
).returncode
|
|
49
|
+
|
|
50
|
+
if rval != 0:
|
|
51
|
+
# For some reason, recently, aws-mfa has been returning 1 even though
|
|
52
|
+
# it successfully updates the tokens. So if it does return non-zero,
|
|
53
|
+
# we need to check the credentials file itself to verify if it's a real
|
|
54
|
+
# failure or not.
|
|
55
|
+
logger.info("Warning: MFA seems to have failed by rval, checking expiration")
|
|
56
|
+
if not credential_is_valid(profile):
|
|
57
|
+
logger.error(f"Not rotating long-term keys due to MFA failure ({rval})")
|
|
58
|
+
raise Exception("AWS MFA failure")
|
|
59
|
+
|
|
60
|
+
logger.info("Rotating long-term keys")
|
|
61
|
+
log_command(logger, ["aws", "rotate-iam-keys", f"{profile}-long-term"], check=True)
|
login_auth_tui/cli.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import sqlite3
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from .aws_mfa import aws_mfa
|
|
11
|
+
from .dbx_token_rotate import manage_dbx_tokens
|
|
12
|
+
from .kion_auth_password import update_kion_auth_password
|
|
13
|
+
from .kion_cli_password import DEFAULT_CONFIG_FILE, update_kion_cli_password
|
|
14
|
+
from .tui import run_tui
|
|
15
|
+
from .tui_bootstrap import bootstrap_tui_config
|
|
16
|
+
from .util import configure_logging, log_command
|
|
17
|
+
|
|
18
|
+
TWELVE_HOURS = 12 * 60 * 60
|
|
19
|
+
DEFAULT_LOG = os.path.join(str(os.getenv("HOME")), ".logs", "login-auth.log")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CliArgs:
|
|
23
|
+
__slots__ = ["force", "log", "level"]
|
|
24
|
+
|
|
25
|
+
def __init__(self):
|
|
26
|
+
self.force = False
|
|
27
|
+
self.log = DEFAULT_LOG
|
|
28
|
+
self.level = "INFO"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@click.group()
|
|
32
|
+
@click.version_option()
|
|
33
|
+
@click.option(
|
|
34
|
+
"--force",
|
|
35
|
+
is_flag=True,
|
|
36
|
+
default=False,
|
|
37
|
+
help="Force update regardless of last update time (only aws, batch, and dbx).",
|
|
38
|
+
)
|
|
39
|
+
@click.option(
|
|
40
|
+
"--log",
|
|
41
|
+
type=click.Path(dir_okay=False, writable=True, resolve_path=True, allow_dash=True),
|
|
42
|
+
default=DEFAULT_LOG,
|
|
43
|
+
help="File to log to ('-' for stdout).",
|
|
44
|
+
)
|
|
45
|
+
@click.option(
|
|
46
|
+
"--level",
|
|
47
|
+
type=click.Choice([k for k in logging.getLevelNamesMapping().keys() if k != "NOTSET"]),
|
|
48
|
+
default="INFO",
|
|
49
|
+
help="Logging level to use.",
|
|
50
|
+
)
|
|
51
|
+
@click.pass_context
|
|
52
|
+
def cli(ctx: click.Context, force: bool, log: str, level: str):
|
|
53
|
+
"""Run authentication useful when developing on DataConnect."""
|
|
54
|
+
ctx.ensure_object(CliArgs)
|
|
55
|
+
ctx.obj.force = force
|
|
56
|
+
ctx.obj.log = log
|
|
57
|
+
ctx.obj.level = level
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def login_auth(force: bool, conn: sqlite3.Connection) -> None:
|
|
61
|
+
# Now that we hold an exclusive lock on the config, see if we actually need
|
|
62
|
+
# to do anything.
|
|
63
|
+
last_update = conn.execute("SELECT * FROM last_update").fetchone()[0]
|
|
64
|
+
now = time.time()
|
|
65
|
+
if last_update < (now - TWELVE_HOURS):
|
|
66
|
+
logging.info("More than twelve hours since last update.")
|
|
67
|
+
elif force:
|
|
68
|
+
logging.info("Running due to force")
|
|
69
|
+
else:
|
|
70
|
+
logging.info("Not running; auth does not need to be updated")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
res = conn.execute("SELECT * FROM config")
|
|
74
|
+
config = dict(zip([r[0] for r in res.description], res.fetchone()))
|
|
75
|
+
|
|
76
|
+
if config["wait"]:
|
|
77
|
+
logging.info(f"Waiting {config['wait']} seconds for boot...")
|
|
78
|
+
time.sleep(config["wait"])
|
|
79
|
+
|
|
80
|
+
if config["ssh_add"]:
|
|
81
|
+
logging.info("Adding SSH keys...")
|
|
82
|
+
env = copy.deepcopy(os.environ)
|
|
83
|
+
args = ["/usr/bin/ssh-add"]
|
|
84
|
+
if sys.platform == "darwin":
|
|
85
|
+
env["APPLE_SSH_ADD_BEHAVIOR"] = "yes"
|
|
86
|
+
args.append("-A")
|
|
87
|
+
log_command(logging.getLogger(), args, env=env)
|
|
88
|
+
|
|
89
|
+
success = True
|
|
90
|
+
res = conn.execute("SELECT * FROM aws_profiles")
|
|
91
|
+
aws_profiles = [r[0] for r in res.fetchall()]
|
|
92
|
+
for aws_profile in aws_profiles:
|
|
93
|
+
logging.info(f"Authenticating AWS profile {aws_profile}")
|
|
94
|
+
try:
|
|
95
|
+
aws_mfa(aws_profile, force)
|
|
96
|
+
except Exception:
|
|
97
|
+
logging.exception(f"Exception running AWS MFA {aws_profile}")
|
|
98
|
+
success = False
|
|
99
|
+
|
|
100
|
+
res = conn.execute("SELECT * FROM dbx_profiles")
|
|
101
|
+
for row in res.fetchall():
|
|
102
|
+
profile, alias = row
|
|
103
|
+
logging.info(f"Rotating DBX token {profile} (alias={alias})")
|
|
104
|
+
try:
|
|
105
|
+
manage_dbx_tokens(profile, [alias], force)
|
|
106
|
+
except Exception:
|
|
107
|
+
logging.exception(f"Exception rotating DBX token {profile}")
|
|
108
|
+
success = False
|
|
109
|
+
|
|
110
|
+
kion_auth_config_file = config["kion_auth_config_file"]
|
|
111
|
+
if kion_auth_config_file:
|
|
112
|
+
logging.info("Update kion-auth config file with current password")
|
|
113
|
+
update_kion_auth_password(kion_auth_config_file)
|
|
114
|
+
|
|
115
|
+
kion_cli_config_file = config["kion_cli_config_file"]
|
|
116
|
+
if kion_cli_config_file:
|
|
117
|
+
logging.info("Update kion cli config file with current password")
|
|
118
|
+
update_kion_cli_password(kion_cli_config_file)
|
|
119
|
+
|
|
120
|
+
if success:
|
|
121
|
+
logging.info("Success!")
|
|
122
|
+
conn.execute(f"UPDATE last_update SET tstamp = {int(now)}") # nosec
|
|
123
|
+
else:
|
|
124
|
+
logging.info("Failed")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@cli.command("batch")
|
|
128
|
+
@click.option("--background", is_flag=True, default=False, help="Run in the background.")
|
|
129
|
+
@click.option(
|
|
130
|
+
"--config",
|
|
131
|
+
type=click.Path(dir_okay=False, exists=True, resolve_path=True),
|
|
132
|
+
default=os.path.join(str(os.getenv("HOME")), ".config", "login-auth.sqlite"),
|
|
133
|
+
help="Path to configuration database.",
|
|
134
|
+
)
|
|
135
|
+
@click.pass_context
|
|
136
|
+
def login_auth_main(ctx: click.Context, background: bool, config: str) -> None:
|
|
137
|
+
"""Run all the login management in one batch."""
|
|
138
|
+
if background:
|
|
139
|
+
# TODO - run in the background
|
|
140
|
+
pid = os.fork()
|
|
141
|
+
if pid != 0:
|
|
142
|
+
# This is the parent, we done
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
# This is the child, detach from the parent
|
|
146
|
+
os.setpgrp()
|
|
147
|
+
|
|
148
|
+
configure_logging(ctx.obj.log, ctx.obj.level)
|
|
149
|
+
|
|
150
|
+
conn = sqlite3.connect(config)
|
|
151
|
+
conn.execute("BEGIN EXCLUSIVE TRANSACTION")
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
login_auth(ctx.obj.force, conn)
|
|
155
|
+
except Exception:
|
|
156
|
+
logging.exception("Exception doing login_auth")
|
|
157
|
+
conn.execute("ROLLBACK")
|
|
158
|
+
raise
|
|
159
|
+
else:
|
|
160
|
+
logging.info("Commit transaction")
|
|
161
|
+
conn.execute("COMMIT")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@cli.command("aws")
|
|
165
|
+
@click.argument("profile")
|
|
166
|
+
@click.pass_context
|
|
167
|
+
def aws_mfa_main(ctx: click.Context, profile: str) -> None:
|
|
168
|
+
"""Run AWS MFA process.
|
|
169
|
+
|
|
170
|
+
PROFILE the AWS profile to run the MFA process for.
|
|
171
|
+
"""
|
|
172
|
+
configure_logging(ctx.obj.log, ctx.obj.level)
|
|
173
|
+
|
|
174
|
+
aws_mfa(profile, ctx.obj.force)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@cli.command("dbx")
|
|
178
|
+
@click.option("--alias", multiple=True, help="Other profiles that are aliases.")
|
|
179
|
+
@click.argument("profile")
|
|
180
|
+
@click.pass_context
|
|
181
|
+
def dbx_token_rotate_main(ctx: click.Context, alias: tuple[str], profile: str) -> None:
|
|
182
|
+
"""Rotate Databricks API token.
|
|
183
|
+
|
|
184
|
+
PROFILE the Databricks configuration profile to rotate the API token for.
|
|
185
|
+
"""
|
|
186
|
+
configure_logging(ctx.obj.log, ctx.obj.level)
|
|
187
|
+
|
|
188
|
+
manage_dbx_tokens(profile, alias, ctx.obj.force)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@cli.command("kion-auth")
|
|
192
|
+
@click.option(
|
|
193
|
+
"--file",
|
|
194
|
+
type=click.Path(exists=True, dir_okay=False, writable=True, resolve_path=True),
|
|
195
|
+
default=os.path.join(str(os.getenv("HOME")), ".config", "kion-auth.ini"),
|
|
196
|
+
help="Path of kion-auth configuration file.",
|
|
197
|
+
)
|
|
198
|
+
@click.pass_context
|
|
199
|
+
def update_kion_auth_password_main(ctx: click.Context, file: str) -> None:
|
|
200
|
+
"""Update the password used by kion-auth from 1Password."""
|
|
201
|
+
configure_logging(ctx.obj.log, ctx.obj.level)
|
|
202
|
+
|
|
203
|
+
update_kion_auth_password(file)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@cli.command("kion-cli")
|
|
207
|
+
@click.option(
|
|
208
|
+
"--file",
|
|
209
|
+
type=click.Path(exists=True, dir_okay=False, writable=True, resolve_path=True),
|
|
210
|
+
default=DEFAULT_CONFIG_FILE,
|
|
211
|
+
help="Path of kion cli configuration file.",
|
|
212
|
+
)
|
|
213
|
+
@click.pass_context
|
|
214
|
+
def update_kion_cli_password_main(ctx: click.Context, file: str) -> None:
|
|
215
|
+
"""Update the password used by the kion cli from 1Password."""
|
|
216
|
+
configure_logging(ctx.obj.log, ctx.obj.level)
|
|
217
|
+
|
|
218
|
+
update_kion_cli_password(file)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@cli.command("tui")
|
|
222
|
+
@click.option(
|
|
223
|
+
"--config",
|
|
224
|
+
type=click.Path(exists=True, dir_okay=False, writable=True, resolve_path=True),
|
|
225
|
+
default=os.path.join(str(os.getenv("HOME")), ".config", "login_auth.yml"),
|
|
226
|
+
help="Path of TUI configuration file.",
|
|
227
|
+
)
|
|
228
|
+
@click.pass_context
|
|
229
|
+
def tui(ctx: click.Context, config: str) -> None:
|
|
230
|
+
"""Run the TUI for authentication processes."""
|
|
231
|
+
configure_logging(ctx.obj.log, ctx.obj.level)
|
|
232
|
+
run_tui(config)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@cli.command("tui-bootstrap")
|
|
236
|
+
@click.option(
|
|
237
|
+
"--in-config",
|
|
238
|
+
type=click.Path(dir_okay=False, exists=True, resolve_path=True),
|
|
239
|
+
default=os.path.join(str(os.getenv("HOME")), ".config", "login-auth.sqlite"),
|
|
240
|
+
help="Path to configuration database.",
|
|
241
|
+
)
|
|
242
|
+
@click.option(
|
|
243
|
+
"--out-config",
|
|
244
|
+
type=click.Path(dir_okay=False, writable=True, resolve_path=True),
|
|
245
|
+
default=os.path.join(str(os.getenv("HOME")), ".config", "login_auth.yml"),
|
|
246
|
+
help="Path of TUI configuration file.",
|
|
247
|
+
)
|
|
248
|
+
@click.pass_context
|
|
249
|
+
def tui_bootstrap(ctx: click.Context, in_config: str, out_config: str) -> None:
|
|
250
|
+
"""Bootstrap the tui configuration from the batch configuration."""
|
|
251
|
+
configure_logging(ctx.obj.log, ctx.obj.level)
|
|
252
|
+
if os.path.exists(out_config) and not ctx.obj.force:
|
|
253
|
+
raise ValueError(f"Not overwriting existing TUI config {out_config}")
|
|
254
|
+
bootstrap_tui_config(in_config, out_config)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import configparser
|
|
2
|
+
import datetime
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import time
|
|
8
|
+
import typing
|
|
9
|
+
|
|
10
|
+
from .util import log_command_output
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("dbx")
|
|
13
|
+
|
|
14
|
+
HOMEDIR = os.getenv("HOME")
|
|
15
|
+
TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000
|
|
16
|
+
NINETY_DAYS_S = int((TEN_DAYS_MS / 1000) * 9)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_dbx(profile: str, *args) -> str:
|
|
20
|
+
subprocess_args = ["databricks", "--profile", profile, "-o", "json"] + list(args)
|
|
21
|
+
logger.info(f"Run {' '.join(subprocess_args)}")
|
|
22
|
+
return log_command_output(logger, subprocess_args)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_new_token(profile: str) -> str:
|
|
26
|
+
logger.info(f"Creating new token for {profile}")
|
|
27
|
+
today = str(datetime.date.today())
|
|
28
|
+
new_token_json = run_dbx(
|
|
29
|
+
profile,
|
|
30
|
+
"tokens",
|
|
31
|
+
"create",
|
|
32
|
+
"--comment",
|
|
33
|
+
f"dbx cli {today}",
|
|
34
|
+
"--lifetime-seconds",
|
|
35
|
+
str(NINETY_DAYS_S),
|
|
36
|
+
)
|
|
37
|
+
new_token = json.loads(new_token_json)
|
|
38
|
+
|
|
39
|
+
return new_token["token_value"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def delete_old_token(profile: str, token_id: str) -> None:
|
|
43
|
+
logger.info(f"Deleting old token {token_id} for profile {profile}")
|
|
44
|
+
run_dbx(profile, "tokens", "delete", token_id)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def overwrite_dbx_config(
|
|
48
|
+
dbxcfg: str,
|
|
49
|
+
cp: configparser.ConfigParser,
|
|
50
|
+
profile: str,
|
|
51
|
+
aliases: typing.Iterable[str],
|
|
52
|
+
new_token: str,
|
|
53
|
+
) -> None:
|
|
54
|
+
logger.info(f"Updating config file for profile {profile} (alias={aliases})")
|
|
55
|
+
cp[profile]["token"] = new_token
|
|
56
|
+
cp[profile]["nwgh-last-update"] = str(datetime.date.today())
|
|
57
|
+
if aliases:
|
|
58
|
+
for alias in aliases:
|
|
59
|
+
cp[alias]["token"] = new_token
|
|
60
|
+
with open(f"{dbxcfg}.new", "w") as f:
|
|
61
|
+
cp.write(f)
|
|
62
|
+
shutil.copyfile(dbxcfg, f"{dbxcfg}.old")
|
|
63
|
+
shutil.move(f"{dbxcfg}.new", dbxcfg)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def manage_dbx_tokens(profile: str, aliases: typing.Iterable[str], force: bool) -> None:
|
|
67
|
+
dbxcfg = os.path.join(str(os.getenv("HOME")), ".databrickscfg")
|
|
68
|
+
if not os.path.exists(dbxcfg):
|
|
69
|
+
raise Exception(f"Databricks config file {dbxcfg} does not exist")
|
|
70
|
+
|
|
71
|
+
cp = configparser.ConfigParser()
|
|
72
|
+
cp.read(dbxcfg)
|
|
73
|
+
if profile not in cp:
|
|
74
|
+
raise Exception(f"Databricks config does not have profile {dbxcfg}")
|
|
75
|
+
|
|
76
|
+
json_result = run_dbx(profile, "tokens", "list")
|
|
77
|
+
tokens = json.loads(json_result)
|
|
78
|
+
if len(tokens) != 1:
|
|
79
|
+
raise Exception(
|
|
80
|
+
f"This script does not work with {len(tokens)} tokens! There can be only one."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
now_ms = int(time.time()) * 1000
|
|
84
|
+
if force or (tokens[0]["expiry_time"] - now_ms <= TEN_DAYS_MS):
|
|
85
|
+
new_token = create_new_token(profile)
|
|
86
|
+
overwrite_dbx_config(dbxcfg, cp, profile, aliases, new_token)
|
|
87
|
+
delete_old_token(profile, tokens[0]["token_id"])
|
|
88
|
+
else:
|
|
89
|
+
logger.info(f"Token for {profile} is still ok, not rotating")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import configparser
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from .util import log_command_output
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("kion-auth")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def update_kion_auth_password(config_file: str) -> None:
|
|
11
|
+
if not os.path.exists(config_file):
|
|
12
|
+
logger.error(f"kion-auth config file {config_file} does not exist")
|
|
13
|
+
return
|
|
14
|
+
|
|
15
|
+
cp = configparser.ConfigParser()
|
|
16
|
+
cp.read(config_file)
|
|
17
|
+
|
|
18
|
+
if not cp.get("kion_auth", "password"):
|
|
19
|
+
logger.info("Kion password is not defined, not updating")
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
password = log_command_output(logger, ["op", "read", "op://Private/CMS EUA/password"])
|
|
23
|
+
cp["kion_auth"]["password"] = password
|
|
24
|
+
|
|
25
|
+
with open(config_file, "w") as f:
|
|
26
|
+
cp.write(f)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from .util import log_command_output
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger("kion-cli")
|
|
7
|
+
|
|
8
|
+
DEFAULT_CONFIG_FILE = os.path.join(str(os.getenv("HOME")), ".config", "kion.yml")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def update_kion_cli_password(config_file: str) -> None:
|
|
12
|
+
if not os.path.exists(config_file):
|
|
13
|
+
logger.error(f"kion cli config file {config_file} does not exist")
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
password = log_command_output(logger, ["op", "read", "op://Private/CMS EUA/password"])
|
|
17
|
+
|
|
18
|
+
new_lines = []
|
|
19
|
+
with open(config_file) as f:
|
|
20
|
+
for line in f:
|
|
21
|
+
line = line.rstrip()
|
|
22
|
+
if line.startswith(" password:"):
|
|
23
|
+
line = f" password: {password}"
|
|
24
|
+
new_lines.append(line)
|
|
25
|
+
|
|
26
|
+
with open(config_file, "w") as f:
|
|
27
|
+
f.write("\n".join(new_lines))
|
login_auth_tui/noop.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import logging
|
|
3
|
+
import random
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger("noop")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run_noop():
|
|
9
|
+
now = datetime.datetime.now()
|
|
10
|
+
nlines = random.randint(2, 5)
|
|
11
|
+
for i in range(1, nlines):
|
|
12
|
+
logger.info(f"Message {i} from run at {now}")
|
|
13
|
+
|
|
14
|
+
if random.choice([0, 1]):
|
|
15
|
+
msg = f"Faking error for run at {now}"
|
|
16
|
+
logger.error(msg)
|
|
17
|
+
raise Exception(msg)
|
login_auth_tui/tui.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
import io
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
from textual import on, work
|
|
7
|
+
from textual.app import App, ComposeResult
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from textual.containers import HorizontalGroup
|
|
10
|
+
from textual.logging import TextualHandler
|
|
11
|
+
from textual.message import Message
|
|
12
|
+
from textual.reactive import reactive
|
|
13
|
+
from textual.screen import ModalScreen
|
|
14
|
+
from textual.widget import Widget
|
|
15
|
+
from textual.widgets import (
|
|
16
|
+
Footer,
|
|
17
|
+
Label,
|
|
18
|
+
ListItem,
|
|
19
|
+
ListView,
|
|
20
|
+
Log,
|
|
21
|
+
Rule,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from .aws_mfa import aws_mfa
|
|
25
|
+
from .dbx_token_rotate import manage_dbx_tokens
|
|
26
|
+
from .kion_cli_password import DEFAULT_CONFIG_FILE, update_kion_cli_password
|
|
27
|
+
from .noop import run_noop
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("tui")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ProcessStatus(enum.Enum):
|
|
33
|
+
OK = 0
|
|
34
|
+
FAIL = 1
|
|
35
|
+
UNKNOWN = 2
|
|
36
|
+
RUNNING = 3
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Each process will write to its own io.StringIO
|
|
40
|
+
class AuthProcess:
|
|
41
|
+
def __init__(self, process_config: dict):
|
|
42
|
+
self.name = process_config["name"]
|
|
43
|
+
self.type = process_config["type"]
|
|
44
|
+
|
|
45
|
+
if self.type == "aws":
|
|
46
|
+
self.profile = process_config["profile"]
|
|
47
|
+
elif self.type == "dbx":
|
|
48
|
+
self.profile = process_config["profile"]
|
|
49
|
+
self.aliases = []
|
|
50
|
+
alias = process_config.get("alias")
|
|
51
|
+
if alias:
|
|
52
|
+
self.aliases.append(alias)
|
|
53
|
+
elif self.type == "kion-cli":
|
|
54
|
+
self.config = process_config.get("config", DEFAULT_CONFIG_FILE)
|
|
55
|
+
elif self.type == "noop":
|
|
56
|
+
# This is used for testing things
|
|
57
|
+
pass
|
|
58
|
+
else:
|
|
59
|
+
raise ValueError(f"Unexpected auth process type: {self.type}")
|
|
60
|
+
|
|
61
|
+
self.log_stream = io.StringIO()
|
|
62
|
+
self.log_handler = logging.StreamHandler(self.log_stream)
|
|
63
|
+
|
|
64
|
+
def run(self):
|
|
65
|
+
logger.debug(f"Run {self.name} {self.type}")
|
|
66
|
+
process_logger = logging.getLogger(self.type)
|
|
67
|
+
process_logger.addHandler(self.log_handler)
|
|
68
|
+
try:
|
|
69
|
+
if self.type == "aws":
|
|
70
|
+
aws_mfa(self.profile, False)
|
|
71
|
+
elif self.type == "dbx":
|
|
72
|
+
manage_dbx_tokens(self.profile, self.aliases, False)
|
|
73
|
+
elif self.type == "kion-cli":
|
|
74
|
+
update_kion_cli_password(self.config)
|
|
75
|
+
elif self.type == "noop":
|
|
76
|
+
run_noop()
|
|
77
|
+
else:
|
|
78
|
+
raise ValueError(f"Unexpected auth process type: {self.type}")
|
|
79
|
+
finally:
|
|
80
|
+
process_logger.removeHandler(self.log_handler)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_config(config_file: str) -> list[AuthProcess]:
|
|
84
|
+
logger.debug(f"Loading config from {config_file}")
|
|
85
|
+
processes = []
|
|
86
|
+
with open(config_file) as f:
|
|
87
|
+
config = yaml.safe_load(f)
|
|
88
|
+
for auth_process in config:
|
|
89
|
+
processes.append(AuthProcess(auth_process))
|
|
90
|
+
return processes
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AuthProcessStatus(Widget):
|
|
94
|
+
value = reactive(ProcessStatus.UNKNOWN)
|
|
95
|
+
|
|
96
|
+
def render(self) -> str:
|
|
97
|
+
if self.value == ProcessStatus.OK:
|
|
98
|
+
return "\u2714"
|
|
99
|
+
if self.value == ProcessStatus.FAIL:
|
|
100
|
+
return "\u2716"
|
|
101
|
+
if self.value == ProcessStatus.RUNNING:
|
|
102
|
+
return "-"
|
|
103
|
+
return "?"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AuthRunMessage(Message):
|
|
107
|
+
def __init__(self, status: ProcessStatus, *args, **kwargs):
|
|
108
|
+
super().__init__(*args, **kwargs)
|
|
109
|
+
self.status = status
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class AuthRunCompleteMessage(Message):
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class AuthProcessItem(ListItem):
|
|
117
|
+
def __init__(self, proc: AuthProcess, *args, **kwargs):
|
|
118
|
+
super().__init__(*args, **kwargs)
|
|
119
|
+
self.process_config = proc
|
|
120
|
+
self.status = AuthProcessStatus()
|
|
121
|
+
|
|
122
|
+
def compose(self) -> ComposeResult:
|
|
123
|
+
# TODO - make use of more stuff here
|
|
124
|
+
yield HorizontalGroup(Label(self.process_config.name), Label(" "), self.status)
|
|
125
|
+
|
|
126
|
+
@on(AuthRunMessage)
|
|
127
|
+
def run_complete(self, msg: AuthRunMessage):
|
|
128
|
+
self.status.value = msg.status
|
|
129
|
+
self.post_message(AuthRunCompleteMessage())
|
|
130
|
+
|
|
131
|
+
@work(exclusive=True, thread=True)
|
|
132
|
+
def run_auth(self):
|
|
133
|
+
logger.debug(f"Run auth: {self}")
|
|
134
|
+
result = ProcessStatus.UNKNOWN
|
|
135
|
+
try:
|
|
136
|
+
self.process_config.run()
|
|
137
|
+
result = ProcessStatus.OK
|
|
138
|
+
except Exception:
|
|
139
|
+
logger.exception("Error updating auth")
|
|
140
|
+
result = ProcessStatus.FAIL
|
|
141
|
+
self.post_message(AuthRunMessage(result))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class UpdateLogMessage(Message):
|
|
145
|
+
def __init__(self, name: str, value: str, *args, **kwargs):
|
|
146
|
+
super().__init__(*args, **kwargs)
|
|
147
|
+
self.proc_name = name
|
|
148
|
+
self.log_value = value
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class AuthProcessList(ListView):
|
|
152
|
+
BINDINGS = [
|
|
153
|
+
Binding("j, down", "cursor_down", "Cursor down"),
|
|
154
|
+
Binding("k, up", "cursor_up", "Cursor up"),
|
|
155
|
+
Binding("o, enter", "select_cursor", "Select"),
|
|
156
|
+
Binding("r", "run_auth", "Run highlighted"),
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
def __init__(self, procs: list[AuthProcess], *args, **kwargs):
|
|
160
|
+
super().__init__(*args, **kwargs)
|
|
161
|
+
self._auth_processes: list[AuthProcess] = procs
|
|
162
|
+
self._highlighted: AuthProcessItem
|
|
163
|
+
self.running = False
|
|
164
|
+
self.running_all = False
|
|
165
|
+
self.current_item = 0
|
|
166
|
+
self.set_interval(300, self.run_all)
|
|
167
|
+
|
|
168
|
+
def compose(self) -> ComposeResult:
|
|
169
|
+
for p in self._auth_processes:
|
|
170
|
+
yield AuthProcessItem(p)
|
|
171
|
+
|
|
172
|
+
def action_run_auth(self):
|
|
173
|
+
self.running = True
|
|
174
|
+
self._highlighted.status.value = ProcessStatus.RUNNING
|
|
175
|
+
self._highlighted.run_auth()
|
|
176
|
+
|
|
177
|
+
def on_list_view_selected(self, event: ListView.Selected):
|
|
178
|
+
logger.debug(f"Opening {event.item}")
|
|
179
|
+
proc: AuthProcessItem = event.item # type: ignore
|
|
180
|
+
proc_name = proc.process_config.name
|
|
181
|
+
log_value = proc.process_config.log_stream.getvalue()
|
|
182
|
+
self.post_message(UpdateLogMessage(proc_name, log_value))
|
|
183
|
+
|
|
184
|
+
def on_list_view_highlighted(self, event: ListView.Highlighted):
|
|
185
|
+
logger.debug(f"Item is {event.item}")
|
|
186
|
+
self._highlighted = event.item # type: ignore
|
|
187
|
+
|
|
188
|
+
def run_all(self):
|
|
189
|
+
self.running_all = True
|
|
190
|
+
self.running = True
|
|
191
|
+
self.current_item = 0
|
|
192
|
+
self.run_next()
|
|
193
|
+
|
|
194
|
+
def run_next(self):
|
|
195
|
+
if self.current_item >= len(self.children):
|
|
196
|
+
self.running_all = False
|
|
197
|
+
else:
|
|
198
|
+
self.running = True
|
|
199
|
+
item: AuthProcessItem = self.children[self.current_item] # type: ignore
|
|
200
|
+
item.run_auth()
|
|
201
|
+
|
|
202
|
+
@on(AuthRunCompleteMessage)
|
|
203
|
+
def handle_auth_run_complete(self):
|
|
204
|
+
self.running = False
|
|
205
|
+
if self.running_all:
|
|
206
|
+
self.current_item += 1
|
|
207
|
+
self.run_next()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class ProcessLogHeader(Widget):
|
|
211
|
+
process = reactive("none")
|
|
212
|
+
|
|
213
|
+
def render(self):
|
|
214
|
+
return f"Log for process: {self.process}"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class LogScreen(ModalScreen):
|
|
218
|
+
BINDINGS = [Binding("x", "close_log", "Close")]
|
|
219
|
+
|
|
220
|
+
def __init__(self, auth_name: str, log_value: str, *args, **kwargs):
|
|
221
|
+
super().__init__(*args, **kwargs)
|
|
222
|
+
self.auth_name = auth_name
|
|
223
|
+
self.log_value = log_value
|
|
224
|
+
|
|
225
|
+
def compose(self) -> ComposeResult:
|
|
226
|
+
yield Label(f"Log for {self.auth_name}")
|
|
227
|
+
yield Rule()
|
|
228
|
+
|
|
229
|
+
log = Log()
|
|
230
|
+
yield log
|
|
231
|
+
log.write(self.log_value)
|
|
232
|
+
yield Footer()
|
|
233
|
+
|
|
234
|
+
def action_close_log(self):
|
|
235
|
+
self.app.pop_screen()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class AuthTUI(App):
|
|
239
|
+
BINDINGS = [Binding("q", "quit", "Quit")]
|
|
240
|
+
CSS_PATH = "tui.tcss"
|
|
241
|
+
|
|
242
|
+
def __init__(self, processes: list[AuthProcess], **kwargs):
|
|
243
|
+
super().__init__(**kwargs)
|
|
244
|
+
self._processes = processes
|
|
245
|
+
|
|
246
|
+
def compose(self) -> ComposeResult:
|
|
247
|
+
yield AuthProcessList(self._processes, id="auth_list")
|
|
248
|
+
yield Footer()
|
|
249
|
+
|
|
250
|
+
@on(UpdateLogMessage)
|
|
251
|
+
def handle_update_log(self, msg: UpdateLogMessage):
|
|
252
|
+
self.push_screen(LogScreen(msg.proc_name, msg.log_value))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def run_tui(config_file: str):
|
|
256
|
+
logger.addHandler(TextualHandler())
|
|
257
|
+
processes = load_config(config_file)
|
|
258
|
+
app = AuthTUI(processes)
|
|
259
|
+
app.run()
|
login_auth_tui/tui.tcss
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
|
|
3
|
+
import yaml
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def bootstrap_tui_config(input_config: str, output_config: str):
|
|
7
|
+
tui_config = []
|
|
8
|
+
with sqlite3.connect(input_config) as conn:
|
|
9
|
+
res = conn.execute("SELECT * FROM aws_profiles").fetchall()
|
|
10
|
+
for r in res:
|
|
11
|
+
profile_name = r[0]
|
|
12
|
+
tui_config.append(
|
|
13
|
+
{"name": f"AWS ({profile_name})", "type": "aws", "profile": profile_name}
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
res = conn.execute("SELECT * FROM dbx_profiles").fetchall()
|
|
17
|
+
for r in res:
|
|
18
|
+
profile_name = r[0]
|
|
19
|
+
alias = r[1]
|
|
20
|
+
config_entry = {
|
|
21
|
+
"name": f"Databricks ({profile_name})",
|
|
22
|
+
"type": "dbx",
|
|
23
|
+
"profile": profile_name,
|
|
24
|
+
}
|
|
25
|
+
if alias:
|
|
26
|
+
config_entry["alias"] = alias
|
|
27
|
+
tui_config.append(config_entry)
|
|
28
|
+
|
|
29
|
+
res = conn.execute("SELECT kion_cli_config_file FROM config").fetchall()
|
|
30
|
+
if res[0][0]:
|
|
31
|
+
tui_config.append({"name": "Kion", "type": "kion-cli", "config": res[0][0]})
|
|
32
|
+
|
|
33
|
+
with open(output_config, "w") as f:
|
|
34
|
+
yaml.dump(tui_config, f)
|
login_auth_tui/util.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import logging.handlers
|
|
3
|
+
import os
|
|
4
|
+
import subprocess # nosec
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
HOME = str(os.getenv("HOME"))
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def configure_logging(log: str, level: str) -> None:
|
|
11
|
+
if log in ("stdout", "-"):
|
|
12
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
13
|
+
else:
|
|
14
|
+
handler = logging.handlers.RotatingFileHandler(
|
|
15
|
+
log,
|
|
16
|
+
backupCount=1,
|
|
17
|
+
maxBytes=1024 * 1024,
|
|
18
|
+
)
|
|
19
|
+
try:
|
|
20
|
+
log_level = logging.getLevelNamesMapping()[level]
|
|
21
|
+
except Exception:
|
|
22
|
+
raise ValueError(f"Unhandled log level {level}")
|
|
23
|
+
logging.basicConfig(
|
|
24
|
+
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
|
25
|
+
level=log_level,
|
|
26
|
+
handlers=[handler],
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def log_command(
|
|
31
|
+
logger: logging.Logger, command: list[str], **kwargs
|
|
32
|
+
) -> subprocess.CompletedProcess:
|
|
33
|
+
logger.info(f"Run {' '.join(command)}")
|
|
34
|
+
result = subprocess.run(command, capture_output=True, **kwargs) # nosec
|
|
35
|
+
logger.info("--- BEGIN PROCESS STDOUT ---")
|
|
36
|
+
for line in result.stdout.decode("utf-8").strip().split("\n"):
|
|
37
|
+
logger.info(line)
|
|
38
|
+
logger.info("--- END PROCESS STDOUT ---")
|
|
39
|
+
logger.info("--- BEGIN PROCESS STDERR ---")
|
|
40
|
+
for line in result.stderr.decode("utf-8").strip().split("\n"):
|
|
41
|
+
logger.error(line)
|
|
42
|
+
logger.info("--- END PROCESS STDERR ---")
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def log_command_output(logger: logging.Logger, command: list[str], **kwargs) -> str:
|
|
47
|
+
logger.info(f"Run: {' '.join(command)}")
|
|
48
|
+
output = subprocess.check_output(command, **kwargs) # nosec
|
|
49
|
+
return output.decode("utf-8").strip()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: login-auth-tui
|
|
3
|
+
Version: 0.3
|
|
4
|
+
Summary: TUI for login-auth stuff
|
|
5
|
+
Author: Nicholas Hurley
|
|
6
|
+
License-Expression: CC0-1.0
|
|
7
|
+
Requires-Dist: click
|
|
8
|
+
Requires-Dist: textual>=6.4.0
|
|
9
|
+
Requires-Dist: pyyaml
|
|
10
|
+
Requires-Python: >=3.13, <3.14
|
|
11
|
+
Project-URL: CI, https://builds.sr.ht/~nwgh/login-auth-tui
|
|
12
|
+
Project-URL: Changelog, https://git.sr.ht/~nwgh/login-auth-tui/log
|
|
13
|
+
Project-URL: Homepage, https://sr.ht/~nwgh/login-auth-tui
|
|
14
|
+
Project-URL: Issues, https://todo.sr.ht/~nwgh/login-auth-tui
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# login-auth-tui
|
|
18
|
+
|
|
19
|
+
[](https://git.sr.ht/~nwgh/login-auth-tui/log)
|
|
20
|
+
|
|
21
|
+
[](https://builds.sr.ht/~nwgh/login-auth-tui?)
|
|
22
|
+
|
|
23
|
+
[](https://git.sr.ht/~nwgh/login-auth-tui/tree/main/item/LICENSE)
|
|
24
|
+
|
|
25
|
+
TUI for login-auth stuff
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
Install this tool using `uv`:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uv tool install auth-manage --from login-auth-tui
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
For help, run:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
auth-manage --help
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Development
|
|
44
|
+
|
|
45
|
+
To contribute to this tool, first install `uv`. See [the uv documentation](https://docs.astral.sh/uv/getting-started/installation/) for how.
|
|
46
|
+
|
|
47
|
+
Next, checkout the code
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
git clone https://git.sr.ht/~nwgh/login-auth-tui
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Then create a new virtual environment and sync the dependencies:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
cd login-auth-tui
|
|
57
|
+
uv sync
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
To run the tests:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
uv run python -m pytest
|
|
64
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
login_auth_tui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
login_auth_tui/__main__.py,sha256=NdADfCPzs5EAB6hEUNtEpxZNgLdWpqA5iuLGiQmNwlE,73
|
|
3
|
+
login_auth_tui/aws_mfa.py,sha256=rXOcl8DmwSBtRszhubUiT6DmNpGHT_t1oS9kXTPRh8s,2041
|
|
4
|
+
login_auth_tui/cli.py,sha256=3WXcB7uf3ZDBfrekZKk8Iwf1sznGBlgOtMpQ9MhmxiA,8250
|
|
5
|
+
login_auth_tui/dbx_token_rotate.py,sha256=vOp4WtyU6eSH9d0ZpjN5I7Y3J7jDwt0Uz2A2wKdSLuU,2748
|
|
6
|
+
login_auth_tui/kion_auth_password.py,sha256=TA90E0VbKJf2_L8i-Sk51MF0GSLlYyydhN92925KJ6M,697
|
|
7
|
+
login_auth_tui/kion_cli_password.py,sha256=vQM3xyOtSRl1K2KmUYGY5AoDCHCyH_mkoIeS8ZiviuY,783
|
|
8
|
+
login_auth_tui/noop.py,sha256=oqgboJVQte12AO55EPCpJEZ-Q-kRtdNX4bKiTaaOo5M,385
|
|
9
|
+
login_auth_tui/tui.py,sha256=AKSLt0Hq5ZVvDbFZt9tGimPSdPM1y43095DF2_EcuB0,7821
|
|
10
|
+
login_auth_tui/tui.tcss,sha256=T4bylO35mSyN4rkzhYiv80mNILXMaThV8PKVfksCvb0,158
|
|
11
|
+
login_auth_tui/tui_bootstrap.py,sha256=-WVRZwktQWkLtIoiTay5HupQxPufNMncKtDkSVNSnh0,1118
|
|
12
|
+
login_auth_tui/util.py,sha256=JWoyP1K5kvjsX2il5RNlR02HhvK0bqnyx1-yVaguAXg,1570
|
|
13
|
+
login_auth_tui-0.3.dist-info/WHEEL,sha256=5w2T7AS2mz1-rW9CNagNYWRCaB0iQqBMYLwKdlgiR4Q,78
|
|
14
|
+
login_auth_tui-0.3.dist-info/entry_points.txt,sha256=gudzjnTw1su5H2XCHjbHX2MfqDjEiozvUAEXqar9Fgk,56
|
|
15
|
+
login_auth_tui-0.3.dist-info/METADATA,sha256=rLVBYB2mlo0c8coKL9vbMOLKzzZ00Ia8HWEwND15nNE,1507
|
|
16
|
+
login_auth_tui-0.3.dist-info/RECORD,,
|