nate2-scripts 1.0.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.
- nate2_scripts/__init__.py +4 -0
- nate2_scripts/bing_wrapper.py +170 -0
- nate2_scripts/console/__init__.py +174 -0
- nate2_scripts/console/arnResolver.py +514 -0
- nate2_scripts/console/destitationResolver.py +104 -0
- nate2_scripts/console/hashGrabber.py +178 -0
- nate2_scripts/console/regionCache.py +50 -0
- nate2_scripts/console/resourceIdResolver.py +174 -0
- nate2_scripts/console/sessionCache.py +96 -0
- nate2_scripts/test.py +98 -0
- nate2_scripts-1.0.0.dist-info/METADATA +9 -0
- nate2_scripts-1.0.0.dist-info/RECORD +15 -0
- nate2_scripts-1.0.0.dist-info/WHEEL +4 -0
- nate2_scripts-1.0.0.dist-info/entry_points.txt +3 -0
- nate2_scripts-1.0.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import shutil
|
|
3
|
+
import sqlite3
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
SUPPORTED_BROWSERS = {
|
|
11
|
+
"com.google.chrome": "chrome",
|
|
12
|
+
"org.mozilla.firefox": "firefox",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
WAIT_SECONDS = 10 # Time to wait for the browser to open and record the hash
|
|
16
|
+
|
|
17
|
+
ONLY_HASH_SECONDS = 5 # Time to wait for the browser to open and record the hash, but only return the hash if found
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HashGrabber:
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def _get_default_browser() -> str:
|
|
24
|
+
"""Return 'chrome' or 'firefox'. Prints to stderr and exits for unsupported browsers."""
|
|
25
|
+
result = subprocess.run(
|
|
26
|
+
[
|
|
27
|
+
"defaults", "read",
|
|
28
|
+
str(Path.home() / "Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure"),
|
|
29
|
+
"LSHandlers",
|
|
30
|
+
],
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
)
|
|
34
|
+
# Find the block containing LSHandlerURLScheme = https and extract LSHandlerRoleAll
|
|
35
|
+
match = re.search(
|
|
36
|
+
r'LSHandlerRoleAll\s*=\s*"([^"]+)";\s*LSHandlerURLScheme\s*=\s*https;',
|
|
37
|
+
result.stdout,
|
|
38
|
+
)
|
|
39
|
+
bundle_id = match.group(1).lower() if match else ""
|
|
40
|
+
|
|
41
|
+
if bundle_id in SUPPORTED_BROWSERS:
|
|
42
|
+
return SUPPORTED_BROWSERS[bundle_id]
|
|
43
|
+
|
|
44
|
+
print(
|
|
45
|
+
f"Unsupported default browser: {bundle_id or 'unknown'}. Only Chrome and Firefox are supported.",
|
|
46
|
+
file=sys.stderr,
|
|
47
|
+
)
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def get_hash(id: str, time_started: float) -> str:
|
|
52
|
+
"""Search the default browser's history for the most recent AWS console URL matching the account ID.
|
|
53
|
+
|
|
54
|
+
Polls up to WAIT_SECONDS for a URL visited after time_started.
|
|
55
|
+
Returns the hash portion from URLs like https://{id}-{hash}.{region}.console.aws.amazon.com/*
|
|
56
|
+
or empty string for URLs like https://{region}.console.aws.amazon.com/*.
|
|
57
|
+
|
|
58
|
+
Raises RuntimeError if no matching URL is found within the timeout.
|
|
59
|
+
"""
|
|
60
|
+
default = HashGrabber._get_default_browser()
|
|
61
|
+
|
|
62
|
+
pattern_with_hash = re.compile(
|
|
63
|
+
rf"https://{re.escape(id)}-([a-z0-9]+)\.[a-z0-9-]+\.console\.aws\.amazon\.com"
|
|
64
|
+
)
|
|
65
|
+
pattern_without_hash = re.compile(
|
|
66
|
+
r"https://[a-z0-9-]+\.console\.aws\.amazon\.com"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
fetch_fn = {
|
|
70
|
+
"chrome": HashGrabber._chrome_history,
|
|
71
|
+
"firefox": HashGrabber._firefox_history,
|
|
72
|
+
}[default]
|
|
73
|
+
|
|
74
|
+
deadline = time_started + WAIT_SECONDS
|
|
75
|
+
hash_only_deadline = time_started + ONLY_HASH_SECONDS
|
|
76
|
+
time.sleep(0.5)
|
|
77
|
+
|
|
78
|
+
while time.time() < deadline:
|
|
79
|
+
try:
|
|
80
|
+
print("-----------------------------------------")
|
|
81
|
+
for url, visit_time in fetch_fn(id, time_started):
|
|
82
|
+
print(url, visit_time)
|
|
83
|
+
match = pattern_with_hash.search(url)
|
|
84
|
+
if match:
|
|
85
|
+
return match.group(1)
|
|
86
|
+
elif time.time() >= hash_only_deadline and pattern_without_hash.search(url):
|
|
87
|
+
return ""
|
|
88
|
+
except (OSError, sqlite3.Error):
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
time.sleep(0.5)
|
|
92
|
+
|
|
93
|
+
raise RuntimeError(
|
|
94
|
+
f"No AWS console URL found for account {id} within {WAIT_SECONDS}s"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _chrome_history(account_id: str, time_started: float) -> list[tuple[str, float]]:
|
|
99
|
+
db_path = HashGrabber._chrome_sqlite_path()
|
|
100
|
+
if not db_path.exists():
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
# Chrome stores time as microseconds since 1601-01-01
|
|
104
|
+
chrome_epoch_offset = 11644473600
|
|
105
|
+
chrome_time_started = int((time_started + chrome_epoch_offset) * 1_000_000)
|
|
106
|
+
|
|
107
|
+
with tempfile.NamedTemporaryFile(suffix=".sqlite") as tmp:
|
|
108
|
+
shutil.copy2(db_path, tmp.name)
|
|
109
|
+
conn = sqlite3.connect(tmp.name)
|
|
110
|
+
try:
|
|
111
|
+
cursor = conn.execute(
|
|
112
|
+
"""
|
|
113
|
+
SELECT url, last_visit_time
|
|
114
|
+
FROM urls
|
|
115
|
+
WHERE (url LIKE ? OR url LIKE '%console.aws.amazon.com%')
|
|
116
|
+
AND last_visit_time > ?
|
|
117
|
+
ORDER BY last_visit_time DESC
|
|
118
|
+
LIMIT 10
|
|
119
|
+
""",
|
|
120
|
+
(f"%{account_id}%console.aws.amazon.com%", chrome_time_started),
|
|
121
|
+
)
|
|
122
|
+
return cursor.fetchall()
|
|
123
|
+
finally:
|
|
124
|
+
conn.close()
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _firefox_history(account_id: str, time_started: float) -> list[tuple[str, float]]:
|
|
128
|
+
db_path = HashGrabber._firefox_sqlite_path()
|
|
129
|
+
if not db_path or not db_path.exists():
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
# Firefox stores time as microseconds since Unix epoch
|
|
133
|
+
firefox_time_started = int(time_started * 1_000_000)
|
|
134
|
+
|
|
135
|
+
with tempfile.NamedTemporaryFile(suffix=".sqlite") as tmp:
|
|
136
|
+
shutil.copy2(db_path, tmp.name)
|
|
137
|
+
conn = sqlite3.connect(tmp.name)
|
|
138
|
+
try:
|
|
139
|
+
cursor = conn.execute(
|
|
140
|
+
"""
|
|
141
|
+
SELECT p.url, v.visit_date
|
|
142
|
+
FROM moz_places p
|
|
143
|
+
JOIN moz_historyvisits v ON v.place_id = p.id
|
|
144
|
+
WHERE (p.url LIKE ? OR p.url LIKE '%console.aws.amazon.com%')
|
|
145
|
+
AND v.visit_date > ?
|
|
146
|
+
ORDER BY v.visit_date DESC
|
|
147
|
+
LIMIT 10
|
|
148
|
+
""",
|
|
149
|
+
(f"%{account_id}%console.aws.amazon.com%", firefox_time_started),
|
|
150
|
+
)
|
|
151
|
+
return cursor.fetchall()
|
|
152
|
+
finally:
|
|
153
|
+
conn.close()
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _chrome_sqlite_path() -> Path:
|
|
157
|
+
return Path.home() / "Library/Application Support/Google/Chrome/Default/History"
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _firefox_sqlite_path() -> Path | None:
|
|
161
|
+
profiles_dir = Path.home() / "Library/Application Support/Firefox/Profiles"
|
|
162
|
+
if not profiles_dir.exists():
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
for pattern in ["*.default-release", "*.default"]:
|
|
166
|
+
matches = list(profiles_dir.glob(pattern))
|
|
167
|
+
if matches:
|
|
168
|
+
db = matches[0] / "places.sqlite"
|
|
169
|
+
if db.exists():
|
|
170
|
+
return db
|
|
171
|
+
|
|
172
|
+
for profile in profiles_dir.iterdir():
|
|
173
|
+
if profile.is_dir():
|
|
174
|
+
db = profile / "places.sqlite"
|
|
175
|
+
if db.exists():
|
|
176
|
+
return db
|
|
177
|
+
|
|
178
|
+
return None
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
DEFAULT_REGION = "us-east-1"
|
|
6
|
+
DEFAULT_REGIONS = ["us-east-1", "us-east-2", "us-west-1", "us-west-2"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RegionCache:
|
|
10
|
+
"""Loads and persists the list of AWS regions and the default region.
|
|
11
|
+
|
|
12
|
+
Configuration is read from XDG_STATE_HOME/console-regions.json
|
|
13
|
+
(defaulting to ~/.local/state/console-regions.json). If the file is
|
|
14
|
+
absent or invalid it is recreated with the built-in defaults.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
# Resolve the config file path from XDG_STATE_HOME or the default ~/.local/state
|
|
18
|
+
PATH = (
|
|
19
|
+
Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
|
|
20
|
+
/ "console-regions.json"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Class-level defaults; overwritten per-instance when the config file is loaded
|
|
24
|
+
regions: list[str] = DEFAULT_REGIONS
|
|
25
|
+
default: str = DEFAULT_REGION
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
"""Load region config from disk, falling back to defaults if unavailable."""
|
|
29
|
+
if self.PATH.exists():
|
|
30
|
+
try:
|
|
31
|
+
file = json.loads(self.PATH.read_text())
|
|
32
|
+
if file.get("regions"):
|
|
33
|
+
self.regions = file["regions"]
|
|
34
|
+
if file.get("default"):
|
|
35
|
+
self.default = file["default"]
|
|
36
|
+
return
|
|
37
|
+
except (json.JSONDecodeError, OSError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
# File is missing or corrupt — write defaults so subsequent reads succeed
|
|
41
|
+
self.PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
self.PATH.write_text(json.dumps({"regions": DEFAULT_REGIONS, "default": DEFAULT_REGION}))
|
|
43
|
+
|
|
44
|
+
def get_regions(self) -> list[str]:
|
|
45
|
+
"""Return the list of configured AWS regions."""
|
|
46
|
+
return self.regions
|
|
47
|
+
|
|
48
|
+
def get_default_region(self) -> str:
|
|
49
|
+
"""Return the default AWS region."""
|
|
50
|
+
return self.default
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import sys
|
|
3
|
+
from typing import NoReturn
|
|
4
|
+
import boto3
|
|
5
|
+
from botocore.exceptions import ClientError
|
|
6
|
+
|
|
7
|
+
from nate2_scripts.console.regionCache import RegionCache
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ResourceIdResolver:
|
|
11
|
+
|
|
12
|
+
region_cache: RegionCache
|
|
13
|
+
mdn: str
|
|
14
|
+
|
|
15
|
+
def __init__(self, region_cache: RegionCache, multisession_domain_name: str):
|
|
16
|
+
self.region_cache = region_cache
|
|
17
|
+
self.mdn = multisession_domain_name
|
|
18
|
+
|
|
19
|
+
_KMS_UUID = re.compile(
|
|
20
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
21
|
+
)
|
|
22
|
+
_KMS_MRK = re.compile(r"^mrk-[0-9a-f]{32}$")
|
|
23
|
+
|
|
24
|
+
def try_parse_resource_id(self, resource_id: str) -> str | None:
|
|
25
|
+
kind = self._kind(resource_id)
|
|
26
|
+
if kind is None:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
# IAM resources are global — no region search needed
|
|
30
|
+
if kind == "role":
|
|
31
|
+
name = resource_id[5:]
|
|
32
|
+
try:
|
|
33
|
+
boto3.client("iam").get_role(RoleName=name)
|
|
34
|
+
except ClientError:
|
|
35
|
+
self._not_found(resource_id)
|
|
36
|
+
return f"https://{self.mdn}us-east-1.console.aws.amazon.com/iam/home#/roles/{name}"
|
|
37
|
+
if kind == "user":
|
|
38
|
+
name = resource_id[4:]
|
|
39
|
+
try:
|
|
40
|
+
boto3.client("iam").get_user(UserName=name)
|
|
41
|
+
except ClientError:
|
|
42
|
+
self._not_found(resource_id)
|
|
43
|
+
return f"https://{self.mdn}us-east-1.console.aws.amazon.com/iam/home#/users/{name}"
|
|
44
|
+
|
|
45
|
+
# regional resources — find the region that actually holds the resource
|
|
46
|
+
for region in self.region_cache.get_regions():
|
|
47
|
+
url = self._resolve_in_region(kind, resource_id, region)
|
|
48
|
+
if url:
|
|
49
|
+
return url
|
|
50
|
+
|
|
51
|
+
self._not_found(resource_id)
|
|
52
|
+
|
|
53
|
+
def _kind(self, resource_id: str) -> str | None:
|
|
54
|
+
if re.match(r"^i-[0-9a-f]{8,17}$", resource_id):
|
|
55
|
+
return "instance"
|
|
56
|
+
if re.match(r"^vpc-[0-9a-f]{8,17}$", resource_id):
|
|
57
|
+
return "vpc"
|
|
58
|
+
if re.match(r"^subnet-[0-9a-f]{8,17}$", resource_id):
|
|
59
|
+
return "subnet"
|
|
60
|
+
if re.match(r"^sg-[0-9a-f]{8,17}$", resource_id):
|
|
61
|
+
return "sg"
|
|
62
|
+
if re.match(r"^ami-[0-9a-f]{8,17}$", resource_id):
|
|
63
|
+
return "ami"
|
|
64
|
+
if re.match(r"^lt-[0-9a-f]{8,17}$", resource_id):
|
|
65
|
+
return "lt"
|
|
66
|
+
if re.match(r"^rtb-[0-9a-f]{8,17}$", resource_id):
|
|
67
|
+
return "rtb"
|
|
68
|
+
if re.match(r"^vol-[0-9a-f]{8,17}$", resource_id):
|
|
69
|
+
return "vol"
|
|
70
|
+
if re.match(r"^snap-[0-9a-f]{8,17}$", resource_id):
|
|
71
|
+
return "snap"
|
|
72
|
+
if re.match(r"^eni-[0-9a-f]{8,17}$", resource_id):
|
|
73
|
+
return "eni"
|
|
74
|
+
if re.match(r"^nat-[0-9a-f]{8,17}$", resource_id):
|
|
75
|
+
return "nat"
|
|
76
|
+
# no idea where this is supposed to go, but it's a thing that exists
|
|
77
|
+
# keeping it here in case I figure out later
|
|
78
|
+
# if re.match(r"^fl-[0-9a-f]{8,17}$", resource_id):
|
|
79
|
+
# return "fl"
|
|
80
|
+
if self._KMS_UUID.match(resource_id) or self._KMS_MRK.match(resource_id):
|
|
81
|
+
return "kms"
|
|
82
|
+
if resource_id.startswith("db/"):
|
|
83
|
+
return "rds"
|
|
84
|
+
if resource_id.startswith("role/"):
|
|
85
|
+
return "role"
|
|
86
|
+
if resource_id.startswith("usr/"):
|
|
87
|
+
return "user"
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def _resolve_in_region(
|
|
91
|
+
self, kind: str, resource_id: str, region: str
|
|
92
|
+
) -> str | None:
|
|
93
|
+
base = f"https://{self.mdn}{region}.console.aws.amazon.com"
|
|
94
|
+
try:
|
|
95
|
+
match kind:
|
|
96
|
+
case "instance":
|
|
97
|
+
ec2 = boto3.client("ec2", region_name=region)
|
|
98
|
+
if ec2.describe_instances(InstanceIds=[resource_id])["Reservations"]:
|
|
99
|
+
return f"{base}/ec2/v2/home?region={region}#InstanceDetails:instanceId={resource_id}"
|
|
100
|
+
case "vpc":
|
|
101
|
+
ec2 = boto3.client("ec2", region_name=region)
|
|
102
|
+
if ec2.describe_vpcs(VpcIds=[resource_id])["Vpcs"]:
|
|
103
|
+
return f"{base}/vpcconsole/home?region={region}#VpcDetails:VpcId={resource_id}"
|
|
104
|
+
case "subnet":
|
|
105
|
+
ec2 = boto3.client("ec2", region_name=region)
|
|
106
|
+
if ec2.describe_subnets(SubnetIds=[resource_id])["Subnets"]:
|
|
107
|
+
return f"{base}/vpcconsole/home?region={region}#SubnetDetails:subnetId={resource_id}"
|
|
108
|
+
case "sg":
|
|
109
|
+
ec2 = boto3.client("ec2", region_name=region)
|
|
110
|
+
if ec2.describe_security_groups(GroupIds=[resource_id])[
|
|
111
|
+
"SecurityGroups"
|
|
112
|
+
]:
|
|
113
|
+
return f"{base}/ec2/v2/home?region={region}#SecurityGroup:groupId={resource_id}"
|
|
114
|
+
case "ami":
|
|
115
|
+
ec2 = boto3.client("ec2", region_name=region)
|
|
116
|
+
if ec2.describe_images(ImageIds=[resource_id])["Images"]:
|
|
117
|
+
return f"{base}/ec2/home?region={region}#ImageDetails:imageId={resource_id}"
|
|
118
|
+
case "lt":
|
|
119
|
+
ec2 = boto3.client("ec2", region_name=region)
|
|
120
|
+
if ec2.describe_launch_templates(LaunchTemplateIds=[resource_id])[
|
|
121
|
+
"LaunchTemplates"
|
|
122
|
+
]:
|
|
123
|
+
return f"{base}/ec2/home?region={region}#LaunchTemplateDetails:launchTemplateId={resource_id}"
|
|
124
|
+
case "rtb":
|
|
125
|
+
ec2 = boto3.client("ec2", region_name=region)
|
|
126
|
+
if ec2.describe_route_tables(RouteTableIds=[resource_id])[
|
|
127
|
+
"RouteTables"
|
|
128
|
+
]:
|
|
129
|
+
return f"{base}/vpcconsole/home?region={region}#RouteTableDetails:RouteTableId={resource_id}"
|
|
130
|
+
case "vol":
|
|
131
|
+
ec2 = boto3.client("ec2", region_name=region)
|
|
132
|
+
if ec2.describe_volumes(VolumeIds=[resource_id])["Volumes"]:
|
|
133
|
+
return f"{base}/ec2/home?region={region}#VolumeDetails:volumeId={resource_id}"
|
|
134
|
+
case "snap":
|
|
135
|
+
ec2 = boto3.client("ec2", region_name=region)
|
|
136
|
+
if ec2.describe_snapshots(SnapshotIds=[resource_id])["Snapshots"]:
|
|
137
|
+
return f"{base}/ec2/home?region={region}#SnapshotDetails:snapshotId={resource_id}"
|
|
138
|
+
case "eni":
|
|
139
|
+
ec2 = boto3.client("ec2", region_name=region)
|
|
140
|
+
if ec2.describe_network_interfaces(NetworkInterfaceIds=[resource_id])["NetworkInterfaces"]:
|
|
141
|
+
return f"{base}/ec2/home?region={region}#NetworkInterface:networkInterfaceId={resource_id}"
|
|
142
|
+
case "nat":
|
|
143
|
+
ec2 = boto3.client("ec2", region_name=region)
|
|
144
|
+
if ec2.describe_nat_gateways(NatGatewayIds=[resource_id])["NatGateways"]:
|
|
145
|
+
return f"{base}/vpcconsole/home?region={region}#NatGatewayDetails:natGatewayId={resource_id}"
|
|
146
|
+
# case "fl":
|
|
147
|
+
# ec2 = boto3.client("ec2", region_name=region)
|
|
148
|
+
# if ec2.describe_flow_logs(FlowLogIds=[resource_id])["FlowLogs"]:
|
|
149
|
+
# return f"{base}/vpcconsole/home?region={region}#FlowLogs:flowLogId={resource_id}"
|
|
150
|
+
case "kms":
|
|
151
|
+
boto3.client("kms", region_name=region).describe_key(
|
|
152
|
+
KeyId=resource_id
|
|
153
|
+
)
|
|
154
|
+
return f"{base}/kms/home?region={region}#/kms/keys/{resource_id}"
|
|
155
|
+
# TODO test this
|
|
156
|
+
case "rds":
|
|
157
|
+
name = resource_id[3:]
|
|
158
|
+
if boto3.client("rds", region_name=region).describe_db_instances(
|
|
159
|
+
DBInstanceIdentifier=name
|
|
160
|
+
)["DBInstances"]:
|
|
161
|
+
return f"{base}/rds/home?region={region}#database:id={name};is-cluster=false"
|
|
162
|
+
except ClientError:
|
|
163
|
+
# not present (or not accessible) in this region — try the next one
|
|
164
|
+
return None
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
def _not_found(self, resource_id: str) -> NoReturn:
|
|
168
|
+
regions = ", ".join(self.region_cache.get_regions())
|
|
169
|
+
print(
|
|
170
|
+
f"Error: '{resource_id}' not found or not accessible in regions: {regions}",
|
|
171
|
+
file=sys.stderr,
|
|
172
|
+
)
|
|
173
|
+
sys.exit(1)
|
|
174
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SessionCache:
|
|
9
|
+
"""Persists AWS console federation session hashes to avoid redundant federated sign-ins.
|
|
10
|
+
|
|
11
|
+
The cache is stored as a JSON file at XDG_STATE_HOME/console-sessions.json
|
|
12
|
+
(defaulting to ~/.local/state/console-sessions.json). Each entry maps an AWS
|
|
13
|
+
profile name to its federated_at timestamp and session hash.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# Resolve the cache file path from XDG_STATE_HOME or the default ~/.local/state
|
|
17
|
+
PATH = (
|
|
18
|
+
Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
|
|
19
|
+
/ "console-sessions.json"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def _load_cache(self) -> dict:
|
|
23
|
+
"""Read the cache file and return its contents as a dict.
|
|
24
|
+
|
|
25
|
+
Returns an empty dict if the file does not exist or is unreadable/corrupt.
|
|
26
|
+
"""
|
|
27
|
+
if self.PATH.exists():
|
|
28
|
+
try:
|
|
29
|
+
return json.loads(self.PATH.read_text())
|
|
30
|
+
except (json.JSONDecodeError, OSError):
|
|
31
|
+
return {}
|
|
32
|
+
return {}
|
|
33
|
+
|
|
34
|
+
def _save_cache(self, cache: dict) -> None:
|
|
35
|
+
"""Persist the cache dict to disk, creating parent directories as needed."""
|
|
36
|
+
self.PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
self.PATH.write_text(json.dumps(cache))
|
|
38
|
+
|
|
39
|
+
def get_session_hash(self, profile: str, creds: dict) -> None | str:
|
|
40
|
+
"""Return the cached session hash for a profile if it is still valid.
|
|
41
|
+
|
|
42
|
+
A cached entry is considered invalid (returns None) when:
|
|
43
|
+
- No entry exists for the profile.
|
|
44
|
+
- The STS credential Expiration timestamp has already passed.
|
|
45
|
+
- The entry was federated more than 11 hours ago (AWS console session limit).
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
profile: The AWS profile name used to look up the cache entry.
|
|
49
|
+
creds: STS credentials dict; may contain an "Expiration" ISO-8601 string.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The cached session hash string, or None if the cache is missing/expired.
|
|
53
|
+
"""
|
|
54
|
+
cache = self._load_cache()
|
|
55
|
+
entry = cache.get(profile)
|
|
56
|
+
|
|
57
|
+
if not entry:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
# Honour the STS credential expiration if present
|
|
61
|
+
expiration = creds.get("Expiration")
|
|
62
|
+
if expiration:
|
|
63
|
+
try:
|
|
64
|
+
exp_time = datetime.fromisoformat(expiration.replace("Z", "+00:00"))
|
|
65
|
+
if datetime.now(timezone.utc) >= exp_time:
|
|
66
|
+
return None
|
|
67
|
+
except ValueError:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
# AWS federated console sessions expire after 12 hours; evict after 11 to be safe
|
|
71
|
+
if time.time() - entry["federated_at"] > 11 * 3600:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
return entry.get("hash")
|
|
75
|
+
|
|
76
|
+
def record_valid_hash(self, profile: str, hash: str) -> None:
|
|
77
|
+
"""Store a newly obtained session hash and evict entries older than 12 hours.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
profile: The AWS profile name to associate with the hash.
|
|
81
|
+
hash: The session hash returned after a successful federation sign-in.
|
|
82
|
+
"""
|
|
83
|
+
cache = self._load_cache()
|
|
84
|
+
# Record the new entry with the current epoch time
|
|
85
|
+
cache[profile] = {"federated_at": time.time(), "hash": hash}
|
|
86
|
+
# Prune stale entries to keep the cache file small
|
|
87
|
+
cache = {
|
|
88
|
+
k: v
|
|
89
|
+
for k, v in cache.items()
|
|
90
|
+
if time.time() - v.get("federated_at", 0) < 12 * 3600
|
|
91
|
+
}
|
|
92
|
+
self._save_cache(cache)
|
|
93
|
+
|
|
94
|
+
def reset(self) -> None:
|
|
95
|
+
"""Clear the entire session cache."""
|
|
96
|
+
self._save_cache({})
|
nate2_scripts/test.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import webbrowser
|
|
9
|
+
import argparse
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import argcomplete
|
|
14
|
+
import boto3
|
|
15
|
+
from botocore.exceptions import ClientError
|
|
16
|
+
|
|
17
|
+
def get_credentials() -> dict:
|
|
18
|
+
result = subprocess.run(
|
|
19
|
+
["aws", "configure", "export-credentials"],
|
|
20
|
+
capture_output=True,
|
|
21
|
+
text=True,
|
|
22
|
+
)
|
|
23
|
+
if result.returncode != 0:
|
|
24
|
+
print(f"Error: Failed to export credentials.\n{result.stderr}", file=sys.stderr)
|
|
25
|
+
sys.exit(1)
|
|
26
|
+
return json.loads(result.stdout)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_signin_token(creds: dict) -> str:
|
|
30
|
+
session = json.dumps(
|
|
31
|
+
{
|
|
32
|
+
"sessionId": creds["AccessKeyId"],
|
|
33
|
+
"sessionKey": creds["SecretAccessKey"],
|
|
34
|
+
"sessionToken": creds["SessionToken"],
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
sess = urllib.parse.quote(session)
|
|
38
|
+
token_url = (
|
|
39
|
+
f"https://signin.aws.amazon.com/federation?Action=getSigninToken&Session={sess}"
|
|
40
|
+
)
|
|
41
|
+
result = subprocess.run(
|
|
42
|
+
["curl", "-s", token_url], capture_output=True, text=True, check=True
|
|
43
|
+
)
|
|
44
|
+
try:
|
|
45
|
+
return json.loads(result.stdout)["SigninToken"]
|
|
46
|
+
except json.JSONDecodeError:
|
|
47
|
+
print("Error: Failed to parse JSON response. You may not have credentials.")
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
|
|
50
|
+
creds = get_credentials()
|
|
51
|
+
print(creds)
|
|
52
|
+
|
|
53
|
+
signin_token = get_signin_token(creds)
|
|
54
|
+
|
|
55
|
+
print(signin_token)
|
|
56
|
+
|
|
57
|
+
# Build the federation login URL
|
|
58
|
+
destination = "https://us-east-1.console.aws.amazon.com/console/home?region=us-east-1"
|
|
59
|
+
dest_encoded = urllib.parse.quote(destination, safe="")
|
|
60
|
+
login_url = f"https://signin.aws.amazon.com/federation?Action=login&Issuer=&Destination={dest_encoded}&SigninToken={signin_token}"
|
|
61
|
+
|
|
62
|
+
# Follow redirects and capture the final URL
|
|
63
|
+
# print("\nFollowing federation redirect chain...")
|
|
64
|
+
# result = subprocess.run(
|
|
65
|
+
# ["curl", "-s", "-L", "-o", "/dev/null", "-w", "%{url_effective}", login_url],
|
|
66
|
+
# capture_output=True,
|
|
67
|
+
# text=True,
|
|
68
|
+
# )
|
|
69
|
+
# final_url = result.stdout.strip()
|
|
70
|
+
# print(f"Final URL: {final_url}")
|
|
71
|
+
|
|
72
|
+
print(" ".join(["curl", "-s", "-L", "-v", login_url]))
|
|
73
|
+
sys.exit(0)
|
|
74
|
+
# Also check just the first redirect (without following)
|
|
75
|
+
signin_token2 = get_signin_token(creds)
|
|
76
|
+
login_url2 = f"https://signin.aws.amazon.com/federation?Action=login&Issuer=&Destination={dest_encoded}&SigninToken={signin_token2}"
|
|
77
|
+
|
|
78
|
+
result2 = subprocess.run(
|
|
79
|
+
["curl", "-s", "-o", "/dev/null", "-w", "%{redirect_url}", login_url2],
|
|
80
|
+
capture_output=True,
|
|
81
|
+
text=True,
|
|
82
|
+
)
|
|
83
|
+
first_redirect = result2.stdout.strip()
|
|
84
|
+
print(f"First redirect URL: {first_redirect}")
|
|
85
|
+
|
|
86
|
+
# Check if either contains the account-specific hash pattern
|
|
87
|
+
pattern = r"(\d{12})-([a-z0-9]+)\.[a-z0-9-]+\.console\.aws\.amazon\.com"
|
|
88
|
+
for label, url in [("Final", final_url), ("First redirect", first_redirect)]:
|
|
89
|
+
match = re.search(pattern, url)
|
|
90
|
+
if match:
|
|
91
|
+
account_id, console_hash = match.groups()
|
|
92
|
+
print(f"\n>>> Found hash in {label} URL!")
|
|
93
|
+
print(f" Account ID: {account_id}")
|
|
94
|
+
print(f" Console hash: {console_hash}")
|
|
95
|
+
break
|
|
96
|
+
else:
|
|
97
|
+
print("\n>>> No account-specific hash found in redirects.")
|
|
98
|
+
print(" The rewrite is likely done client-side by JavaScript.")
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nate2-scripts
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Utility scripts for AWS development on macOS
|
|
5
|
+
Project-URL: Homepage, https://github.com/tennex-nwestfall/nate2-scripts
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: argcomplete
|
|
9
|
+
Requires-Dist: boto3
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
nate2_scripts/__init__.py,sha256=Odk0uF1h2RuL1xKI0M2Fp8A_Be7pT0PLkAw2nB9wC9M,101
|
|
2
|
+
nate2_scripts/bing_wrapper.py,sha256=AKeIYAfFCFDVkP3PMdj7TEx25M_JpYRJHMN9SRqln5U,4694
|
|
3
|
+
nate2_scripts/test.py,sha256=IlsOWc3eE_komf774xFBisu2ui1a28QmVPcySsVeG9s,3204
|
|
4
|
+
nate2_scripts/console/__init__.py,sha256=_PGvw96WrEQNjJOZe0RmRvOClZ44L_y8hXY8P88AG_o,5050
|
|
5
|
+
nate2_scripts/console/arnResolver.py,sha256=tiBw_XVlEWtj322jZWk0RVz9Pe6fdBk2C-lzMOPhxUE,24934
|
|
6
|
+
nate2_scripts/console/destitationResolver.py,sha256=myZW81U7Deh0nPv4jfIvNVd8_XFf3d22_VX0_7ExjiY,5630
|
|
7
|
+
nate2_scripts/console/hashGrabber.py,sha256=ydSqDCqCwG1YaqO5WKWWqMm0dLxR9GODX7BwYyXpcFo,6437
|
|
8
|
+
nate2_scripts/console/regionCache.py,sha256=2yHSfP1bXBqL3QoSVEXw1pO4cuCIIYjm7h1qOYAwfWc,1812
|
|
9
|
+
nate2_scripts/console/resourceIdResolver.py,sha256=QH7QA17n-jsJ7yj9ulyHP6MFT94jochXi9CX0-xkcqY,8185
|
|
10
|
+
nate2_scripts/console/sessionCache.py,sha256=q60C_0vIg8h5oPP1Bh3rTdrMPn5mEkPQz7FR8DhrHmA,3562
|
|
11
|
+
nate2_scripts-1.0.0.dist-info/METADATA,sha256=ibx35mXSfjLFA7bdsqxzTgvQlI9jgOwfwzbd4DjllgY,278
|
|
12
|
+
nate2_scripts-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
13
|
+
nate2_scripts-1.0.0.dist-info/entry_points.txt,sha256=PvmjaUn-B1nck47azFCr4HYs0si1LRe-hMqEYwvIFCM,102
|
|
14
|
+
nate2_scripts-1.0.0.dist-info/licenses/LICENSE,sha256=ixuiBLtpoK3iv89l7ylKkg9rs2GzF9ukPH7ynZYzK5s,35148
|
|
15
|
+
nate2_scripts-1.0.0.dist-info/RECORD,,
|