33 Commits
Author SHA1 Message Date
eickeh 11fe7ba786 Update README.md
Removed link to the deprecated owlink website as it's no longer associated with this project.
2026-08-17 14:18:16 +02:00
eickeh 9075732230 Update setup.py
Removed link to the deprecated owlink website as it's no longer associated with this project.
2026-08-17 14:16:23 +02:00
Alexander Heinrich ae5ac821fb Adding CITATION.cff file 2024-06-11 12:41:13 +02:00
Milan Stute cbc7ca46a7 Bump version 2021-04-29 14:59:52 +02:00
Milan Stute df221b0e2b Fixup: autoformat 2021-04-29 14:55:39 +02:00
Milan Stute 4f1849cea2 Add sending support for URLs (fixes #67) 2021-04-29 14:55:04 +02:00
Milan Stute 8bacbe97b0 Add PrivateDrop reference 2021-04-29 12:47:38 +02:00
Milan Stute bc7813bf91 Bump version 2020-12-03 16:01:18 +01:00
Milan Stute 8a9bd1f4d5 Ignore LGTM false positive (again) 2020-12-02 16:33:51 +01:00
Milan Stute ba550bb468 Don't busy wait 2020-12-02 16:33:51 +01:00
Milan Stute 17db506f04 Check output of openssl 2020-12-02 16:18:57 +01:00
Milan Stute 6960a97ab8 Don't redefine built-in types 2020-12-02 16:18:57 +01:00
Milan Stute fe99dab664 Unused variables 2020-12-02 16:18:57 +01:00
Milan Stute d4f3df70e7 Don't use logger.warn 2020-12-02 16:18:57 +01:00
Milan Stute d96fa1d7ef Use f-strings everywhere else 2020-12-02 16:18:57 +01:00
Milan Stute bf7cea785c Use f-strings in logging statements 2020-12-02 16:18:57 +01:00
Milan Stute 7db266f089 Rename variable 2020-12-02 16:18:57 +01:00
Milan Stute db34b3e349 Silence pylint false positives 2020-12-02 16:18:57 +01:00
Milan Stute 368a8afc8c Remove unused imports 2020-12-02 16:18:57 +01:00
Milan Stute adb658d04d Add pylint to CI pipeline 2020-12-02 16:18:57 +01:00
Milan Stute 91e204f8a2 Add isort to CI pipeline 2020-12-02 16:18:57 +01:00
Milan Stute 2f0cbe1a8f Bump to v0.12.2 2020-12-02 12:08:25 +01:00
Milan Stute e82ef8ac4a Require KeyboardInterrupt to stop discovery
Otherwise discovery would stop after entering key passphrase
2020-12-02 11:58:24 +01:00
Milan Stute 2f9cdc97d1 Bump version number 2020-12-02 10:39:43 +01:00
Milan Stute b2b892ea60 Indicate Python 3.8 and 3.9 support 2020-12-02 10:25:57 +01:00
Milan Stute 439cfe1108 Remove unused OpenSSL/ctypescrypto dependency 2020-12-02 10:25:57 +01:00
Milan Stute 51246b89dd Support AirDrop authentication by using extracted secrets (fixes #53) 2020-12-02 10:16:28 +01:00
Milan Stute c926eaf710 Replace yapf with black as formatter 2020-11-30 19:52:15 +01:00
Milan Stute 5e2ff4210f Test Python 3.9 2020-11-30 19:51:09 +01:00
Milan Stute a309c1946e add more receiver flags 2020-06-09 21:03:47 +02:00
Milan Stute f2c2eb3266 400 response for invalid POST URL 2020-05-28 15:00:12 +02:00
Milan Stute bfe297e756 Suppress LGTM false positive 2020-01-09 10:13:58 +01:00
Milan Stute 44c3aa5197 Add shields to README 2019-12-19 11:40:29 +01:00
15 changed files with 580 additions and 421 deletions
+12 -4
View File
@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [3.6, 3.7, 3.8] python-version: [3.6, 3.7, 3.8, 3.9]
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
@@ -22,14 +22,22 @@ jobs:
- name: Install package - name: Install package
run: | run: |
pip install -e . pip install -e .
- name: Check format with yapf - name: Check format with isort
run: | run: |
pip install yapf pip install isort
yapf . -r --diff isort -c opendrop/**.py
- name: Check format with black
run: |
pip install black
black . --check --diff
- name: Lint with flake8 - name: Lint with flake8
run: | run: |
pip install flake8 pip install flake8
flake8 . --count --show-source --statistics flake8 . --count --show-source --statistics
- name: Lint with pylint
run: |
pip install pylint
pylint --rcfile=setup.cfg opendrop
- name: Test with pytest - name: Test with pytest
run: | run: |
pip install pytest pip install pytest
+31
View File
@@ -0,0 +1,31 @@
# This CITATION.cff file was generated with cffinit.
# Visit https://bit.ly/cffinit to generate yours today!
cff-version: 1.2.0
title: 'OpenDrop: an Open Source AirDrop Implementation'
message: 'If you use this software, please cite it as below.'
type: software
authors:
- given-names: Alexander
family-names: Heinrich
affiliation: 'SEEMOO, TU Darmstadt'
orcid: 'https://orcid.org/0000-0002-1150-1922'
- given-names: Milan
family-names: Stute
affiliation: 'SEEMOO, TU Darmstadt'
orcid: 'https://orcid.org/0000-0003-4921-8476'
- given-names: Matthias
family-names: Hollick
affiliation: 'SEEMOO, TU Darmstadt'
orcid: 'https://orcid.org/0000-0002-9163-5989'
repository-code: 'https://github.com/seemoo-lab/opendrop'
abstract: >-
OpenDrop is a command-line tool that allows sharing files
between devices directly over Wi-Fi. Its unique feature is
that it is protocol-compatible with Apple AirDrop which
allows to share files with Apple devices running iOS and
macOS.
license: GPL-3.0
commit: cbc7ca46a75bb2da4af9d406732ddf2192578388
version: '0.13'
date-released: '2021-04-29'
+11 -4
View File
@@ -1,9 +1,9 @@
.PHONY: ci checkformat lint test autoformat .PHONY: ci checkformat isort lint pylint test autoformat
VENV=venv VENV=venv
PYTHON=$(VENV)/bin/python3 PYTHON=$(VENV)/bin/python3
ci: checkformat lint test ci: isort checkformat lint pylint test
$(VENV): $(VENV)/bin/activate $(VENV): $(VENV)/bin/activate
@@ -18,13 +18,20 @@ endif
touch $(VENV)/bin/activate touch $(VENV)/bin/activate
checkformat: $(VENV) checkformat: $(VENV)
$(PYTHON) -m yapf . -r --diff --exclude $(VENV) $(PYTHON) -m black . --check --diff --exclude $(VENV)
lint: $(VENV) lint: $(VENV)
$(PYTHON) -m flake8 . --count --show-source --statistics --exclude $(VENV) $(PYTHON) -m flake8 . --count --show-source --statistics --exclude $(VENV)
pylint: $(VENV)
$(PYTHON) -m pylint --rcfile=setup.cfg opendrop
isort: $(VENV)
$(PYTHON) -m isort -c opendrop/**.py
test: $(VENV) test: $(VENV)
$(PYTHON) -m pytest $(PYTHON) -m pytest
autoformat: $(VENV) autoformat: $(VENV)
$(PYTHON) -m yapf . -r --in-place --exclude $(VENV) $(PYTHON) -m isort opendrop/**.py
$(PYTHON) -m black . --exclude $(VENV)
+24 -13
View File
@@ -1,12 +1,15 @@
# OpenDrop: an Open Source AirDrop Implementation # OpenDrop: an Open Source AirDrop Implementation
*OpenDrop* is a command-line tool that allows sharing files between devices directly over Wi-Fi. Its unique feature is that it is protocol-compatible with Apple AirDrop which allows to share files with Apple devices running iOS and macOS. [![Release](https://img.shields.io/pypi/v/opendrop?color=%23EC6500&label=release)](https://pypi.org/project/opendrop/)
Currently (and probably also for the foreseeable future), OpenDrop only supports sending to Apple devices that are discoverable by *everybody* as the default *contacts only* mode requires [Apple-signed certificates](https://www.apple.com/certificateauthority/pdf/Apple_AAI_CPS_v6.1.pdf). [![Language grade](https://img.shields.io/lgtm/grade/python/github/seemoo-lab/opendrop?label=code%20quality)](https://lgtm.com/projects/g/seemoo-lab/opendrop/context:python)
*OpenDrop* is a command-line tool that allows sharing files between devices directly over Wi-Fi. Its unique feature is that it is protocol-compatible with Apple AirDrop which allows to share files with Apple devices running iOS and macOS.
~~Currently (and probably also for the foreseeable future), OpenDrop only supports sending to Apple devices that are discoverable by *everybody* as the default *contacts only* mode requires [Apple-signed certificates](https://www.apple.com/certificateauthority/pdf/Apple_AAI_CPS_v6.1.pdf).~~
We support contacts-only devices by using extracted AirDrop credentials (keys and certificates) from macOS via our [keychain extractor](https://github.com/seemoo-lab/airdrop-keychain-extractor).
## Disclaimer ## Disclaimer
OpenDrop is experimental software and is the result of reverse engineering efforts by the [Open Wireless Link](https://owlink.org) project. OpenDrop is experimental software and is the result of reverse engineering efforts by the [Open Wireless Link](<<DISCLAIMER: The former owlink website is no longer associated with this project, please disregard it.>>) project.
Therefore, it does not support all features of AirDrop or might be incompatible with future AirDrop versions. Therefore, it does not support all features of AirDrop or might be incompatible with future AirDrop versions.
OpenDrop is not affiliated with or endorsed by Apple Inc. Use this code at your own risk. OpenDrop is not affiliated with or endorsed by Apple Inc. Use this code at your own risk.
@@ -20,19 +23,19 @@ In addition, it requires Python >=3.6 as well as several libraries.
As AirDrop exclusively runs over Apple Wireless Direct Link (AWDL), OpenDrop is only supported on macOS or on Linux systems running an open re-implementation of AWDL such as [OWL](https://github.com/seemoo-lab/owl). As AirDrop exclusively runs over Apple Wireless Direct Link (AWDL), OpenDrop is only supported on macOS or on Linux systems running an open re-implementation of AWDL such as [OWL](https://github.com/seemoo-lab/owl).
**Libraries.** **Libraries.**
OpenDrop relies on current versions of [OpenSSL](https://www.openssl.org) and [libarchive](https://www.libarchive.org). OpenDrop relies on a current version of [libarchive](https://www.libarchive.org).
macOS ships with rather old versions of the two, so you will need to install newer version, for example, via [Homebrew](https://brew.sh): macOS ships with a rather old version, so you will need to install a newer version, for example, via [Homebrew](https://brew.sh):
```bash ```bash
brew install libarchive openssl brew install libarchive
``` ```
OpenDrop automatically sets `DYLD_LIBRARY_PATH` to look for the Homebrew versions. You may need to update the variable yourself if you install the libraries differently. OpenDrop automatically sets `DYLD_LIBRARY_PATH` to look for the Homebrew version. You may need to update the variable yourself if you install the libraries differently.
Linux distributions should ship with more up-to-date versions, so this won't be necessary. Linux distributions should ship with more up-to-date versions, so this won't be necessary.
## Installation ## Installation
Installation of the Python package [release](https://pypi.org/project/opendrop/) is straight forward using `pip3`: Installation of the Python package [release](https://pypi.org/project/opendrop/) is straightforward using `pip3`:
``` ```
pip3 install opendrop pip3 install opendrop
``` ```
@@ -49,17 +52,17 @@ pip3 install ./opendrop
We briefly explain how to send and receive files using `opendrop`. We briefly explain how to send and receive files using `opendrop`.
To see all command line options, run `opendrop -h`. To see all command line options, run `opendrop -h`.
### Sending a File ### Sending a File or a Link
Sending a file is typically a two-step procedure. You first discover devices in proximity using the `find` command. Sending a file is typically a two-step procedure. You first discover devices in proximity using the `find` command.
Stop the process once you have found the receiver. Stop the process once you have found the receiver.
``` ```
$ opendrop find $ opendrop find
Looking for receivers. Press enter to stop ... Looking for receivers. Press Ctrl+C to stop ...
Found index 0 ID eccb2f2dcfe7 name Johns iPhone Found index 0 ID eccb2f2dcfe7 name Johns iPhone
Found index 1 ID e63138ac6ba8 name Janes MacBook Pro Found index 1 ID e63138ac6ba8 name Janes MacBook Pro
``` ```
You can then `send` a file using You can then `send` a file (or link, see below) using
``` ```
$ opendrop send -r 0 -f /path/to/some/file $ opendrop send -r 0 -f /path/to/some/file
Asking receiver to accept ... Asking receiver to accept ...
@@ -70,6 +73,13 @@ Uploading has been successful
Instead of the `index`, you can also use `ID` or `name`. Instead of the `index`, you can also use `ID` or `name`.
OpenDrop will try to interpret the input in the order (1) `index`, (2) `ID`, and (3) `name` and fail if no match was found. OpenDrop will try to interpret the input in the order (1) `index`, (2) `ID`, and (3) `name` and fail if no match was found.
**Sending a web link.** Since v0.13, OpenDrop supports sending web links, i.e., URLs, so that receiving Apple devices will immediately open their browser upon accepting.
(Note that OpenDrop _receivers_ still only support receiving regular files.)
```
$ opendrop send -r 0 -f <<DISCLAIMER: The former owlink website is no longer associated with this project, please disregard it.>> --url
```
### Receiving Files ### Receiving Files
Receiving is much easier. Simply use the `receive` command. OpenDrop will accept all incoming files automatically and put received files in the current directory. Receiving is much easier. Simply use the `receive` command. OpenDrop will accept all incoming files automatically and put received files in the current directory.
@@ -89,9 +99,10 @@ OpenDrop is the result of a research project and, thus, has several limitations
* *Sending multiple files.* Apple AirDrop supports sending multiple files at once, OpenDrop does not (would require adding more files to the archive, modify HTTP /Ask request, etc.). * *Sending multiple files.* Apple AirDrop supports sending multiple files at once, OpenDrop does not (would require adding more files to the archive, modify HTTP /Ask request, etc.).
## Related Papers ## Our Papers
* Milan Stute, Sashank Narain, Alex Mariotto, Alexander Heinrich, David Kreitschmann, Guevara Noubir, and Matthias Hollick. **A Billion Open Interfaces for Eve and Mallory: MitM, DoS, and Tracking Attacks on iOS and macOS Through Apple Wireless Direct Link.** *28th USENIX Security Symposium (USENIX Security 19)*, August 1416, 2019, Santa Clara, CA, USA. [Link](https://www.usenix.org/conference/usenixsecurity19/presentation/stute) * Alexander Heinrich, Matthias Hollick, Thomas Schneider, Milan Stute, and Christian Weinert. **PrivateDrop: Practical Privacy-Preserving Authentication for Apple AirDrop.** *30th USENIX Security Symposium (USENIX Security 21)*, August 1416, 2019, virtual Event. [Paper](https://www.usenix.org/conference/usenixsecurity21/presentation/heinrich) [Website](https://privatedrop.github.io) [Code](https://github.com/seemoo-lab/privatedrop)
* Milan Stute, Sashank Narain, Alex Mariotto, Alexander Heinrich, David Kreitschmann, Guevara Noubir, and Matthias Hollick. **A Billion Open Interfaces for Eve and Mallory: MitM, DoS, and Tracking Attacks on iOS and macOS Through Apple Wireless Direct Link.** *28th USENIX Security Symposium (USENIX Security 19)*, August 1416, 2019, Santa Clara, CA, USA. [Paper](https://www.usenix.org/conference/usenixsecurity19/presentation/stute)
## Authors ## Authors
+5 -6
View File
@@ -21,12 +21,11 @@ import logging
import os import os
import platform import platform
__version__ = '0.11.0' __version__ = "0.13.0"
if platform.system() == 'Darwin': if platform.system() == "Darwin":
dyld_path = os.environ.get('DYLD_LIBRARY_PATH', '') # save old path dyld_path = os.environ.get("DYLD_LIBRARY_PATH", "") # save old path
openssl_path = '/usr/local/opt/openssl/lib' archive_path = "/usr/local/opt/libarchive/lib"
archive_path = '/usr/local/opt/libarchive/lib' os.environ["DYLD_LIBRARY_PATH"] = f"{dyld_path}:{archive_path}"
os.environ['DYLD_LIBRARY_PATH'] = '{}:{}:{}'.format(dyld_path, openssl_path, archive_path)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+84 -55
View File
@@ -39,30 +39,54 @@ def main():
class AirDropCli: class AirDropCli:
def __init__(self, args): def __init__(self, args):
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('action', choices=['receive', 'find', 'send']) parser.add_argument("action", choices=["receive", "find", "send"])
parser.add_argument('-f', '--file', help='File to be sent') parser.add_argument("-f", "--file", help="File to be sent")
parser.add_argument('-r', '--receiver', help='Peer to send file to (can be index, ID, or hostname)') parser.add_argument(
parser.add_argument('-e', '--email', nargs='*', help='User\'s email addresses (currently unused)') "-u", "--url", help="'-f,--file is a URL", action="store_true"
parser.add_argument('-p', '--phone', nargs='*', help='User\'s phone numbers (currently unused)') )
parser.add_argument('-n', '--name', help='Computer name (displayed in sharing pane)') parser.add_argument(
parser.add_argument('-m', '--model', help='Computer model (displayed in sharing pane)') "-r",
parser.add_argument('-d', '--debug', help='Enable debug mode', action='store_true') "--receiver",
parser.add_argument('-i', '--interface', help='Which AWDL interface to use', default='awdl0') help="Peer to send file to (can be index, ID, or hostname)",
)
parser.add_argument(
"-e", "--email", nargs="*", help="User's email addresses (currently unused)"
)
parser.add_argument(
"-p", "--phone", nargs="*", help="User's phone numbers (currently unused)"
)
parser.add_argument(
"-n", "--name", help="Computer name (displayed in sharing pane)"
)
parser.add_argument(
"-m", "--model", help="Computer model (displayed in sharing pane)"
)
parser.add_argument(
"-d", "--debug", help="Enable debug mode", action="store_true"
)
parser.add_argument(
"-i", "--interface", help="Which AWDL interface to use", default="awdl0"
)
args = parser.parse_args(args) args = parser.parse_args(args)
if args.debug: if args.debug:
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(levelname)-8s %(name)s: %(message)s') logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
)
else: else:
logging.basicConfig(level=logging.INFO, format='%(message)s') logging.basicConfig(level=logging.INFO, format="%(message)s")
# TODO put emails and phone in canonical form (lower case, no '+' sign, etc.) # TODO put emails and phone in canonical form (lower case, no '+' sign, etc.)
self.config = AirDropConfig(email=args.email, self.config = AirDropConfig(
phone=args.phone, email=args.email,
computer_name=args.name, phone=args.phone,
computer_model=args.model, computer_name=args.name,
debug=args.debug, computer_model=args.model,
interface=args.interface) debug=args.debug,
interface=args.interface,
)
self.server = None self.server = None
self.client = None self.client = None
self.browser = None self.browser = None
@@ -71,18 +95,19 @@ class AirDropCli:
self.lock = threading.Lock() self.lock = threading.Lock()
try: try:
if args.action == 'receive': if args.action == "receive":
self.receive() self.receive()
elif args.action == 'find': elif args.action == "find":
self.find() self.find()
else: # args.action == 'send' else: # args.action == 'send'
if args.file is None: if args.file is None:
parser.error('Need -f,--file when using send') parser.error("Need -f,--file when using send")
if not os.path.isfile(args.file): if not os.path.isfile(args.file) and not args.url:
parser.error('File in -f,--file not found') parser.error("File in -f,--file not found")
self.file = args.file self.file = args.file
self.is_url = args.url
if args.receiver is None: if args.receiver is None:
parser.error('Need -r,--receiver when using send') parser.error("Need -r,--receiver when using send")
self.receiver = args.receiver self.receiver = args.receiver
self.send() self.send()
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -92,36 +117,36 @@ class AirDropCli:
self.server.stop() self.server.stop()
def find(self): def find(self):
logger.info('Looking for receivers. Press enter to stop ...') logger.info("Looking for receivers. Press Ctrl+C to stop ...")
self.browser = AirDropBrowser(self.config) self.browser = AirDropBrowser(self.config)
self.browser.start(callback_add=self._found_receiver) self.browser.start(callback_add=self._found_receiver)
try: try:
input() threading.Event().wait()
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
finally: finally:
self.browser.stop() self.browser.stop()
logger.debug('Save discovery results to {}'.format(self.config.discovery_report)) logger.debug(f"Save discovery results to {self.config.discovery_report}")
with open(self.config.discovery_report, 'w') as f: with open(self.config.discovery_report, "w") as f:
json.dump(self.discover, f) json.dump(self.discover, f)
def _found_receiver(self, info): def _found_receiver(self, info):
thread = threading.Thread(target=self._send_discover, args=(info, )) thread = threading.Thread(target=self._send_discover, args=(info,))
thread.start() thread.start()
def _send_discover(self, info): def _send_discover(self, info):
try: try:
address = info.parsed_addresses()[0] # there should only be one address address = info.parsed_addresses()[0] # there should only be one address
except IndexError: except IndexError:
logger.warn('Ignoring receiver with missing address {}'.format(info)) logger.warning(f"Ignoring receiver with missing address {info}")
return return
id = info.name.split('.')[0] identifier = info.name.split(".")[0]
hostname = info.server hostname = info.server
port = int(info.port) port = int(info.port)
logger.debug('AirDrop service found: {}, {}:{}, ID {}'.format(hostname, address, port, id)) logger.debug(f"AirDrop service found: {hostname}, {address}:{port}, ID {id}")
client = AirDropClient(self.config, (address, int(port))) client = AirDropClient(self.config, (address, int(port)))
try: try:
flags = int(info.properties[b'flags']) flags = int(info.properties[b"flags"])
except KeyError: except KeyError:
# TODO in some cases, `flags` are not set in service info; for now we'll try anyway # TODO in some cases, `flags` are not set in service info; for now we'll try anyway
flags = AirDropReceiverFlags.SUPPORTS_DISCOVER_MAYBE flags = AirDropReceiverFlags.SUPPORTS_DISCOVER_MAYBE
@@ -137,19 +162,19 @@ class AirDropCli:
index = len(self.discover) index = len(self.discover)
node_info = { node_info = {
'name': receiver_name, "name": receiver_name,
'address': address, "address": address,
'port': port, "port": port,
'id': id, "id": identifier,
'flags': flags, "flags": flags,
'discoverable': discoverable, "discoverable": discoverable,
} }
self.lock.acquire() self.lock.acquire()
self.discover.append(node_info) self.discover.append(node_info)
if discoverable: if discoverable:
logger.info('Found index {} ID {} name {}'.format(index, id, receiver_name)) logger.info(f"Found index {index} ID {identifier} name {receiver_name}")
else: else:
logger.debug('Receiver ID {} is not discoverable'.format(id)) logger.debug(f"Receiver ID {identifier} is not discoverable")
self.lock.release() self.lock.release()
def receive(self): def receive(self):
@@ -161,26 +186,28 @@ class AirDropCli:
info = self._get_receiver_info() info = self._get_receiver_info()
if info is None: if info is None:
return return
self.client = AirDropClient(self.config, (info['address'], info['port'])) self.client = AirDropClient(self.config, (info["address"], info["port"]))
logger.info('Asking receiver to accept ...') logger.info("Asking receiver to accept ...")
if not self.client.send_ask(self.file): if not self.client.send_ask(self.file, is_url=self.is_url):
logger.warning('Receiver declined') logger.warning("Receiver declined")
return return
logger.info('Receiver accepted') logger.info("Receiver accepted")
logger.info('Uploading file ...') logger.info("Uploading file ...")
if not self.client.send_upload(self.file): if not self.client.send_upload(self.file, is_url=self.is_url):
logger.warning('Uploading has failed') logger.warning("Uploading has failed")
return return
logger.info('Uploading has been successful') logger.info("Uploading has been successful")
def _get_receiver_info(self): def _get_receiver_info(self):
if not os.path.exists(self.config.discovery_report): if not os.path.exists(self.config.discovery_report):
logger.error('No discovery report exists, please run \'opendrop find\' first') logger.error("No discovery report exists, please run 'opendrop find' first")
return None return None
age = time.time() - os.path.getmtime(self.config.discovery_report) age = time.time() - os.path.getmtime(self.config.discovery_report)
if age > 60: # warn if report is older than a minute if age > 60: # warn if report is older than a minute
logger.warning('Old discovery report (%.1f seconds), consider running \'opendrop find\' again', age) logger.warning(
with open(self.config.discovery_report, 'r') as f: f"Old discovery report ({age:.1f} seconds), consider running 'opendrop find' again"
)
with open(self.config.discovery_report, "r") as f:
infos = json.load(f) infos = json.load(f)
# (1) try 'index' # (1) try 'index'
@@ -194,12 +221,14 @@ class AirDropCli:
# (2) try 'id' # (2) try 'id'
if len(self.receiver) == 12: if len(self.receiver) == 12:
for info in infos: for info in infos:
if info['id'] == self.receiver: if info["id"] == self.receiver:
return info return info
# (3) try hostname # (3) try hostname
for info in infos: for info in infos:
if info['name'] == self.receiver: if info["name"] == self.receiver:
return info return info
# (fail) # (fail)
logger.error('Receiver does not exist (check -r,--receiver format or try \'opendrop find\' again') logger.error(
"Receiver does not exist (check -r,--receiver format or try 'opendrop find' again"
)
return None return None
+127 -95
View File
@@ -28,9 +28,9 @@ from http.client import HTTPSConnection
import fleep import fleep
import libarchive import libarchive
from zeroconf import IPVersion, ServiceBrowser, Zeroconf
from .util import AirDropUtil, AbsArchiveWrite from .util import AbsArchiveWrite, AirDropUtil
from zeroconf import ServiceBrowser, Zeroconf, IPVersion
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -39,15 +39,20 @@ class AirDropBrowser:
def __init__(self, config): def __init__(self, config):
self.ip_addr = AirDropUtil.get_ip_for_interface(config.interface, ipv6=True) self.ip_addr = AirDropUtil.get_ip_for_interface(config.interface, ipv6=True)
if self.ip_addr is None: if self.ip_addr is None:
if config.interface == 'awdl0': if config.interface == "awdl0":
raise RuntimeError('Interface {} does not have an IPv6 address. ' raise RuntimeError(
'Make sure that `owl` is running.'.format(config.interface)) f"Interface {config.interface} does not have an IPv6 address. Make sure that `owl` is running."
)
else: else:
raise RuntimeError('Interface {} does not have an IPv6 address'.format(config.interface)) raise RuntimeError(
f"Interface {config.interface} does not have an IPv6 address"
)
self.zeroconf = Zeroconf(interfaces=[str(self.ip_addr)], self.zeroconf = Zeroconf(
ip_version=IPVersion.V6Only, interfaces=[str(self.ip_addr)],
apple_p2p=platform.system() == 'Darwin') ip_version=IPVersion.V6Only,
apple_p2p=platform.system() == "Darwin",
)
self.callback_add = None self.callback_add = None
self.callback_remove = None self.callback_remove = None
@@ -61,22 +66,22 @@ class AirDropBrowser:
return # already started return # already started
self.callback_add = callback_add self.callback_add = callback_add
self.callback_remove = callback_remove self.callback_remove = callback_remove
self.browser = ServiceBrowser(self.zeroconf, '_airdrop._tcp.local.', self) self.browser = ServiceBrowser(self.zeroconf, "_airdrop._tcp.local.", self)
def stop(self): def stop(self):
self.browser.cancel() self.browser.cancel()
self.browser = None self.browser = None
self.zeroconf.close() self.zeroconf.close()
def add_service(self, zeroconf, type, name): def add_service(self, zeroconf, service_type, name):
info = zeroconf.get_service_info(type, name) info = zeroconf.get_service_info(service_type, name)
logger.debug('Add service {}'.format(name)) logger.debug(f"Add service {name}")
if self.callback_add is not None: if self.callback_add is not None:
self.callback_add(info) self.callback_add(info)
def remove_service(self, zeroconf, type, name): def remove_service(self, zeroconf, service_type, name):
info = zeroconf.get_service_info(type, name) info = zeroconf.get_service_info(service_type, name)
logger.debug('Remove service {}'.format(name)) logger.debug(f"Remove service {name}")
if self.callback_remove is not None: if self.callback_remove is not None:
self.callback_remove(info) self.callback_remove(info)
@@ -89,9 +94,11 @@ class AirDropClient:
self.http_conn = None self.http_conn = None
def send_POST(self, url, body, headers=None): def send_POST(self, url, body, headers=None):
logger.debug('Send {} request'.format(url)) logger.debug(f"Send {url} request")
AirDropUtil.write_debug(self.config, body, 'send_{}_request.plist'.format(url.lower().strip('/'))) AirDropUtil.write_debug(
self.config, body, f"send_{url.lower().strip('/')}_request.plist"
)
_headers = self._get_headers() _headers = self._get_headers()
if headers is not None: if headers is not None:
@@ -99,98 +106,116 @@ class AirDropClient:
_headers[key] = val _headers[key] = val
if self.http_conn is None: if self.http_conn is None:
# Use single connection # Use single connection
self.http_conn = HTTPSConnectionAWDL(self.receiver_host, self.http_conn = HTTPSConnectionAWDL(
self.receiver_port, self.receiver_host,
interface_name=self.config.interface, self.receiver_port,
context=self.config.get_ssl_context()) interface_name=self.config.interface,
self.http_conn.request('POST', url, body=body, headers=_headers) context=self.config.get_ssl_context(),
)
self.http_conn.request("POST", url, body=body, headers=_headers)
http_resp = self.http_conn.getresponse() http_resp = self.http_conn.getresponse()
response_bytes = http_resp.read() response_bytes = http_resp.read()
AirDropUtil.write_debug(self.config, response_bytes, 'send_{}_response.plist'.format(url.lower().strip('/'))) AirDropUtil.write_debug(
self.config,
response_bytes,
f"send_{url.lower().strip('/')}_response.plist",
)
if http_resp.status != 200: if http_resp.status != 200:
status = False status = False
logger.debug('{} request failed: {}'.format(url, http_resp.status)) logger.debug(f"{url} request failed: {http_resp.status}")
else: else:
status = True status = True
logger.debug('{} request successful'.format(url)) logger.debug(f"{url} request successful")
return status, response_bytes return status, response_bytes
def send_discover(self): def send_discover(self):
discover_body = {} discover_body = {}
if self.config.record_data: if self.config.record_data:
discover_body['SenderRecordData'] = self.config.record_data discover_body["SenderRecordData"] = self.config.record_data
discover_plist_binary = plistlib.dumps(discover_body, fmt=plistlib.FMT_BINARY) discover_plist_binary = plistlib.dumps(
success, response_bytes = self.send_POST('/Discover', discover_plist_binary) discover_body, fmt=plistlib.FMT_BINARY # pylint: disable=no-member
)
_, response_bytes = self.send_POST("/Discover", discover_plist_binary)
response = plistlib.loads(response_bytes) response = plistlib.loads(response_bytes)
# if name is returned, then receiver is discoverable # if name is returned, then receiver is discoverable
return response.get('ReceiverComputerName') return response.get("ReceiverComputerName")
def send_ask(self, file_path, icon=None): def send_ask(self, file_path, is_url=False, icon=None):
ask_body = { ask_body = {
'SenderComputerName': self.config.computer_name, "SenderComputerName": self.config.computer_name,
'BundleID': 'com.apple.finder', "BundleID": "com.apple.finder",
'SenderModelName': self.config.computer_model, "SenderModelName": self.config.computer_model,
'SenderID': self.config.service_id, "SenderID": self.config.service_id,
'ConvertMediaFormats': False, "ConvertMediaFormats": False,
} }
if self.config.record_data: if self.config.record_data:
ask_body['SenderRecordData'] = self.config.record_data ask_body["SenderRecordData"] = self.config.record_data
if isinstance(file_path, str):
file_path = [file_path]
# generate icon for first file
with open(file_path[0], 'rb') as f:
file_header = f.read(128)
flp = fleep.get(file_header)
if not icon and len(flp.mime) > 0 and 'image' in flp.mime[0]:
icon = AirDropUtil.generate_file_icon(f.name)
if icon:
ask_body['FileIcon'] = icon
def file_entries(files): def file_entries(files):
for file in files: for file in files:
file_name = os.path.basename(file) file_name = os.path.basename(file)
file_entry = { file_entry = {
'FileName': file_name, "FileName": file_name,
'FileType': AirDropUtil.get_uti_type(flp), "FileType": AirDropUtil.get_uti_type(flp),
'FileBomPath': os.path.join('.', file_name), "FileBomPath": os.path.join(".", file_name),
'FileIsDirectory': os.path.isdir(file_name), "FileIsDirectory": os.path.isdir(file_name),
'ConvertMediaFormats': 0 "ConvertMediaFormats": 0,
} }
yield file_entry yield file_entry
ask_body['Files'] = [e for e in file_entries(file_path)] if isinstance(file_path, str):
ask_body['Items'] = [] file_path = [file_path]
if is_url:
ask_body["Items"] = file_path
else:
# generate icon for first file
with open(file_path[0], "rb") as f:
file_header = f.read(128)
flp = fleep.get(file_header)
if not icon and len(flp.mime) > 0 and "image" in flp.mime[0]:
icon = AirDropUtil.generate_file_icon(f.name)
ask_body["Files"] = [e for e in file_entries(file_path)]
if icon:
ask_body["FileIcon"] = icon
ask_binary = plistlib.dumps(ask_body, fmt=plistlib.FMT_BINARY) ask_binary = plistlib.dumps(
success, _ = self.send_POST('/Ask', ask_binary) ask_body, fmt=plistlib.FMT_BINARY # pylint: disable=no-member
)
success, _ = self.send_POST("/Ask", ask_binary)
return success return success
def send_upload(self, file_path): def send_upload(self, file_path, is_url=False):
""" """
Send a file to a receiver. Send a file to a receiver.
""" """
# Don't send an upload request if we just sent a link
if is_url:
return
headers = { headers = {
'Content-Type': 'application/x-cpio', "Content-Type": "application/x-cpio",
} }
# Create archive in memory ... # Create archive in memory ...
stream = io.BytesIO() stream = io.BytesIO()
with libarchive.custom_writer(stream.write, 'cpio', filter_name='gzip', with libarchive.custom_writer(
archive_write_class=AbsArchiveWrite) as archive: stream.write,
"cpio",
filter_name="gzip",
archive_write_class=AbsArchiveWrite,
) as archive:
for f in [file_path]: for f in [file_path]:
ff = os.path.basename(f) ff = os.path.basename(f)
archive.add_abs_file(f, os.path.join('.', ff)) archive.add_abs_file(f, os.path.join(".", ff))
stream.seek(0) stream.seek(0)
# ... then send in chunked mode # ... then send in chunked mode
success, _ = self.send_POST('/Upload', stream, headers=headers) success, _ = self.send_POST("/Upload", stream, headers=headers)
# TODO better: write archive chunk whenever send_POST does a read to avoid having the whole archive in memory # TODO better: write archive chunk whenever send_POST does a read to avoid having the whole archive in memory
@@ -201,12 +226,12 @@ class AirDropClient:
Get the headers for requests sent Get the headers for requests sent
""" """
headers = { headers = {
'Content-Type': 'application/octet-stream', "Content-Type": "application/octet-stream",
'Connection': 'keep-alive', "Connection": "keep-alive",
'Accept': '*/*', "Accept": "*/*",
'User-Agent': 'AirDrop/1.0', "User-Agent": "AirDrop/1.0",
'Accept-Language': 'en-us', "Accept-Language": "en-us",
'Accept-Encoding': 'br, gzip, deflate' "Accept-Encoding": "br, gzip, deflate",
} }
return headers return headers
@@ -215,39 +240,46 @@ class HTTPSConnectionAWDL(HTTPSConnection):
""" """
This class allows to bind the HTTPConnection to a specific network interface This class allows to bind the HTTPConnection to a specific network interface
""" """
def __init__(self,
host, def __init__(
port=None, self,
key_file=None, host,
cert_file=None, port=None,
timeout=None, key_file=None,
source_address=None, cert_file=None,
*, timeout=None,
context=None, source_address=None,
check_hostname=None, *,
interface_name=None): context=None,
check_hostname=None,
interface_name=None,
):
if interface_name is not None: if interface_name is not None:
if '%' not in host: if "%" not in host:
if isinstance(ipaddress.ip_address(host), ipaddress.IPv6Address): if isinstance(ipaddress.ip_address(host), ipaddress.IPv6Address):
host = host + '%' + interface_name host = host + "%" + interface_name
if timeout is None: if timeout is None:
timeout = socket.getdefaulttimeout() timeout = socket.getdefaulttimeout()
super(HTTPSConnectionAWDL, self).__init__(host=host, super(HTTPSConnectionAWDL, self).__init__(
port=port, host=host,
key_file=key_file, port=port,
cert_file=cert_file, key_file=key_file,
timeout=timeout, cert_file=cert_file,
source_address=source_address, timeout=timeout,
context=context, source_address=source_address,
check_hostname=check_hostname) context=context,
check_hostname=check_hostname,
)
self.interface_name = interface_name self.interface_name = interface_name
self._create_connection = self.create_connection_awdl self._create_connection = self.create_connection_awdl
def create_connection_awdl(self, address, timeout=socket.getdefaulttimeout(), source_address=None): def create_connection_awdl(
self, address, timeout=socket.getdefaulttimeout(), source_address=None
):
"""Connect to *address* and return the socket object. """Connect to *address* and return the socket object.
Convenience function. Connect to *address* (a 2-tuple ``(host, Convenience function. Connect to *address* (a 2-tuple ``(host,
@@ -263,13 +295,13 @@ class HTTPSConnectionAWDL(HTTPSConnection):
host, port = address host, port = address
err = None err = None
for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM): for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM):
af, socktype, proto, canonname, sa = res af, socktype, proto, _, sa = res
sock = None sock = None
try: try:
sock = socket.socket(af, socktype, proto) sock = socket.socket(af, socktype, proto)
if timeout is not socket.getdefaulttimeout(): if timeout is not socket.getdefaulttimeout():
sock.settimeout(timeout) sock.settimeout(timeout)
if self.interface_name == 'awdl0' and platform.system() == 'Darwin': if self.interface_name == "awdl0" and platform.system() == "Darwin":
sock.setsockopt(socket.SOL_SOCKET, 0x1104, 1) sock.setsockopt(socket.SOL_SOCKET, 0x1104, 1)
if source_address: if source_address:
sock.bind(source_address) sock.bind(source_address)
@@ -285,4 +317,4 @@ class HTTPSConnectionAWDL(HTTPSConnection):
if err is not None: if err is not None:
raise err raise err
else: else:
raise socket.error('getaddrinfo returns an empty list') raise socket.error("getaddrinfo returns an empty list")
+74 -35
View File
@@ -34,7 +34,9 @@ class AirDropReceiverFlags:
Recovered from sharingd`receiverSupportsX methods. Recovered from sharingd`receiverSupportsX methods.
A valid node needs to either have SUPPORTS_PIPELINING or SUPPORTS_MIXED_TYPES A valid node needs to either have SUPPORTS_PIPELINING or SUPPORTS_MIXED_TYPES
according to sharingd`[SDBonjourBrowser removeInvalidNodes:]. according to sharingd`[SDBonjourBrowser removeInvalidNodes:].
Default flags on macOS: 0x3fb according to sharingd`[SDRapportBrowser defaultSFNodeFlags]
""" """
SUPPORTS_URL = 0x01 SUPPORTS_URL = 0x01
SUPPORTS_DVZIP = 0x02 SUPPORTS_DVZIP = 0x02
SUPPORTS_PIPELINING = 0x04 SUPPORTS_PIPELINING = 0x04
@@ -42,24 +44,30 @@ class AirDropReceiverFlags:
SUPPORTS_UNKNOWN1 = 0x10 SUPPORTS_UNKNOWN1 = 0x10
SUPPORTS_UNKNOWN2 = 0x20 SUPPORTS_UNKNOWN2 = 0x20
SUPPORTS_IRIS = 0x40 SUPPORTS_IRIS = 0x40
SUPPORTS_DISCOVER_MAYBE = 0x80 # Probably indicates that server supports /Discover URL SUPPORTS_DISCOVER_MAYBE = (
0x80 # Probably indicates that server supports /Discover URL
)
SUPPORTS_UNKNOWN3 = 0x100
SUPPORTS_ASSET_BUNDLE = 0x200
class AirDropConfig: class AirDropConfig:
def __init__(self, def __init__(
host_name=None, self,
computer_name=None, host_name=None,
computer_model=None, computer_name=None,
server_port=8771, computer_model=None,
airdrop_dir='~/.opendrop', server_port=8771,
service_id=None, airdrop_dir="~/.opendrop",
email=None, service_id=None,
phone=None, email=None,
debug=False, phone=None,
interface=None): debug=False,
interface=None,
):
self.airdrop_dir = os.path.expanduser(airdrop_dir) self.airdrop_dir = os.path.expanduser(airdrop_dir)
self.discovery_report = os.path.join(self.airdrop_dir, 'discover.last.json') self.discovery_report = os.path.join(self.airdrop_dir, "discover.last.json")
if host_name is None: if host_name is None:
host_name = socket.gethostname() host_name = socket.gethostname()
@@ -68,19 +76,19 @@ class AirDropConfig:
computer_name = host_name computer_name = host_name
self.computer_name = computer_name self.computer_name = computer_name
if computer_model is None: if computer_model is None:
computer_model = 'OpenDrop' computer_model = "OpenDrop"
self.computer_model = computer_model self.computer_model = computer_model
self.port = server_port self.port = server_port
if service_id is None: if service_id is None:
service_id = '{0:0{1}x}'.format(random.randint(0, 0xffffffffffff), 12) # random 6-byte string in base16 service_id = f"{random.randint(0, 0xFFFFFFFFFFFF):012x}" # random 6-byte string in base16
self.service_id = service_id self.service_id = service_id
self.debug = debug self.debug = debug
self.debug_dir = os.path.join(self.airdrop_dir, 'debug') self.debug_dir = os.path.join(self.airdrop_dir, "debug")
if interface is None: if interface is None:
interface = 'awdl0' interface = "awdl0"
self.interface = interface self.interface = interface
if email is None: if email is None:
@@ -91,39 +99,70 @@ class AirDropConfig:
self.phone = phone self.phone = phone
# Bare minimum, we currently do not support anything else # Bare minimum, we currently do not support anything else
self.flags = AirDropReceiverFlags.SUPPORTS_MIXED_TYPES | AirDropReceiverFlags.SUPPORTS_DISCOVER_MAYBE self.flags = (
AirDropReceiverFlags.SUPPORTS_MIXED_TYPES
| AirDropReceiverFlags.SUPPORTS_DISCOVER_MAYBE
)
self.root_ca_file = resource_filename('opendrop', 'certs/apple_root_ca.pem') self.root_ca_file = resource_filename("opendrop", "certs/apple_root_ca.pem")
if not os.path.exists(self.root_ca_file): if not os.path.exists(self.root_ca_file):
raise FileNotFoundError('Need Apple root CA certificate: {}'.format(self.root_ca_file)) raise FileNotFoundError(
f"Need Apple root CA certificate: {self.root_ca_file}"
)
self.key_dir = os.path.join(self.airdrop_dir, 'keys') self.key_dir = os.path.join(self.airdrop_dir, "keys")
self.cert_file = os.path.join(self.key_dir, 'certificate.pem') self.cert_file = os.path.join(self.key_dir, "certificate.pem")
self.key_file = os.path.join(self.key_dir, 'key.pem') self.key_file = os.path.join(self.key_dir, "key.pem")
if not os.path.exists(self.cert_file) or not os.path.exists(self.key_file): if not os.path.exists(self.cert_file) or not os.path.exists(self.key_file):
logger.info('Key file or certificate does not exist') logger.info("Key file or certificate does not exist")
self.create_default_key() self.create_default_key()
# TODO extract record data from a sample exchange self.record_file = os.path.join(self.key_dir, "validation_record.cms")
self.record_data = None self.record_data = None
if os.path.exists(self.record_file):
logger.debug("Using provided Apple ID Validation Record")
with open(self.record_file, "rb") as f:
self.record_data = f.read()
else:
logger.debug("No Apple ID Validation Record found")
def create_default_key(self): def create_default_key(self):
logger.info('Create new self-signed certificate in {}'.format(self.key_dir)) logger.info(f"Create new self-signed certificate in {self.key_dir}")
if not os.path.exists(self.key_dir): if not os.path.exists(self.key_dir):
os.makedirs(self.key_dir) os.makedirs(self.key_dir)
subprocess.run([ subprocess.run(
'openssl', 'req', '-newkey', 'rsa:2048', '-nodes', '-keyout', 'key.pem', '-x509', '-days', '365', '-out', [
'certificate.pem', '-subj', '/CN={}'.format(self.computer_name) "openssl",
], "req",
cwd=self.key_dir, "-newkey",
stdout=subprocess.PIPE, "rsa:2048",
stderr=subprocess.PIPE) "-nodes",
"-keyout",
"key.pem",
"-x509",
"-days",
"365",
"-out",
"certificate.pem",
"-subj",
f"/CN={self.computer_name}",
],
cwd=self.key_dir,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
def get_ssl_context(self): def get_ssl_context(self):
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS)
ctx = ssl.SSLContext( # lgtm[py/insecure-protocol], TODO see https://github.com/Semmle/ql/issues/2554
ssl.PROTOCOL_TLS
)
ctx.options |= ssl.OP_NO_TLSv1 # TLSv1.0 is insecure ctx.options |= ssl.OP_NO_TLSv1 # TLSv1.0 is insecure
ctx.load_cert_chain(self.cert_file, keyfile=self.key_file) ctx.load_cert_chain(self.cert_file, keyfile=self.key_file)
ctx.load_verify_locations(cafile=self.root_ca_file) ctx.load_verify_locations(cafile=self.root_ca_file)
ctx.verify_mode = ssl.CERT_NONE # we accept self-signed certificates as does Apple ctx.verify_mode = (
ssl.CERT_NONE
) # we accept self-signed certificates as does Apple
return ctx return ctx
+105 -70
View File
@@ -23,14 +23,14 @@ import platform
import plistlib import plistlib
import socket import socket
import time import time
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import BaseHTTPRequestHandler, HTTPServer
import libarchive import libarchive
import libarchive.extract import libarchive.extract
import libarchive.read import libarchive.read
from zeroconf import IPVersion, ServiceInfo, Zeroconf
from .util import AirDropUtil from .util import AirDropUtil
from zeroconf import Zeroconf, ServiceInfo, IPVersion
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -39,47 +39,58 @@ class AirDropServer:
""" """
Announces an HTTPS AirDrop server in the local network via mDNS. Announces an HTTPS AirDrop server in the local network via mDNS.
""" """
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
# Use IPv6 # Use IPv6
self.serveraddress = ('::', self.config.port) self.serveraddress = ("::", self.config.port)
self.ServerClass = HTTPServerV6 self.ServerClass = HTTPServerV6
self.ServerClass.allow_reuse_address = False self.ServerClass.allow_reuse_address = False
self.ip_addr = AirDropUtil.get_ip_for_interface(self.config.interface, ipv6=True) self.ip_addr = AirDropUtil.get_ip_for_interface(
self.config.interface, ipv6=True
)
if self.ip_addr is None: if self.ip_addr is None:
if self.config.interface == 'awdl0': if self.config.interface == "awdl0":
raise RuntimeError('Interface {} does not have an IPv6 address. ' raise RuntimeError(
'Make sure that `owl` is running.'.format(self.config.interface)) f"Interface {self.config.interface} does not have an IPv6 address. Make sure that `owl` is running."
)
else: else:
raise RuntimeError('Interface {} does not have an IPv6 address'.format(self.config.interface)) raise RuntimeError(
f"Interface {self.config.interface} does not have an IPv6 address"
)
self.Handler = AirDropServerHandler self.Handler = AirDropServerHandler
self.Handler.config = self.config self.Handler.config = self.config
self.zeroconf = Zeroconf(interfaces=[str(self.ip_addr)], self.zeroconf = Zeroconf(
ip_version=IPVersion.V6Only, interfaces=[str(self.ip_addr)],
apple_p2p=platform.system() == 'Darwin') ip_version=IPVersion.V6Only,
apple_p2p=platform.system() == "Darwin",
)
self.http_server = self._init_server() self.http_server = self._init_server()
self.service_info = self._init_service() self.service_info = self._init_service()
def _init_service(self): def _init_service(self):
properties = self.get_properties() properties = self.get_properties()
server = self.config.host_name + '.local.' server = self.config.host_name + ".local."
service_name = self.config.service_id + '._airdrop._tcp.local.' service_name = self.config.service_id + "._airdrop._tcp.local."
info = ServiceInfo('_airdrop._tcp.local.', info = ServiceInfo(
service_name, "_airdrop._tcp.local.",
port=self.config.port, service_name,
properties=properties, port=self.config.port,
server=server, properties=properties,
addresses=[self.ip_addr.packed]) server=server,
addresses=[self.ip_addr.packed],
)
return info return info
def start_service(self): def start_service(self):
logger.info('Announcing service: host {}, address {}, port {}'.format(self.config.host_name, self.ip_addr, logger.info(
self.config.port)) f"Announcing service: host {self.config.host_name}, address {self.ip_addr}, port {self.config.port}"
)
self.zeroconf.register_service(self.service_info) self.zeroconf.register_service(self.service_info)
def _init_server(self): def _init_server(self):
@@ -92,15 +103,17 @@ class AirDropServer:
httpd = self.ServerClass(self.serveraddress, self.Handler) httpd = self.ServerClass(self.serveraddress, self.Handler)
# Adapt socket for awdl0 # Adapt socket for awdl0
if self.config.interface == 'awdl0' and platform.system() == 'Darwin': if self.config.interface == "awdl0" and platform.system() == "Darwin":
httpd.socket.setsockopt(socket.SOL_SOCKET, 0x1104, 1) httpd.socket.setsockopt(socket.SOL_SOCKET, 0x1104, 1)
httpd.socket = self.config.get_ssl_context().wrap_socket(sock=httpd.socket, server_side=True) httpd.socket = self.config.get_ssl_context().wrap_socket(
sock=httpd.socket, server_side=True
)
return httpd return httpd
def start_server(self): def start_server(self):
logger.info('Starting HTTPS server') logger.info("Starting HTTPS server")
self.http_server.serve_forever() self.http_server.serve_forever()
def stop(self): def stop(self):
@@ -108,7 +121,7 @@ class AirDropServer:
self.http_server.shutdown() self.http_server.shutdown()
def get_properties(self): def get_properties(self):
properties = {b'flags': str(self.config.flags).encode('utf-8')} properties = {b"flags": str(self.config.flags).encode("utf-8")}
return properties return properties
@@ -120,7 +133,8 @@ class AirDropServerHandler(BaseHTTPRequestHandler):
""" """
Server which responds to AirDrop HTTP POST requests Server which responds to AirDrop HTTP POST requests
""" """
protocol_version = 'HTTP/1.1'
protocol_version = "HTTP/1.1"
config = None config = None
def _set_response(self, content_length): def _set_response(self, content_length):
@@ -128,7 +142,7 @@ class AirDropServerHandler(BaseHTTPRequestHandler):
Setting the default values for a successful response Setting the default values for a successful response
""" """
self.send_response(200) self.send_response(200)
self.send_header('Content-Length', content_length) self.send_header("Content-Length", content_length)
self.end_headers() self.end_headers()
def do_HEAD(self): def do_HEAD(self):
@@ -136,27 +150,29 @@ class AirDropServerHandler(BaseHTTPRequestHandler):
Answer head requests Answer head requests
""" """
self.send_response(200) self.send_response(200)
self.send_header('Content-type', 'text/html') self.send_header("Content-type", "text/html")
self.end_headers() self.end_headers()
def do_GET(self): def do_GET(self):
""" """
Answer get requests Answer get requests
""" """
logger.debug('GET request at {}'.format(self.path)) logger.debug(f"GET request at {self.path}")
body = '\n'.encode('utf-8') body = "\n".encode("utf-8")
self._set_response(len(body)) self._set_response(len(body))
self.wfile.write(body) self.wfile.write(body)
def handle_discover(self): def handle_discover(self):
content_length = int(self.headers['Content-Length']) content_length = int(self.headers["Content-Length"])
post_data = self.rfile.read(content_length) post_data = self.rfile.read(content_length)
AirDropUtil.write_debug(self.config, post_data, 'receive_discover_request.plist') AirDropUtil.write_debug(
self.config, post_data, "receive_discover_request.plist"
)
# sample media capabilities as recorded from macOS 10.13.3 # sample media capabilities as recorded from macOS 10.13.3
media_capabilities = { media_capabilities = {
'Version': 1, "Version": 1,
# don't advertise any codec/container support so we receive legacy file formats (JPEG instead of HEIF, etc.) # don't advertise any codec/container support so we receive legacy file formats (JPEG instead of HEIF, etc.)
# 'Codecs': { # 'Codecs': {
# 'hvc1': { # 'hvc1': {
@@ -184,59 +200,72 @@ class AirDropServerHandler(BaseHTTPRequestHandler):
# } # }
} }
media_capabilities_json = json.JSONEncoder().encode(media_capabilities) media_capabilities_json = json.JSONEncoder().encode(media_capabilities)
media_capabilities_binary = media_capabilities_json.encode('utf-8') media_capabilities_binary = media_capabilities_json.encode("utf-8")
discover_answer = { discover_answer = {
'ReceiverMediaCapabilities': media_capabilities_binary, "ReceiverMediaCapabilities": media_capabilities_binary,
'ReceiverComputerName': self.config.computer_name, "ReceiverComputerName": self.config.computer_name,
'ReceiverModelName': self.config.computer_model, "ReceiverModelName": self.config.computer_model,
} }
if self.config.record_data: if self.config.record_data:
discover_answer['ReceiverRecordData'] = self.config.record_data discover_answer["ReceiverRecordData"] = self.config.record_data
discover_answer_binary = plistlib.dumps(discover_answer, fmt=plistlib.FMT_BINARY) discover_answer_binary = plistlib.dumps(
discover_answer, fmt=plistlib.FMT_BINARY # pylint: disable=no-member
)
AirDropUtil.write_debug(self.config, discover_answer_binary, 'receive_discover_response.plist') AirDropUtil.write_debug(
self.config, discover_answer_binary, "receive_discover_response.plist"
)
# Change to actual length # Change to actual length
self._set_response(len(discover_answer_binary)) self._set_response(len(discover_answer_binary))
self.wfile.write(discover_answer_binary) self.wfile.write(discover_answer_binary)
def handle_ask(self): def handle_ask(self):
content_length = int(self.headers['Content-Length']) content_length = int(self.headers["Content-Length"])
post_data = self.rfile.read(content_length) post_data = self.rfile.read(content_length)
AirDropUtil.write_debug(self.config, post_data, 'receive_ask_request.plist') AirDropUtil.write_debug(self.config, post_data, "receive_ask_request.plist")
ask_response = {'ReceiverModelName': self.config.computer_model, 'ReceiverComputerName': self.config.computer_name} ask_response = {
ask_resp_binary = plistlib.dumps(ask_response, fmt=plistlib.FMT_BINARY) "ReceiverModelName": self.config.computer_model,
"ReceiverComputerName": self.config.computer_name,
}
ask_resp_binary = plistlib.dumps(
ask_response, fmt=plistlib.FMT_BINARY # pylint: disable=no-member
)
AirDropUtil.write_debug(self.config, ask_resp_binary, 'receive_ask_response.plist') AirDropUtil.write_debug(
self.config, ask_resp_binary, "receive_ask_response.plist"
)
self._set_response(len(ask_resp_binary)) self._set_response(len(ask_resp_binary))
self.wfile.write(ask_resp_binary) self.wfile.write(ask_resp_binary)
def handle_upload(self): def handle_upload(self):
if self.headers.get('content-type', '').lower() != 'application/x-cpio': if self.headers.get("content-type", "").lower() != "application/x-cpio":
logger.warning('Unsupported content-type: {}'.format(self.headers.get('content-type'))) logger.warning(
f"Unsupported content-type: {self.headers.get('content-type')}"
)
self.send_response(406) # Unprocessable Entity self.send_response(406) # Unprocessable Entity
self.send_header('Content-Type', 'application/x-cpio') self.send_header("Content-Type", "application/x-cpio")
self.send_header('Content-Length', 0) self.send_header("Content-Length", 0)
self.send_header('Connection', 'close') self.send_header("Connection", "close")
self.end_headers() self.end_headers()
return return
# If pipelining is not support, 'Expect: 100-continue' is sent to which we need to respond # If pipelining is not support, 'Expect: 100-continue' is sent to which we need to respond
if self.headers.get('expect', '').lower() == '100-continue': if self.headers.get("expect", "").lower() == "100-continue":
self.send_response(100) self.send_response(100)
self.send_header('Content-Length', 0) self.send_header("Content-Length", 0)
self.end_headers() self.end_headers()
if self.headers.get('transfer-encoding', '').lower() != 'chunked': if self.headers.get("transfer-encoding", "").lower() != "chunked":
logger.warning('Expect chunked transfer encoding') logger.warning("Expect chunked transfer encoding")
self.send_response(400) # Bad Request self.send_response(400) # Bad Request
self.send_header('Transfer-Encoding', 'Chunked') self.send_header("Transfer-Encoding", "Chunked")
self.send_header('Content-Length', 0) self.send_header("Content-Length", 0)
self.send_header('Connection', 'close') self.send_header("Connection", "close")
self.end_headers() self.end_headers()
return return
@@ -269,18 +298,20 @@ class AirDropServerHandler(BaseHTTPRequestHandler):
with libarchive.read.stream_reader(stream) as archive: with libarchive.read.stream_reader(stream) as archive:
libarchive.extract.extract_entries(archive, flags) libarchive.extract.extract_entries(archive, flags)
logger.info('Receiving file(s) ...') logger.info("Receiving file(s) ...")
start = time.time() start = time.time()
reader = HTTPChunkedReader(self.rfile) reader = HTTPChunkedReader(self.rfile)
extract_stream(reader) extract_stream(reader)
transferred = reader.total / 1024.0 / 1024.0 transferred = reader.total / 1024.0 / 1024.0
speed = transferred / (time.time() - start) speed = transferred / (time.time() - start)
logger.info('File(s) received (size {:.02f} MB, speed {:.02f} MB/s)'.format(transferred, speed)) logger.info(
f"File(s) received (size {transferred:.02f} MB, speed {speed:.02f} MB/s)"
)
self.send_response(200) self.send_response(200)
self.send_header('Content-Length', 0) self.send_header("Content-Length", 0)
self.send_header('Connection', 'close') self.send_header("Connection", "close")
self.end_headers() self.end_headers()
def do_POST(self): def do_POST(self):
@@ -288,19 +319,23 @@ class AirDropServerHandler(BaseHTTPRequestHandler):
Handle post requests Handle post requests
""" """
logger.debug('POST request at {}'.format(self.path)) logger.debug(f"POST request at {self.path}")
logger.debug('Headers\n{}'.format(self.headers)) logger.debug(f"Headers\n{self.headers}")
if self.path == '/Discover': if self.path == "/Discover":
self.handle_discover() self.handle_discover()
elif self.path == '/Ask': elif self.path == "/Ask":
self.handle_ask() self.handle_ask()
elif self.path == '/Upload': elif self.path == "/Upload":
self.handle_upload() self.handle_upload()
else: else:
answer = 'POST request for {}'.format(self.path).encode('utf-8') logger.debug(f"POST request at {self.path}")
self._set_response(len(answer)) self.send_response(400)
self.wfile.write(answer) self.send_header("Content-Length", 0)
self.end_headers()
def log_message(self, format, *args): def log_message(self, format, *args):
logger.debug('{} - - [{}] {}'.format(self.client_address[0], self.log_date_time_string(), format % args)) # pylint: disable=redefined-builtin
logger.debug(
f"{self.client_address[0]} - - [{self.log_date_time_string()}] {format % args}"
)
+51 -107
View File
@@ -17,30 +17,25 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
import base64
import datetime
import hashlib
import io import io
import ipaddress import ipaddress
import os import os
import plistlib
import ifaddr import ifaddr
from PIL import Image, ExifTags from libarchive.entry import ArchiveEntry, new_archive_entry
from ctypescrypto import cms, x509, pkey, oid from libarchive.ffi import ( # pylint: disable=no-name-in-module
from libarchive import ffi
from libarchive.entry import new_archive_entry, ArchiveEntry
from libarchive.ffi import (
ARCHIVE_EOF, ARCHIVE_EOF,
entry_sourcepath,
entry_clear, entry_clear,
read_next_header2, entry_sourcepath,
read_disk_descend, read_disk_descend,
write_header, read_next_header2,
write_data, write_data,
write_finish_entry, write_finish_entry,
write_get_bytes_per_block,
write_header,
) )
from libarchive.write import ArchiveWrite, new_archive_read_disk from libarchive.write import ArchiveWrite, new_archive_read_disk
from PIL import ExifTags, Image
class AirDropUtil: class AirDropUtil:
@@ -48,6 +43,7 @@ class AirDropUtil:
This class contains a set of utility functions that support the opendrop implementation This class contains a set of utility functions that support the opendrop implementation
They have been moved, because the opendrop files tend to get too long They have been moved, because the opendrop files tend to get too long
""" """
@staticmethod @staticmethod
def get_uti_type(flp) -> str: def get_uti_type(flp) -> str:
""" """
@@ -57,98 +53,39 @@ class AirDropUtil:
""" """
# Default UTI Type # Default UTI Type
uti_type = 'public.content' uti_type = "public.content"
if len(flp.mime) == 0 or len(flp.type) == 0: if len(flp.mime) == 0 or len(flp.type) == 0:
return uti_type return uti_type
mime = flp.mime[0] mime = flp.mime[0]
f_type = flp.type[0] f_type = flp.type[0]
if 'image' in mime: if "image" in mime:
uti_type = 'public.image' uti_type = "public.image"
if 'jpg' in mime: if "jpg" in mime:
uti_type = 'public.jpeg' uti_type = "public.jpeg"
elif 'jp2' in mime: elif "jp2" in mime:
uti_type = 'public.jpeg-2000' uti_type = "public.jpeg-2000"
elif 'gif' in mime: elif "gif" in mime:
uti_type = 'com.compuserve.gif' uti_type = "com.compuserve.gif"
elif 'png' in mime: elif "png" in mime:
uti_type = 'public.png' uti_type = "public.png"
elif 'raw' in mime or 'raw' in f_type: elif "raw" in mime or "raw" in f_type:
uti_type = 'public.camera-raw-image' uti_type = "public.camera-raw-image"
elif 'audio' in f_type: elif "audio" in f_type:
uti_type = 'public.audio' uti_type = "public.audio"
elif 'video' in f_type: elif "video" in f_type:
uti_type = 'public.video' uti_type = "public.video"
elif 'archive' in f_type: elif "archive" in f_type:
uti_type = 'public.data' uti_type = "public.data"
if 'gzip' in mime: if "gzip" in mime:
uti_type = 'org.gnu.gnu-zip-archive' uti_type = "org.gnu.gnu-zip-archive"
if 'zip' in mime: if "zip" in mime:
uti_type = 'public.zip-archive' uti_type = "public.zip-archive"
return uti_type return uti_type
@staticmethod
def record_data(config, tls_cert, sign_cert, key):
"""
This method generates the sender record data and will sign it using the CMS format.
This code serves documentation purposes only and is UNTESTED. To be accepted by Apple clients, we would need the
Apple-owned private key of the signing certificate.
:param tls_cert: path to certificate used for AirDrop TLS connections
:param sign_cert: path to signing certificate
:param key: path to private key to the signing certificate
"""
valid_date = datetime.datetime.now() - datetime.timedelta(days=3)
valid_date_string = valid_date.strftime('%Y-%m-%dT%H:%M:%SZ')
emails_hashed = [hashlib.sha256(email.encode('utf-8')).hexdigest() for email in config.email]
phone_numbers_hashed = [hashlib.sha256(phone_number.encode('utf-8')).hexdigest() for phone_number in config.phone]
# Get the common name of the TLS certificate
with open(tls_cert, 'rb') as cert_file:
cert = x509.X509(cert_file.read())
cn = cert.subject[oid.Oid('2.5.4.3')]
encDsID = cn.replace('com.apple.idms.appleid.prd.', '')
# Construct record data
record_data = {
'Version': 2,
'encDsID': encDsID, # Common name suffix of the certificate
'altDsID': encDsID, # Same as encDsID
'SuggestValidDuration': 30 * 24 * 60 * 60, # in seconds
'ValidAsOf': valid_date_string, # 3 days before now
'ValidatedEmailHashes': emails_hashed,
'ValidatedPhoneHashes': phone_numbers_hashed,
}
record_data_plist = plistlib.dumps(record_data, fmt=plistlib.FMT_XML)
with open(sign_cert, 'rb') as sign_cert_file:
with open(key, 'rb') as key_file:
cert = x509.X509(sign_cert_file.read())
key = pkey.PKey(privkey=key_file.read())
# possibly need to add intermediate certs
cms_signed = cms.SignedData.create(record_data_plist, cert=cert, pkey=key, certs=None, flags=cms.Flags.PARTIAL)
signed_data = AirDropUtil.pem2der(cms_signed.pem())
return signed_data
@staticmethod
def pem2der(s):
"""
Create DER Formatted bytes from a PEM Base64 String
:param s: PEM formatted string
"""
start = s.find('-----\n')
finish = s.rfind('\n-----END')
data = s[start + 6:finish]
return base64.b64decode(data)
@staticmethod @staticmethod
def generate_file_icon(file_path): def generate_file_icon(file_path):
""" """
@@ -161,9 +98,13 @@ class AirDropUtil:
# rotate according to EXIF tags # rotate according to EXIF tags
try: try:
exif = dict((ExifTags.TAGS[k], v) for k, v in im._getexif().items() if k in ExifTags.TAGS) exif = dict(
(ExifTags.TAGS[k], v)
for k, v in im._getexif().items() # pylint: disable=protected-access
if k in ExifTags.TAGS
)
angles = {3: 180, 6: 270, 8: 90} angles = {3: 180, 6: 270, 8: 90}
orientation = exif['Orientation'] orientation = exif["Orientation"]
if orientation in angles.keys(): if orientation in angles.keys():
im = im.rotate(angles[orientation], expand=True) im = im.rotate(angles[orientation], expand=True)
except (AttributeError, KeyError): except (AttributeError, KeyError):
@@ -171,15 +112,15 @@ class AirDropUtil:
# Big image # Big image
im.thumbnail((540, 540), Image.ANTIALIAS) im.thumbnail((540, 540), Image.ANTIALIAS)
imgByteArr = io.BytesIO() img_bytes = io.BytesIO()
im.save(imgByteArr, format='JPEG2000') im.save(img_bytes, format="JPEG2000")
file_icon = imgByteArr.getvalue() file_icon = img_bytes.getvalue()
# Small image # Small image
# im.thumbnail((64, 64), Image.ANTIALIAS) # im.thumbnail((64, 64), Image.ANTIALIAS)
# imgByteArr = io.BytesIO() # img_bytes = io.BytesIO()
# im.save(imgByteArr, format='JPEG2000') # im.save(img_bytes, format='JPEG2000')
# small_file_icon = imgByteArr.getvalue() # small_file_icon = img_bytes.getvalue()
return file_icon return file_icon
@@ -192,6 +133,7 @@ class AirDropUtil:
:param bool ipv6: Boolean indicating if the ipv6 address should be retrieved :param bool ipv6: Boolean indicating if the ipv6 address should be retrieved
:return: IPv4Address or IPv6Address object or None :return: IPv4Address or IPv6Address object or None
""" """
def get_interface_by_name(name): def get_interface_by_name(name):
for interface in ifaddr.get_adapters(): for interface in ifaddr.get_adapters():
if interface.name == name: if interface.name == name:
@@ -204,7 +146,9 @@ class AirDropUtil:
for ip in interface.ips: for ip in interface.ips:
if ip.is_IPv6 and ipv6: if ip.is_IPv6 and ipv6:
return ipaddress.IPv6Address(ip.ip[0]) # first of (ip, flowinfo, scope_id) tuple return ipaddress.IPv6Address(
ip.ip[0]
) # first of (ip, flowinfo, scope_id) tuple
if ip.is_IPv4 and not ipv6: if ip.is_IPv4 and not ipv6:
return ipaddress.IPv4Address(ip.ip) return ipaddress.IPv4Address(ip.ip)
@@ -217,8 +161,8 @@ class AirDropUtil:
if not os.path.exists(config.debug_dir): if not os.path.exists(config.debug_dir):
os.makedirs(config.debug_dir) os.makedirs(config.debug_dir)
debug_file_path = os.path.join(config.debug_dir, file_name) debug_file_path = os.path.join(config.debug_dir, file_name)
with open(debug_file_path, 'wb') as file: with open(debug_file_path, "wb") as file:
if hasattr(data, 'read'): if hasattr(data, "read"):
file.write(data.read()) file.write(data.read())
data.seek(0) # reset cursor position data.seek(0) # reset cursor position
else: # assume bytes-like else: # assume bytes-like
@@ -232,7 +176,7 @@ class AbsArchiveWrite(ArchiveWrite):
""" """
write_p = self._pointer write_p = self._pointer
block_size = ffi.write_get_bytes_per_block(write_p) block_size = write_get_bytes_per_block(write_p)
if block_size <= 0: if block_size <= 0:
block_size = 10240 # pragma: no cover block_size = 10240 # pragma: no cover
@@ -247,7 +191,7 @@ class AbsArchiveWrite(ArchiveWrite):
read_disk_descend(read_p) read_disk_descend(read_p)
write_header(write_p, entry_p) write_header(write_p, entry_p)
try: try:
with open(entry_sourcepath(entry_p), 'rb') as f: with open(entry_sourcepath(entry_p), "rb") as f:
while True: while True:
data = f.read(block_size) data = f.read(block_size)
if not data: if not data:
+4 -1
View File
@@ -1,3 +1,6 @@
black
flake8 flake8
flake8-bugbear
isort
pylint
pytest pytest
yapf
+15 -4
View File
@@ -1,6 +1,17 @@
[flake8] [flake8]
max-line-length = 127 extend-ignore = E203, E501
max-line-length = 80
max-complexity = 18
select = B9
[yapf] [isort]
based_on_style = pep8 multi_line_output = 3
column_limit = 127 include_trailing_comma = True
force_grid_wrap = 0
use_parentheses = True
ensure_newline_before_comments = True
line_length = 88
[pylint]
disable = C, R, W0511, W1203
max-line-length = 88
+31 -23
View File
@@ -4,41 +4,49 @@ from os.path import abspath, dirname, join
from setuptools import find_packages, setup from setuptools import find_packages, setup
this_dir = abspath(dirname(__file__)) this_dir = abspath(dirname(__file__))
with open(join(this_dir, 'README.md'), encoding='utf-8') as file: with open(join(this_dir, "README.md"), encoding="utf-8") as file:
long_description = file.read() long_description = file.read()
setup( setup(
name='opendrop', name="opendrop",
version=__version__, version=__version__,
python_requires='>=3.6', python_requires=">=3.6",
description='An open Apple AirDrop implementation', description="An open Apple AirDrop implementation",
long_description=long_description, long_description=long_description,
long_description_content_type='text/markdown', long_description_content_type="text/markdown",
url='https://owlink.org', url="<<DISCLAIMER: The former owlink website is no longer associated with this project, please disregard it.>>",
project_urls={ project_urls={
'Source': 'https://github.com/seemoo-lab/opendrop', "Source": "https://github.com/seemoo-lab/opendrop",
'Research Paper': 'https://usenix.org/conference/usenixsecurity19/presentation/stute', "Research Paper": "https://usenix.org/conference/usenixsecurity19/presentation/stute",
}, },
author='The Open Wireless Link Project', author="The Open Wireless Link Project",
author_email='mstute@seemoo.tu-darmstadt.de', author_email="mstute@seemoo.tu-darmstadt.de",
classifiers=[ classifiers=[
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
'Operating System :: MacOS', "Operating System :: MacOS",
'Operating System :: POSIX :: Linux', "Operating System :: POSIX :: Linux",
'Natural Language :: English', "Natural Language :: English",
'Programming Language :: Python :: 3', "Programming Language :: Python :: 3",
'Programming Language :: Python :: 3.6', "Programming Language :: Python :: 3.6",
'Programming Language :: Python :: 3.7', "Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
], ],
keywords='cli', keywords="cli",
packages=find_packages(exclude=['docs']), packages=find_packages(exclude=["docs"]),
package_data={'opendrop': ['certs/*.pem']}, package_data={"opendrop": ["certs/*.pem"]},
install_requires=[ install_requires=[
'Pillow', 'ctypescrypto', 'fleep', 'ifaddr', 'libarchive-c', 'requests', 'requests_toolbelt', 'zeroconf>=0.24.2' "Pillow",
"fleep",
"ifaddr",
"libarchive-c",
"requests",
"requests_toolbelt",
"zeroconf>=0.24.2",
], ],
entry_points={ entry_points={
'console_scripts': [ "console_scripts": [
'opendrop=opendrop.cli:main', "opendrop=opendrop.cli:main",
], ],
}, },
) )
+3 -2
View File
@@ -4,15 +4,16 @@ from opendrop.config import AirDropConfig
def get_loopback(): def get_loopback():
import ifaddr import ifaddr
for adapter in ifaddr.get_adapters(): for adapter in ifaddr.get_adapters():
if adapter.name.startswith('lo'): if adapter.name.startswith("lo"):
return adapter.name return adapter.name
return None return None
def test_browser_setup(): def test_browser_setup():
loopback = get_loopback() loopback = get_loopback()
assert loopback is not None, 'Could not find loopback interface' assert loopback is not None, "Could not find loopback interface"
config = AirDropConfig(interface=loopback) config = AirDropConfig(interface=loopback)
browser = AirDropBrowser(config) browser = AirDropBrowser(config)
browser.start() browser.start()
+3 -2
View File
@@ -4,15 +4,16 @@ from opendrop.config import AirDropConfig
def get_loopback(): def get_loopback():
import ifaddr import ifaddr
for adapter in ifaddr.get_adapters(): for adapter in ifaddr.get_adapters():
if adapter.name.startswith('lo'): if adapter.name.startswith("lo"):
return adapter.name return adapter.name
return None return None
def test_server_setup(): def test_server_setup():
loopback = get_loopback() loopback = get_loopback()
assert loopback is not None, 'Could not find loopback interface' assert loopback is not None, "Could not find loopback interface"
config = AirDropConfig(interface=loopback) config = AirDropConfig(interface=loopback)
server = AirDropServer(config) server = AirDropServer(config)
server.start_service() server.start_service()