Skip to content

Commit a58023b

Browse files
authored
Merge pull request #10 from SpaceInvaderTech/experimental
migrate-part-of-script-to-aws
2 parents 4fbe921 + f236eff commit a58023b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+9860
-272
lines changed

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
PASSWD="extract this from iCloud keychain"
2+
HAYSTACKS_ENDPOINT=/haystacks
3+
USER_AGENT_COMMENT="Please contact [email protected]"
4+
API_KEY="get this from prod/space-invader-api/api-key"

.github/workflows/build-deploy.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: Deploy Serverless App
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
stage:
7+
description: 'Stage to deploy to'
8+
required: false
9+
default: 'prod'
10+
11+
jobs:
12+
deploy:
13+
name: Deploy
14+
runs-on: ubuntu-latest
15+
# Add permissions block for OIDC token
16+
permissions:
17+
id-token: write
18+
contents: read
19+
20+
steps:
21+
- name: Checkout code
22+
uses: actions/checkout@v3
23+
24+
- name: Configure AWS Credentials
25+
uses: aws-actions/configure-aws-credentials@v2
26+
with:
27+
role-to-assume: arn:aws:iam::942778112339:role/git-deploy
28+
aws-region: eu-central-1
29+
30+
- name: Install Node.js
31+
uses: actions/setup-node@v3
32+
with:
33+
node-version: '18'
34+
35+
- name: Install npm dependencies
36+
run: npm ci
37+
38+
- name: Install Python
39+
uses: actions/setup-python@v4
40+
with:
41+
python-version: '3.11'
42+
43+
- name: Install Poetry
44+
run: |
45+
pipx install poetry==1.8.2
46+
poetry install --no-root --no-interaction
47+
48+
- name: Deploy to AWS
49+
run: ./node_modules/.bin/sls deploy --stage ${{ github.event.inputs.stage }}

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,6 @@ status_code.txt
164164
log.txt
165165

166166
test.sh
167+
.idea/
168+
node_modules/
169+
.serverless/

.python-version

Lines changed: 0 additions & 1 deletion
This file was deleted.

ReadMe.md

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,48 @@
11
# AppleCollector
22

3-
Query Apple's Find My network, based on all the hard work of [OpenHaystack](https://github.com/seemoo-lab/openhaystack/), @vtky, @hatomist and others.
3+
Query Apple's Find My network, based on all the hard work
4+
of [OpenHaystack](https://github.com/seemoo-lab/openhaystack/), @vtky, @hatomist and others.
45

56
This is a fork of great work from @biemster and modified to have device locations sent to an endpoint.
67

78
## Prerequisites
89

9-
XCode or Command Line Tools, latest pip (`pip3 install -U pip`, otherwise `cryptography` will not install).
10+
- Install python 3.12 and poetry on your system (`pipx install poetry==1.8.2`) if you don't have it already.
11+
- Install project dependencies: `poetry shell` and `poetry install`
1012

11-
pip3 install -r requirements.txt
13+
### Project Setup (MacOS)
1214

13-
## Scripts
15+
- Enable iCloud on your macOS device
16+
- Search for `icloud` in the Keychain
17+
![img.png](docs/keychain_search.png)
1418

15-
`main.py` will query Apple's Find My network based on private keys fetched from an API and can send locations to an API.
19+
- Select the `iCloud` entry with your email address
20+
![keychain_select.png](docs%2Fkeychain_select.png)
1621

17-
`passwd.sh` will get a one time password for iCloud and store it in `$HOME/.haypass`.
22+
- Click on `show password` and copy the password
23+
![img.png](docs/keychain_show_pass.png)
1824

19-
`example.cron.sh` is an example script for running `main.py`.
25+
- Use this password as your `PASSWD` in the `.env` file
2026

21-
`launched.AppleCollector.plist` is for periodically running `cron.sh`.
2227

23-
Setup:
28+
### Project Setup (non-MacOS)
2429

25-
mkdir -p ~/Library/LaunchAgents
26-
cp launched.AppleCollector.plist ~/Library/LaunchAgents/
27-
launchctl load -w ~/Library/LaunchAgents/launched.AppleCollector.plist
30+
You cannot use the `python manage.py refresh-credentials` command to refresh the credentials, since this require
31+
access to the macOS keychain (and it's depended on hardware).
32+
33+
## Refresh Credentials
34+
35+
- Use the `python manage.py refresh-credentials --schedule-location-fetching`
36+
command to refresh the credentials (run on a MacOS, stored on AWS) and schedule location fetching (on AWS)
37+
38+
39+
- Use the `python manage.py refresh-credentials`
40+
command to refresh the credentials (stored on AWS) without scheduling location fetching
41+
42+
> This command is handy for testing purposes
43+
44+
## Local Debug
45+
46+
- After you have executed `python manage.py refresh-credentials` you have one minute before the credentials expire
47+
- Run `python manage.py fetch-locations --trackers E0D4FA128FA9,EC3987ECAA50,CDAA0CCF4128,EDDC7DA1A247,D173D540749D --limit 1000 --hours-ago 48`
48+
to fetch the locations of specific trackers

api.py

Lines changed: 0 additions & 34 deletions
This file was deleted.

app/__init__.py

Whitespace-only changes.

app/api.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import logging
2+
from requests import Session
3+
from app.dtos import DeviceResponse
4+
from app.helpers import status_code_success
5+
6+
requestSession = Session()
7+
logger = logging.getLogger(__name__)
8+
9+
10+
def fetch_devices_metadata_from_space_invader_api(
11+
url,
12+
headers=None,
13+
limit: int = 3000,
14+
page: int = 0,
15+
) -> DeviceResponse:
16+
response = requestSession.get(url, headers=headers, timeout=60, params={
17+
"limit": limit,
18+
"offset": page,
19+
})
20+
_handle_response(response)
21+
return DeviceResponse(**response.json())
22+
23+
24+
def send_reports_to_api(url, data, headers=None):
25+
"""Send reports to API"""
26+
if not url:
27+
return
28+
29+
response = requestSession.post(
30+
url,
31+
headers=headers,
32+
json=data,
33+
timeout=60,
34+
)
35+
_handle_response(response)
36+
37+
38+
def _handle_response(response):
39+
if not status_code_success(response.status_code):
40+
raise Exception(f"Request failed with status code {response.status_code}: {response.text}")

app/apple_fetch.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""
2+
Fetch from Apple's acsnservice
3+
"""
4+
import logging
5+
from requests import Session
6+
7+
from app.exceptions import AppleAuthCredentialsExpired
8+
from app.helpers import status_code_success
9+
from app.date import unix_epoch, date_milliseconds
10+
from pydantic import BaseModel, Field
11+
12+
requestSession = Session()
13+
logger = logging.getLogger(__name__)
14+
15+
16+
class AppleLocation(BaseModel):
17+
date_published: int = Field(alias="datePublished")
18+
payload: str
19+
description: str
20+
id: str
21+
status_code: int = Field(alias="statusCode")
22+
23+
class Config:
24+
populate_by_name = True
25+
validate_by_name = True
26+
27+
28+
class ResponseDto(BaseModel):
29+
results: list[AppleLocation] = Field(default_factory=list)
30+
statusCode: str
31+
error: str = Field(default=None)
32+
33+
@property
34+
def is_success(self) -> bool:
35+
return self.statusCode == "200"
36+
37+
38+
def apple_fetch(security_headers: dict, ids, hours_ago: int = 1) -> ResponseDto:
39+
logger.info("Fetching locations from Apple API for %s", ids)
40+
startdate = unix_epoch() - hours_ago * 60 * 60
41+
enddate = unix_epoch()
42+
43+
response = _acsnservice_fetch(security_headers, ids, startdate, enddate)
44+
45+
if not status_code_success(response.status_code):
46+
if response.status_code == 401:
47+
raise AppleAuthCredentialsExpired(response.reason)
48+
49+
logger.error('Error from Apple API: %s %s', response.status_code, response.reason)
50+
return ResponseDto(error=response.reason, statusCode=str(response.status_code))
51+
52+
return ResponseDto(**response.json())
53+
54+
55+
def _acsnservice_fetch(security_headers, ids, startdate, enddate):
56+
"""Fetch from Apple's acsnservice"""
57+
data = {
58+
"search": [
59+
{
60+
"startDate": date_milliseconds(startdate),
61+
"endDate": date_milliseconds(enddate),
62+
"ids": ids,
63+
}
64+
]
65+
}
66+
return requestSession.post(
67+
"https://gateway.icloud.com/acsnservice/fetch",
68+
headers=security_headers,
69+
json=data,
70+
timeout=60,
71+
)

app/auth.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import os
2+
import json
3+
import logging
4+
import functools
5+
6+
from app.settings import settings
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
def api_auth_required(func):
12+
"""
13+
Decorator to validate API authorization code in request headers.
14+
Checks for x-api-key header and validates it against CREDENTIALS_API_KEY env variable.
15+
"""
16+
17+
@functools.wraps(func)
18+
def wrapper(event, context):
19+
headers = event.get('headers', {})
20+
if not headers:
21+
logger.warning("No headers found in request")
22+
return {
23+
"statusCode": 401,
24+
"body": json.dumps({"error": "Unauthorized"})
25+
}
26+
27+
api_code = None
28+
for key, value in headers.items():
29+
if key.lower() == 'x-api-key':
30+
api_code = value
31+
break
32+
33+
if not api_code:
34+
logger.warning("No x-api-key header found in request")
35+
return {
36+
"statusCode": 401,
37+
"body": json.dumps({"error": "Unauthorized"})
38+
}
39+
40+
if api_code != settings.CREDENTIALS_API_KEY:
41+
logger.warning("Invalid API code provided")
42+
return {
43+
"statusCode": 403,
44+
"body": json.dumps({"error": "Forbidden"})
45+
}
46+
47+
return func(event, context)
48+
49+
return wrapper

0 commit comments

Comments
 (0)