initial commit of go-rewrite
This commit is contained in:
parent
35f07d84b9
commit
374ce0538a
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,3 +1,7 @@
|
|||||||
|
src/magneticod/vendor
|
||||||
|
src/magneticod/Gopkg.lock
|
||||||
|
src/magneticow/vendor
|
||||||
|
src/magneticow/Gopkg.lock
|
||||||
|
|
||||||
# Created by https://www.gitignore.io/api/linux,python,pycharm
|
# Created by https://www.gitignore.io/api/linux,python,pycharm
|
||||||
|
|
||||||
|
4
bin/.gitignore
vendored
Normal file
4
bin/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# Ignore everything in this directory
|
||||||
|
*
|
||||||
|
# Except this file
|
||||||
|
!.gitignore
|
2
doc/Flow of Operation.draw.io.svg
Normal file
2
doc/Flow of Operation.draw.io.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 89 KiB |
@ -1,2 +0,0 @@
|
|||||||
Dockerfile
|
|
||||||
.dockerignore
|
|
@ -1,9 +0,0 @@
|
|||||||
FROM python:3.6
|
|
||||||
|
|
||||||
RUN mkdir -p /usr/src/app
|
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN pip install -e .
|
|
||||||
CMD ["python", "-mmagneticod"]
|
|
@ -1,161 +0,0 @@
|
|||||||
==========
|
|
||||||
magneticod
|
|
||||||
==========
|
|
||||||
*Autonomous BitTorrent DHT crawler and metadata fetcher.*
|
|
||||||
|
|
||||||
**magneticod** is the daemon that crawls the BitTorrent DHT network in the background to discover info hashes and
|
|
||||||
fetches metadata from the peers. It uses SQLite 3 that is built-in your Python 3.x distribution to persist data.
|
|
||||||
|
|
||||||
Installation
|
|
||||||
============
|
|
||||||
Requirements
|
|
||||||
------------
|
|
||||||
- Python 3.5 or above.
|
|
||||||
|
|
||||||
**WARNING:**
|
|
||||||
|
|
||||||
Python 3.6.0 and 3.6.1 suffer from a bug (`issue #29714 <http://bugs.python.org/issue29714>`_) that causes
|
|
||||||
magneticod to fail. As it is an interpreter bug that I have no control on, please make sure that you are not using
|
|
||||||
any of those Python 3 versions to run magneticod.
|
|
||||||
|
|
||||||
- Decent Internet access (IPv4)
|
|
||||||
|
|
||||||
**magneticod** uses UDP protocol to communicate with the nodes in the DHT network, and TCP to communicate with the
|
|
||||||
peers while fetching metadata. **Please make sure you have a healthy connection;** you can confirm this by checking at
|
|
||||||
the *connection status indicator* of your BitTorrent client: if it does not indicate any error, **magneticod** should
|
|
||||||
just work fine.
|
|
||||||
|
|
||||||
Instructions
|
|
||||||
------------
|
|
||||||
1. Download the latest version of **magneticod** from PyPI using pip3: ::
|
|
||||||
|
|
||||||
pip3 install magneticod --user
|
|
||||||
|
|
||||||
2. Add installation path to the ``$PATH``; append the following line to your ``~/.profile`` if you are using bash ::
|
|
||||||
|
|
||||||
export PATH=$PATH:~/.local/bin
|
|
||||||
|
|
||||||
**or if you are on macOS** and using bash, (assuming that you are using Python 3.5): ::
|
|
||||||
|
|
||||||
export PATH="${PATH}:${HOME}/Library/Python/3.5/bin/"
|
|
||||||
|
|
||||||
3. Activate the changes to ``$PATH`` (again, if you are using bash): ::
|
|
||||||
|
|
||||||
source ~/.profile
|
|
||||||
|
|
||||||
4. Confirm that it is running: ::
|
|
||||||
|
|
||||||
magneticod
|
|
||||||
|
|
||||||
Within maximum 5 minutes (and usually under a minute) **magneticod** will discover a few torrents! This, of course,
|
|
||||||
depends on your bandwidth, and your network configuration (existence of a firewall, misconfigured NAT, etc.).
|
|
||||||
|
|
||||||
5. *(only for systemd users, skip the rest of the steps and proceed to the* `Using`_ *section if you are not a systemd
|
|
||||||
user or want to use a different solution)*
|
|
||||||
|
|
||||||
Download the magneticod systemd service file (at
|
|
||||||
`magneticod/systemd/magneticod.service <systemd/magneticod.service>`_) and change the tilde symbol with
|
|
||||||
the path of your home directory, and the ``PORT_NUMBER`` with the preferred port number. For example, if my username
|
|
||||||
is ``bora`` and I prefer the port 64879, this line ::
|
|
||||||
|
|
||||||
ExecStart=~/.local/bin/magneticod magneticod --node-addr 0.0.0.0:PORT_NUMBER
|
|
||||||
|
|
||||||
should become this: ::
|
|
||||||
|
|
||||||
ExecStart=/home/bora/.local/bin/magneticod --node-addr 0.0.0.0:64879
|
|
||||||
|
|
||||||
Here, tilde (``~``) is replaced with ``/home/bora`` and the ``PORT_NUMBER`` with 64879. Run ``echo ~`` to see the
|
|
||||||
path of your own home directory, if you do not already know. Port numbers above 1000 typically do not require
|
|
||||||
special permissions.
|
|
||||||
|
|
||||||
6. Copy the magneticod systemd service file to your local systemd configuration directory: ::
|
|
||||||
|
|
||||||
cp magneticod.service ~/.config/systemd/user/
|
|
||||||
|
|
||||||
You might need to create intermediate directories (``.config``, ``systemd``, and ``user``) if not exists.
|
|
||||||
|
|
||||||
7. (Optional, **requires root**) Disable iptables for a specified port: ::
|
|
||||||
|
|
||||||
iptables -I OUTPUT -t raw -p udp --sport PORT_NUMBER -j NOTRACK
|
|
||||||
iptables -I PREROUTING -t raw -p udp --dport PORT_NUMBER -j NOTRACK
|
|
||||||
|
|
||||||
This is to prevent excessive number of ``EPERM`` "Operation not permitted" errors, which also has a negative impact
|
|
||||||
on the performance.
|
|
||||||
|
|
||||||
8. Start **magneticod**: ::
|
|
||||||
|
|
||||||
systemctl --user enable magneticod --now
|
|
||||||
|
|
||||||
**magneticod** should now be running under the supervision of systemd and it should also be automatically started
|
|
||||||
whenever you boot your machine.
|
|
||||||
|
|
||||||
You can check its status and most recent log entries using the following command: ::
|
|
||||||
|
|
||||||
systemctl --user status magneticod
|
|
||||||
|
|
||||||
To stop **magneticod**, issue the following: ::
|
|
||||||
|
|
||||||
systemctl --user stop magneticod
|
|
||||||
\
|
|
||||||
|
|
||||||
**Suggestion:**
|
|
||||||
|
|
||||||
Keep **magneticod** running so that when you finish installing **magneticow**, database will be populated and you
|
|
||||||
can see some results.
|
|
||||||
|
|
||||||
Using
|
|
||||||
=====
|
|
||||||
**magneticod** does not require user interference to operate, once it starts running. Hence, there is no "user manual",
|
|
||||||
although you should beware of these points:
|
|
||||||
|
|
||||||
1. **Network Usage:**
|
|
||||||
|
|
||||||
**magneticod** does *not* have any built-in rate limiter *yet*, and it will literally suck the hell out of your
|
|
||||||
bandwidth. Unless you are running **magneticod** on a separate machine dedicated for it, you might want to consider
|
|
||||||
starting it manually only when network load is low (e.g. when you are at work or sleeping at night).
|
|
||||||
|
|
||||||
2. **Pre-Alpha Bugs:**
|
|
||||||
|
|
||||||
**magneticod** is *supposed* to work "just fine", but as being at pre-alpha stage, it's likely that you might find
|
|
||||||
some bugs. It will be much appreciated if you can report those bugs, so that **magneticod** can be improved. See the
|
|
||||||
next sub-section for how to mitigate the issue if you are *not* using systemd.
|
|
||||||
|
|
||||||
Automatic Restarting
|
|
||||||
--------------------
|
|
||||||
Due to minor bugs at this stage of its development, **magneticod** should be supervised by another program to be ensured
|
|
||||||
that it's running, and should be restarted if not. systemd service file supplied by **magneticod** implements that,
|
|
||||||
although (if you wish) you can also use a much more primitive approach using GNU screen (which comes pre-installed in
|
|
||||||
many GNU/Linux distributions):
|
|
||||||
|
|
||||||
1. Start screen session named ``magneticod``: ::
|
|
||||||
|
|
||||||
screen -S magneticod
|
|
||||||
|
|
||||||
2. Run **magneticod** forever: ::
|
|
||||||
|
|
||||||
until magneticod; do echo "restarting..."; sleep 5; done;
|
|
||||||
|
|
||||||
This will keep restarting **magneticod** after five seconds in case if it fails.
|
|
||||||
|
|
||||||
3. Detach the session by pressing Ctrl+A and after Ctrl+D.
|
|
||||||
|
|
||||||
4. If you wish to see the logs, or to kill **magneticod**, ``screen -r magneticod`` will attach the original screen
|
|
||||||
session back. **magneticod** will exit gracefully upon keyboard interrupt (Ctrl+C) [SIGINT].
|
|
||||||
|
|
||||||
Database
|
|
||||||
--------
|
|
||||||
**magneticod** uses SQLite 3 that is built-in by default in almost all Python distributions.
|
|
||||||
`appdirs <https://pypi.python.org/pypi/appdirs/>`_ package is used to determine user data directory, which is often
|
|
||||||
``~/.local/share/magneticod``. **magneticod** uses write-ahead logging for its database, so there might be multiple
|
|
||||||
files while it is operating, but ``database.sqlite3`` is *the main database where every torrent metadata is stored*.
|
|
||||||
|
|
||||||
License
|
|
||||||
=======
|
|
||||||
All the code is licensed under AGPLv3, unless otherwise stated in the source specific source. See ``COPYING`` file
|
|
||||||
in ``magnetico`` directory for the full license text.
|
|
||||||
|
|
||||||
----
|
|
||||||
|
|
||||||
Dedicated to Cemile Binay, in whose hands I thrived.
|
|
||||||
|
|
||||||
Bora M. ALPER <bora@boramalper.org>
|
|
@ -1,15 +0,0 @@
|
|||||||
# magneticod - Autonomous BitTorrent DHT crawler and metadata fetcher.
|
|
||||||
# Copyright (C) 2017 Mert Bora ALPER <bora@boramalper.org>
|
|
||||||
# Dedicated to Cemile Binay, in whose hands I thrived.
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
|
||||||
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
|
|
||||||
# later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
||||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
|
||||||
# <http://www.gnu.org/licenses/>.
|
|
||||||
__version__ = (0, 6, 0)
|
|
@ -1,147 +0,0 @@
|
|||||||
# magneticod - Autonomous BitTorrent DHT crawler and metadata fetcher.
|
|
||||||
# Copyright (C) 2017 Mert Bora ALPER <bora@boramalper.org>
|
|
||||||
# Dedicated to Cemile Binay, in whose hands I thrived.
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
|
||||||
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
|
|
||||||
# later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
||||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
|
||||||
# <http://www.gnu.org/licenses/>.
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import ipaddress
|
|
||||||
import textwrap
|
|
||||||
import urllib.parse
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import typing
|
|
||||||
|
|
||||||
import appdirs
|
|
||||||
import humanfriendly
|
|
||||||
|
|
||||||
from .constants import DEFAULT_MAX_METADATA_SIZE
|
|
||||||
from . import __version__
|
|
||||||
from . import dht
|
|
||||||
from . import persistence
|
|
||||||
|
|
||||||
|
|
||||||
def parse_ip_port(netloc: str) -> typing.Optional[typing.Tuple[str, int]]:
|
|
||||||
# In case no port supplied
|
|
||||||
try:
|
|
||||||
return str(ipaddress.ip_address(netloc)), 0
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# In case port supplied
|
|
||||||
try:
|
|
||||||
parsed = urllib.parse.urlparse("//{}".format(netloc))
|
|
||||||
ip = str(ipaddress.ip_address(parsed.hostname))
|
|
||||||
port = parsed.port
|
|
||||||
if port is None:
|
|
||||||
return None
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return ip, port
|
|
||||||
|
|
||||||
|
|
||||||
def parse_size(value: str) -> int:
|
|
||||||
try:
|
|
||||||
return humanfriendly.parse_size(value)
|
|
||||||
except humanfriendly.InvalidSize as e:
|
|
||||||
raise argparse.ArgumentTypeError("Invalid argument. {}".format(e))
|
|
||||||
|
|
||||||
|
|
||||||
def parse_cmdline_arguments(args: typing.List[str]) -> typing.Optional[argparse.Namespace]:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Autonomous BitTorrent DHT crawler and metadata fetcher.",
|
|
||||||
epilog=textwrap.dedent("""\
|
|
||||||
Copyright (C) 2017 Mert Bora ALPER <bora@boramalper.org>
|
|
||||||
Dedicated to Cemile Binay, in whose hands I thrived.
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify it under
|
|
||||||
the terms of the GNU Affero General Public License as published by the Free
|
|
||||||
Software Foundation, either version 3 of the License, or (at your option) any
|
|
||||||
later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
||||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
||||||
details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License along
|
|
||||||
with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
"""),
|
|
||||||
allow_abbrev=False,
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--node-addr", action="store", type=parse_ip_port, required=False, default="0.0.0.0:0",
|
|
||||||
help="the address of the (DHT) node magneticod will use"
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--max-metadata-size", type=parse_size, default=DEFAULT_MAX_METADATA_SIZE,
|
|
||||||
help="Limit metadata size to protect memory overflow. Provide in human friendly format eg. 1 M, 1 GB"
|
|
||||||
)
|
|
||||||
|
|
||||||
default_database_dir = os.path.join(appdirs.user_data_dir("magneticod"), "database.sqlite3")
|
|
||||||
parser.add_argument(
|
|
||||||
"--database-file", type=str, default=default_database_dir,
|
|
||||||
help="Path to database file (default: {})".format(humanfriendly.format_path(default_database_dir))
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'-d', '--debug',
|
|
||||||
action="store_const", dest="loglevel", const=logging.DEBUG, default=logging.INFO,
|
|
||||||
help="Print debugging information in addition to normal processing.",
|
|
||||||
)
|
|
||||||
return parser.parse_args(args)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
# main_task = create_tasks()
|
|
||||||
arguments = parse_cmdline_arguments(sys.argv[1:])
|
|
||||||
|
|
||||||
logging.basicConfig(level=arguments.loglevel, format="%(asctime)s %(levelname)-8s %(message)s")
|
|
||||||
logging.info("magneticod v%d.%d.%d started", *__version__)
|
|
||||||
|
|
||||||
# use uvloop if it's installed
|
|
||||||
try:
|
|
||||||
import uvloop
|
|
||||||
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
|
||||||
logging.info("uvloop is in use")
|
|
||||||
except ImportError:
|
|
||||||
if sys.platform not in ["linux", "darwin"]:
|
|
||||||
logging.warning("uvloop could not be imported, using the default asyncio implementation")
|
|
||||||
|
|
||||||
# noinspection PyBroadException
|
|
||||||
try:
|
|
||||||
database = persistence.Database(arguments.database_file)
|
|
||||||
except:
|
|
||||||
logging.exception("could NOT connect to the database!")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
node = dht.SybilNode(database, arguments.max_metadata_size)
|
|
||||||
loop.create_task(node.launch(arguments.node_addr))
|
|
||||||
|
|
||||||
try:
|
|
||||||
asyncio.get_event_loop().run_forever()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
logging.critical("Keyboard interrupt received! Exiting gracefully...")
|
|
||||||
finally:
|
|
||||||
loop.run_until_complete(node.shutdown())
|
|
||||||
database.close()
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
@ -1,80 +0,0 @@
|
|||||||
# magneticod - Autonomous BitTorrent DHT crawler and metadata fetcher.
|
|
||||||
# Copyright (C) 2017 Mert Bora ALPER <bora@boramalper.org>
|
|
||||||
# Dedicated to Cemile Binay, in whose hands I thrived.
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
|
||||||
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
|
|
||||||
# later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
||||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
|
||||||
# <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
"""
|
|
||||||
Warning:
|
|
||||||
Encoders do NOT check for circular objects! (and will NEVER check due to speed concerns).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import typing
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
import better_bencode
|
|
||||||
|
|
||||||
"""
|
|
||||||
The type definitions under this comment is actually this:
|
|
||||||
|
|
||||||
KRPCTypes = typing.Union[int, bytes, "KRPCList", "KRPCDict"]
|
|
||||||
KRPCList = typing.List[KRPCTypes]
|
|
||||||
KRPCDict = typing.Dict[bytes, KRPCTypes]
|
|
||||||
|
|
||||||
But since mypy:
|
|
||||||
* does NOT support self-referential types
|
|
||||||
* have problems with complex Unions (in case you thought about expanding manually: I tried)
|
|
||||||
|
|
||||||
just write `typing.Any`. =(
|
|
||||||
"""
|
|
||||||
KRPCTypes = typing.Any
|
|
||||||
KRPCList = typing.Any
|
|
||||||
KRPCDict = typing.Any
|
|
||||||
|
|
||||||
|
|
||||||
def dumps(obj: KRPCTypes) -> bytes:
|
|
||||||
try:
|
|
||||||
return better_bencode.dumps(obj)
|
|
||||||
except:
|
|
||||||
raise BencodeEncodingError()
|
|
||||||
|
|
||||||
|
|
||||||
def loads(bytes_object: bytes) -> KRPCTypes:
|
|
||||||
try:
|
|
||||||
return better_bencode.loads(bytes_object)
|
|
||||||
except Exception as exc:
|
|
||||||
raise BencodeDecodingError(exc)
|
|
||||||
|
|
||||||
|
|
||||||
def loads2(bytes_object: bytes) -> typing.Tuple[KRPCTypes, int]:
|
|
||||||
"""
|
|
||||||
Returns the bencoded object AND the index where the dump of the decoded object ends (exclusive). In less words:
|
|
||||||
|
|
||||||
dump = b"i12eOH YEAH"
|
|
||||||
object, i = loads2(dump)
|
|
||||||
print(">>>", dump[i:]) # OUTPUT: >>> b'OH YEAH'
|
|
||||||
"""
|
|
||||||
bio = BytesIO(bytes_object)
|
|
||||||
try:
|
|
||||||
return better_bencode.load(bio), bio.tell()
|
|
||||||
except Exception as exc:
|
|
||||||
raise BencodeDecodingError(exc)
|
|
||||||
|
|
||||||
|
|
||||||
class BencodeEncodingError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class BencodeDecodingError(Exception):
|
|
||||||
def __init__(self, original_exc):
|
|
||||||
super().__init__()
|
|
||||||
self.original_exc = original_exc
|
|
@ -1,221 +0,0 @@
|
|||||||
# magneticod - Autonomous BitTorrent DHT crawler and metadata fetcher.
|
|
||||||
# Copyright (C) 2017 Mert Bora ALPER <bora@boramalper.org>
|
|
||||||
# Dedicated to Cemile Binay, in whose hands I thrived.
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
|
||||||
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
|
|
||||||
# later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
||||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
|
||||||
# <http://www.gnu.org/licenses/>.
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import hashlib
|
|
||||||
import math
|
|
||||||
import typing
|
|
||||||
import os
|
|
||||||
|
|
||||||
from . import bencode
|
|
||||||
|
|
||||||
InfoHash = bytes
|
|
||||||
PeerAddress = typing.Tuple[str, int]
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_metadata_from_peer(info_hash: InfoHash, peer_addr: PeerAddress, max_metadata_size: int, timeout=None) \
|
|
||||||
-> typing.Optional[bytes]:
|
|
||||||
try:
|
|
||||||
# asyncio.wait_for "returns result of the Future or coroutine."Returns result of the Future or coroutine.
|
|
||||||
return await asyncio.wait_for(DisposablePeer(info_hash, peer_addr, max_metadata_size).run(), timeout=timeout)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class ProtocolError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class DisposablePeer:
|
|
||||||
def __init__(self, info_hash: InfoHash, peer_addr: PeerAddress, max_metadata_size: int) -> None:
|
|
||||||
self.__peer_addr = peer_addr
|
|
||||||
self.__info_hash = info_hash
|
|
||||||
|
|
||||||
self.__ext_handshake_complete = False # Extension Handshake
|
|
||||||
self.__ut_metadata = int() # Since we don't know ut_metadata code that remote peer uses...
|
|
||||||
|
|
||||||
self.__max_metadata_size = max_metadata_size
|
|
||||||
self.__metadata_size = None
|
|
||||||
self.__metadata_received = 0 # Amount of metadata bytes received...
|
|
||||||
self.__metadata = bytearray()
|
|
||||||
|
|
||||||
self._run_task = None
|
|
||||||
self._writer = None
|
|
||||||
|
|
||||||
|
|
||||||
async def run(self) -> typing.Optional[bytes]:
|
|
||||||
event_loop = asyncio.get_event_loop()
|
|
||||||
self._metadata_future = event_loop.create_future()
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._reader, self._writer = await asyncio.open_connection(*self.__peer_addr, loop=event_loop) # type: ignore
|
|
||||||
# Send the BitTorrent initiate_the_bittorrent_handshake message (0x13 = 19 in decimal, the length of the initiate_the_bittorrent_handshake message)
|
|
||||||
self._writer.write(b"\x13BitTorrent protocol%s%s%s" % ( # type: ignore
|
|
||||||
b"\x00\x00\x00\x00\x00\x10\x00\x01",
|
|
||||||
self.__info_hash,
|
|
||||||
os.urandom(20)
|
|
||||||
))
|
|
||||||
# Honestly speaking, BitTorrent protocol might be one of the most poorly documented and (not the most but)
|
|
||||||
# badly designed protocols I have ever seen (I am 19 years old so what I could have seen?).
|
|
||||||
#
|
|
||||||
# Anyway, all the messages EXCEPT the initiate_the_bittorrent_handshake are length-prefixed by 4 bytes in network order, BUT the
|
|
||||||
# size of the initiate_the_bittorrent_handshake message is the 1-byte length prefix + 49 bytes, but luckily, there is only one
|
|
||||||
# canonical way of handshaking in the wild.
|
|
||||||
message = await self._reader.readexactly(68)
|
|
||||||
if message[1:20] != b"BitTorrent protocol":
|
|
||||||
# Erroneous initiate_the_bittorrent_handshake, possibly unknown version...
|
|
||||||
raise ProtocolError("Erroneous BitTorrent initiate_the_bittorrent_handshake! %s" % message)
|
|
||||||
|
|
||||||
self.__on_bt_handshake(message)
|
|
||||||
|
|
||||||
while not self._metadata_future.done():
|
|
||||||
buffer = await self._reader.readexactly(4)
|
|
||||||
length = int.from_bytes(buffer, "big")
|
|
||||||
message = await self._reader.readexactly(length)
|
|
||||||
self.__on_message(message)
|
|
||||||
except Exception:
|
|
||||||
logging.debug("closing %s to %s", self.__info_hash.hex(), self.__peer_addr)
|
|
||||||
finally:
|
|
||||||
if not self._metadata_future.done():
|
|
||||||
self._metadata_future.set_result(None)
|
|
||||||
if self._writer:
|
|
||||||
self._writer.close()
|
|
||||||
return self._metadata_future.result()
|
|
||||||
|
|
||||||
def __on_message(self, message: bytes) -> None:
|
|
||||||
# Every extension message has BitTorrent Message ID = 20
|
|
||||||
if message[0] != 20:
|
|
||||||
# logging.debug("Message is NOT an EXTension message! %s", message[:200])
|
|
||||||
return
|
|
||||||
|
|
||||||
# Extension Handshake has the Extension Message ID = 0
|
|
||||||
if message[1] == 0:
|
|
||||||
self.__on_ext_handshake_message(message[2:])
|
|
||||||
return
|
|
||||||
|
|
||||||
# ut_metadata extension messages has the Extension Message ID = 1 (as we arbitrarily decided!)
|
|
||||||
if message[1] != 1:
|
|
||||||
logging.debug("Message is NOT an ut_metadata message! %s", message[:200])
|
|
||||||
return
|
|
||||||
|
|
||||||
# Okay, now we are -almost- sure that this is an extension message, a kind we are most likely interested in...
|
|
||||||
self.__on_ext_message(message[2:])
|
|
||||||
|
|
||||||
def __on_bt_handshake(self, message: bytes) -> None:
|
|
||||||
""" on BitTorrent Handshake... send the extension initiate_the_bittorrent_handshake! """
|
|
||||||
if message[25] != 16:
|
|
||||||
logging.info("Peer does NOT support the extension protocol")
|
|
||||||
|
|
||||||
msg_dict_dump = bencode.dumps({
|
|
||||||
b"m": {
|
|
||||||
b"ut_metadata": 1
|
|
||||||
}
|
|
||||||
})
|
|
||||||
# In case you cannot read hex:
|
|
||||||
# 0x14 = 20 (BitTorrent ID indicating that it's an extended message)
|
|
||||||
# 0x00 = 0 (Extension ID indicating that it's the initiate_the_bittorrent_handshake message)
|
|
||||||
self._writer.write(b"%b\x14%s" % ( # type: ignore
|
|
||||||
(2 + len(msg_dict_dump)).to_bytes(4, "big"),
|
|
||||||
b'\0' + msg_dict_dump
|
|
||||||
))
|
|
||||||
|
|
||||||
def __on_ext_handshake_message(self, message: bytes) -> None:
|
|
||||||
if self.__ext_handshake_complete:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
msg_dict = bencode.loads(bytes(message))
|
|
||||||
except bencode.BencodeDecodingError:
|
|
||||||
# One might be tempted to close the connection, but why care? Any DisposableNode will be disposed
|
|
||||||
# automatically anyway (after a certain amount of time if the metadata is still not complete).
|
|
||||||
logging.debug("Could NOT decode extension initiate_the_bittorrent_handshake message! %s", message[:200])
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Just to make sure that the remote peer supports ut_metadata extension:
|
|
||||||
ut_metadata = msg_dict[b"m"][b"ut_metadata"]
|
|
||||||
metadata_size = msg_dict[b"metadata_size"]
|
|
||||||
assert metadata_size > 0, "Invalid (empty) metadata size"
|
|
||||||
assert metadata_size < self.__max_metadata_size, "Malicious or malfunctioning peer {}:{} tried send above" \
|
|
||||||
" {} max metadata size".format(self.__peer_addr[0],
|
|
||||||
self.__peer_addr[1],
|
|
||||||
self.__max_metadata_size)
|
|
||||||
except AssertionError as e:
|
|
||||||
logging.debug(str(e))
|
|
||||||
raise
|
|
||||||
|
|
||||||
self.__ut_metadata = ut_metadata
|
|
||||||
try:
|
|
||||||
self.__metadata = bytearray(metadata_size) # type: ignore
|
|
||||||
except MemoryError:
|
|
||||||
logging.exception("Could not allocate %.1f KiB for the metadata!", metadata_size / 1024)
|
|
||||||
raise
|
|
||||||
|
|
||||||
self.__metadata_size = metadata_size
|
|
||||||
self.__ext_handshake_complete = True
|
|
||||||
|
|
||||||
# After the initiate_the_bittorrent_handshake is complete, request all the pieces of metadata
|
|
||||||
n_pieces = math.ceil(self.__metadata_size / (2 ** 14))
|
|
||||||
for piece in range(n_pieces):
|
|
||||||
self.__request_metadata_piece(piece)
|
|
||||||
|
|
||||||
def __on_ext_message(self, message: bytes) -> None:
|
|
||||||
try:
|
|
||||||
msg_dict, i = bencode.loads2(bytes(message))
|
|
||||||
except bencode.BencodeDecodingError:
|
|
||||||
# One might be tempted to close the connection, but why care? Any DisposableNode will be disposed
|
|
||||||
# automatically anyway (after a certain amount of time if the metadata is still not complete).
|
|
||||||
logging.debug("Could NOT decode extension message! %s", message[:200])
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
msg_type = msg_dict[b"msg_type"]
|
|
||||||
piece = msg_dict[b"piece"]
|
|
||||||
except KeyError:
|
|
||||||
logging.debug("Missing EXT keys! %s", msg_dict)
|
|
||||||
return
|
|
||||||
|
|
||||||
if msg_type == 1: # data
|
|
||||||
metadata_piece = message[i:]
|
|
||||||
self.__metadata[piece * 2**14: piece * 2**14 + len(metadata_piece)] = metadata_piece
|
|
||||||
self.__metadata_received += len(metadata_piece)
|
|
||||||
|
|
||||||
# self.__metadata += metadata_piece
|
|
||||||
|
|
||||||
# logging.debug("PIECE %d RECEIVED %s", piece, metadata_piece[:200])
|
|
||||||
|
|
||||||
if self.__metadata_received == self.__metadata_size:
|
|
||||||
if hashlib.sha1(self.__metadata).digest() == self.__info_hash:
|
|
||||||
if not self._metadata_future.done():
|
|
||||||
self._metadata_future.set_result(bytes(self.__metadata))
|
|
||||||
else:
|
|
||||||
logging.debug("Invalid Metadata! Ignoring.")
|
|
||||||
|
|
||||||
elif msg_type == 2: # reject
|
|
||||||
logging.info("Peer rejected us.")
|
|
||||||
|
|
||||||
def __request_metadata_piece(self, piece: int) -> None:
|
|
||||||
msg_dict_dump = bencode.dumps({
|
|
||||||
b"msg_type": 0,
|
|
||||||
b"piece": piece
|
|
||||||
})
|
|
||||||
# In case you cannot read_file hex:
|
|
||||||
# 0x14 = 20 (BitTorrent ID indicating that it's an extended message)
|
|
||||||
# 0x03 = 3 (Extension ID indicating that it's an ut_metadata message)
|
|
||||||
self._writer.write(b"%b\x14%s%s" % ( # type: ignore
|
|
||||||
(2 + len(msg_dict_dump)).to_bytes(4, "big"),
|
|
||||||
self.__ut_metadata.to_bytes(1, "big"),
|
|
||||||
msg_dict_dump
|
|
||||||
))
|
|
@ -1,64 +0,0 @@
|
|||||||
# magneticod - Autonomous BitTorrent DHT crawler and metadata fetcher.
|
|
||||||
# Copyright (C) 2017 Mert Bora ALPER <bora@boramalper.org>
|
|
||||||
# Dedicated to Cemile Binay, in whose hands I thrived.
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
|
||||||
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
|
|
||||||
# later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
||||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
|
||||||
# <http://www.gnu.org/licenses/>.
|
|
||||||
import typing
|
|
||||||
import io
|
|
||||||
|
|
||||||
import better_bencode
|
|
||||||
|
|
||||||
|
|
||||||
Message = typing.Dict[bytes, typing.Any]
|
|
||||||
|
|
||||||
|
|
||||||
def encode(message: Message) -> bytes:
|
|
||||||
try:
|
|
||||||
return better_bencode.dumps(message)
|
|
||||||
except Exception as exc:
|
|
||||||
raise EncodeError(exc)
|
|
||||||
|
|
||||||
|
|
||||||
def decode(data: typing.ByteString) -> Message:
|
|
||||||
try:
|
|
||||||
return better_bencode.loads(data)
|
|
||||||
except Exception as exc:
|
|
||||||
raise DecodeError(exc)
|
|
||||||
|
|
||||||
|
|
||||||
def decode_prefix(data: typing.ByteString) -> typing.Tuple[Message, int]:
|
|
||||||
"""
|
|
||||||
Returns the bencoded object AND the index where the dump of the decoded object ends (exclusive). In less words:
|
|
||||||
|
|
||||||
dump = b"i12eOH YEAH"
|
|
||||||
object, i = decode_prefix(dump)
|
|
||||||
print(">>>", dump[i:]) # OUTPUT: >>> b'OH YEAH'
|
|
||||||
"""
|
|
||||||
bio = io.BytesIO(data)
|
|
||||||
try:
|
|
||||||
return better_bencode.load(bio), bio.tell()
|
|
||||||
except Exception as exc:
|
|
||||||
raise DecodeError(exc)
|
|
||||||
|
|
||||||
|
|
||||||
class BaseCodecError(Exception):
|
|
||||||
def __init__(self, original_exception: Exception):
|
|
||||||
self.original_exception = original_exception
|
|
||||||
|
|
||||||
|
|
||||||
class EncodeError(BaseCodecError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class DecodeError(BaseCodecError):
|
|
||||||
pass
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
|||||||
# coding=utf-8
|
|
||||||
DEFAULT_MAX_METADATA_SIZE = 10 * 1024 * 1024
|
|
||||||
BOOTSTRAPPING_NODES = [
|
|
||||||
("router.bittorrent.com", 6881),
|
|
||||||
("dht.transmissionbt.com", 6881)
|
|
||||||
]
|
|
||||||
PENDING_INFO_HASHES = 10 # threshold for pending info hashes before being committed to database:
|
|
||||||
|
|
||||||
TICK_INTERVAL = 1 # in seconds
|
|
||||||
|
|
||||||
# maximum (inclusive) number of active (disposable) peers to fetch the metadata per info hash at the same time:
|
|
||||||
MAX_ACTIVE_PEERS_PER_INFO_HASH = 5
|
|
||||||
|
|
||||||
PEER_TIMEOUT=120 # seconds
|
|
@ -1,42 +0,0 @@
|
|||||||
# magneticod - Autonomous BitTorrent DHT crawler and metadata fetcher.
|
|
||||||
# Copyright (C) 2017 Mert Bora ALPER <bora@boramalper.org>
|
|
||||||
# Dedicated to Cemile Binay, in whose hands I thrived.
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
|
||||||
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
|
|
||||||
# later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
||||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
|
||||||
# <http://www.gnu.org/licenses/>.
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from .dht import mainline
|
|
||||||
from . import bittorrent
|
|
||||||
|
|
||||||
|
|
||||||
class Coordinator:
|
|
||||||
def __init__(self):
|
|
||||||
self._peer_service = mainline.service.PeerService()
|
|
||||||
|
|
||||||
self._metadata_service_tasks = {}
|
|
||||||
|
|
||||||
async def run(self):
|
|
||||||
await self._peer_service.launch(("0.0.0.0", 0))
|
|
||||||
|
|
||||||
# Private Functionality
|
|
||||||
# =====================
|
|
||||||
def _when_peer(self, info_hash: mainline.protocol.InfoHash, address: mainline.transport.Address) \
|
|
||||||
-> None:
|
|
||||||
if info_hash in self._metadata_service_tasks:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._metadata_service_tasks[info_hash] = asyncio.ensure_future()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _when_metadata(self, info_hash: mainline.protocol.InfoHash, address: mainline.transport.Address) -> None:
|
|
||||||
pass
|
|
@ -1,393 +0,0 @@
|
|||||||
# magneticod - Autonomous BitTorrent DHT crawler and metadata fetcher.
|
|
||||||
# Copyright (C) 2017 Mert Bora ALPER <bora@boramalper.org>
|
|
||||||
# Dedicated to Cemile Binay, in whose hands I thrived.
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
|
||||||
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
|
|
||||||
# later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
||||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
|
||||||
# <http://www.gnu.org/licenses/>.
|
|
||||||
import asyncio
|
|
||||||
import errno
|
|
||||||
import zlib
|
|
||||||
import logging
|
|
||||||
import socket
|
|
||||||
import typing
|
|
||||||
import os
|
|
||||||
|
|
||||||
from .constants import BOOTSTRAPPING_NODES, MAX_ACTIVE_PEERS_PER_INFO_HASH, PEER_TIMEOUT, TICK_INTERVAL
|
|
||||||
from . import bencode
|
|
||||||
from . import bittorrent
|
|
||||||
from . import persistence
|
|
||||||
|
|
||||||
NodeID = bytes
|
|
||||||
NodeAddress = typing.Tuple[str, int]
|
|
||||||
PeerAddress = typing.Tuple[str, int]
|
|
||||||
InfoHash = bytes
|
|
||||||
Metadata = bytes
|
|
||||||
|
|
||||||
|
|
||||||
class SybilNode(asyncio.DatagramProtocol):
|
|
||||||
def __init__(self, database: persistence.Database, max_metadata_size):
|
|
||||||
self.__true_id = os.urandom(20)
|
|
||||||
|
|
||||||
self._routing_table = {} # type: typing.Dict[NodeID, NodeAddress]
|
|
||||||
|
|
||||||
self.__token_secret = os.urandom(4)
|
|
||||||
# Maximum number of neighbours (this is a THRESHOLD where, once reached, the search for new neighbours will
|
|
||||||
# stop; but until then, the total number of neighbours might exceed the threshold).
|
|
||||||
self.__n_max_neighbours = 2000
|
|
||||||
self.__parent_futures = {} # type: typing.Dict[InfoHash, asyncio.Future]
|
|
||||||
self.__database = database
|
|
||||||
self.__max_metadata_size = max_metadata_size
|
|
||||||
self._is_writing_paused = False
|
|
||||||
self._tick_task = None
|
|
||||||
|
|
||||||
logging.info("SybilNode %s initialized!", self.__true_id.hex().upper())
|
|
||||||
|
|
||||||
async def launch(self, address):
|
|
||||||
await asyncio.get_event_loop().create_datagram_endpoint(lambda: self, local_addr=address)
|
|
||||||
logging.info("SybliNode is launched on %s!", address)
|
|
||||||
|
|
||||||
# mypy ignored: mypy errors because we explicitly stated `transport`s type =)
|
|
||||||
def connection_made(self, transport: asyncio.DatagramTransport) -> None: # type: ignore
|
|
||||||
# mypy ignored: mypy doesn't know (yet) about coroutines
|
|
||||||
self._tick_task = asyncio.get_event_loop().create_task(self.tick_periodically()) # type: ignore
|
|
||||||
self._transport = transport
|
|
||||||
|
|
||||||
def connection_lost(self, exc) -> None:
|
|
||||||
logging.critical("SybilNode's connection is lost.")
|
|
||||||
self._is_writing_paused = True
|
|
||||||
|
|
||||||
def pause_writing(self) -> None:
|
|
||||||
self._is_writing_paused = True
|
|
||||||
# In case of congestion, decrease the maximum number of nodes to the 90% of the current value.
|
|
||||||
self.__n_max_neighbours = self.__n_max_neighbours * 9 // 10
|
|
||||||
logging.debug("Maximum number of neighbours now %d", self.__n_max_neighbours)
|
|
||||||
|
|
||||||
def resume_writing(self) -> None:
|
|
||||||
self._is_writing_paused = False
|
|
||||||
|
|
||||||
def sendto(self, data, addr) -> None:
|
|
||||||
if not self._is_writing_paused:
|
|
||||||
self._transport.sendto(data, addr)
|
|
||||||
|
|
||||||
def error_received(self, exc: Exception) -> None:
|
|
||||||
if isinstance(exc, PermissionError) or (isinstance(exc, OSError) and exc.errno == errno.ENOBUFS):
|
|
||||||
# This exception (EPERM errno: 1) is kernel's way of saying that "you are far too fast, chill".
|
|
||||||
# It is also likely that we have received a ICMP source quench packet (meaning, that we really need to
|
|
||||||
# slow down.
|
|
||||||
#
|
|
||||||
# Read more here: http://www.archivum.info/comp.protocols.tcp-ip/2009-05/00088/UDP-socket-amp-amp-sendto
|
|
||||||
# -amp-amp-EPERM.html
|
|
||||||
|
|
||||||
# > Note On BSD systems (OS X, FreeBSD, etc.) flow control is not supported for DatagramProtocol, because
|
|
||||||
# > send failures caused by writing too many packets cannot be detected easily. The socket always appears
|
|
||||||
# > ‘ready’ and excess packets are dropped; an OSError with errno set to errno.ENOBUFS may or may not be
|
|
||||||
# > raised; if it is raised, it will be reported to DatagramProtocol.error_received() but otherwise ignored.
|
|
||||||
# Source: https://docs.python.org/3/library/asyncio-protocol.html#flow-control-callbacks
|
|
||||||
|
|
||||||
# In case of congestion, decrease the maximum number of nodes to the 90% of the current value.
|
|
||||||
if self.__n_max_neighbours < 200:
|
|
||||||
logging.warning("Max. number of neighbours are < 200 and there is still congestion! (check your network "
|
|
||||||
"connection if this message recurs)")
|
|
||||||
else:
|
|
||||||
self.__n_max_neighbours = self.__n_max_neighbours * 9 // 10
|
|
||||||
logging.debug("Maximum number of neighbours now %d", self.__n_max_neighbours)
|
|
||||||
else:
|
|
||||||
# The previous "exception" was kind of "unexceptional", but we should log anything else.
|
|
||||||
logging.error("SybilNode operational error: `%s`", exc)
|
|
||||||
|
|
||||||
async def tick_periodically(self) -> None:
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(TICK_INTERVAL)
|
|
||||||
# Bootstrap (by querying the bootstrapping servers) ONLY IF the routing table is empty (i.e. we don't have
|
|
||||||
# any neighbours). Otherwise we'll increase the load on those central servers by querying them every second.
|
|
||||||
if not self._routing_table:
|
|
||||||
await self.__bootstrap()
|
|
||||||
self.__make_neighbours()
|
|
||||||
self._routing_table.clear()
|
|
||||||
if not self._is_writing_paused:
|
|
||||||
self.__n_max_neighbours = self.__n_max_neighbours * 101 // 100
|
|
||||||
# mypy ignore: because .child_count on Future is monkey-patched
|
|
||||||
logging.debug("fetch metadata task count: %d", sum(
|
|
||||||
x.child_count for x in self.__parent_futures.values())) # type: ignore
|
|
||||||
logging.debug("asyncio task count: %d", len(asyncio.Task.all_tasks()))
|
|
||||||
|
|
||||||
def datagram_received(self, data, addr) -> None:
|
|
||||||
# Ignore nodes that "uses" port 0, as we cannot communicate with them reliably across the different systems.
|
|
||||||
# See https://tools.cisco.com/security/center/viewAlert.x?alertId=19935 for slightly more details
|
|
||||||
if addr[1] == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
if self._transport.is_closing():
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
message = bencode.loads(data)
|
|
||||||
except bencode.BencodeDecodingError:
|
|
||||||
return
|
|
||||||
|
|
||||||
if isinstance(message.get(b"r"), dict) and type(message[b"r"].get(b"nodes")) is bytes:
|
|
||||||
self.__on_FIND_NODE_response(message)
|
|
||||||
elif message.get(b"q") == b"get_peers":
|
|
||||||
self.__on_GET_PEERS_query(message, addr)
|
|
||||||
elif message.get(b"q") == b"announce_peer":
|
|
||||||
self.__on_ANNOUNCE_PEER_query(message, addr)
|
|
||||||
|
|
||||||
async def shutdown(self) -> None:
|
|
||||||
parent_futures = list(self.__parent_futures.values())
|
|
||||||
for pf in parent_futures:
|
|
||||||
pf.set_result(None)
|
|
||||||
self._tick_task.cancel()
|
|
||||||
await asyncio.wait([self._tick_task])
|
|
||||||
self._transport.close()
|
|
||||||
|
|
||||||
def __on_FIND_NODE_response(self, message: bencode.KRPCDict) -> None: # pylint: disable=invalid-name
|
|
||||||
# Well, we are not really interested in your response if our routing table is already full; sorry.
|
|
||||||
# (Thanks to Glandos@GitHub for the heads up!)
|
|
||||||
if len(self._routing_table) >= self.__n_max_neighbours:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
nodes_arg = message[b"r"][b"nodes"]
|
|
||||||
assert type(nodes_arg) is bytes and len(nodes_arg) % 26 == 0
|
|
||||||
except (TypeError, KeyError, AssertionError):
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
nodes = self.__decode_nodes(nodes_arg)
|
|
||||||
except AssertionError:
|
|
||||||
return
|
|
||||||
|
|
||||||
nodes = [n for n in nodes if n[1][1] != 0] # Ignore nodes with port 0.
|
|
||||||
self._routing_table.update(nodes[:self.__n_max_neighbours - len(self._routing_table)])
|
|
||||||
|
|
||||||
def __on_GET_PEERS_query(self, message: bencode.KRPCDict, addr: NodeAddress) -> None: # pylint: disable=invalid-name
|
|
||||||
try:
|
|
||||||
transaction_id = message[b"t"]
|
|
||||||
assert type(transaction_id) is bytes and transaction_id
|
|
||||||
info_hash = message[b"a"][b"info_hash"]
|
|
||||||
assert type(info_hash) is bytes and len(info_hash) == 20
|
|
||||||
except (TypeError, KeyError, AssertionError):
|
|
||||||
return
|
|
||||||
|
|
||||||
data = self.__build_GET_PEERS_response(
|
|
||||||
info_hash[:15] + self.__true_id[:5], transaction_id, self.__calculate_token(addr, info_hash)
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO:
|
|
||||||
# We would like to prioritise GET_PEERS responses as they are the most fruitful ones, i.e., that leads to the
|
|
||||||
# discovery of an info hash & metadata! But there is no easy way to do this with asyncio...
|
|
||||||
# Maybe use priority queues to prioritise certain messages and let them accumulate, and dispatch them to the
|
|
||||||
# transport at every tick?
|
|
||||||
self.sendto(data, addr)
|
|
||||||
|
|
||||||
def __on_ANNOUNCE_PEER_query(self, message: bencode.KRPCDict, addr: NodeAddress) -> None: # pylint: disable=invalid-name
|
|
||||||
try:
|
|
||||||
node_id = message[b"a"][b"id"]
|
|
||||||
assert type(node_id) is bytes and len(node_id) == 20
|
|
||||||
transaction_id = message[b"t"]
|
|
||||||
assert type(transaction_id) is bytes and transaction_id
|
|
||||||
token = message[b"a"][b"token"]
|
|
||||||
assert type(token) is bytes
|
|
||||||
info_hash = message[b"a"][b"info_hash"]
|
|
||||||
assert type(info_hash) is bytes and len(info_hash) == 20
|
|
||||||
if b"implied_port" in message[b"a"]:
|
|
||||||
implied_port = message[b"a"][b"implied_port"]
|
|
||||||
assert implied_port in (0, 1)
|
|
||||||
else:
|
|
||||||
implied_port = None
|
|
||||||
port = message[b"a"][b"port"]
|
|
||||||
|
|
||||||
assert type(port) is int and 0 < port < 65536
|
|
||||||
except (TypeError, KeyError, AssertionError):
|
|
||||||
return
|
|
||||||
|
|
||||||
data = self.__build_ANNOUNCE_PEER_response(node_id[:15] + self.__true_id[:5], transaction_id)
|
|
||||||
self.sendto(data, addr)
|
|
||||||
|
|
||||||
if implied_port:
|
|
||||||
peer_addr = (addr[0], addr[1])
|
|
||||||
else:
|
|
||||||
peer_addr = (addr[0], port)
|
|
||||||
|
|
||||||
if not self.__database.is_infohash_new(info_hash):
|
|
||||||
return
|
|
||||||
|
|
||||||
event_loop = asyncio.get_event_loop()
|
|
||||||
|
|
||||||
# A little clarification about parent and child futures might be really useful here:
|
|
||||||
# For every info hash we are interested in, we create ONE parent future and save it under self.__tasks
|
|
||||||
# (info_hash -> task) dictionary.
|
|
||||||
# For EVERY DisposablePeer working to fetch the metadata of that info hash, we create a child future. Hence, for
|
|
||||||
# every parent future, there should be *at least* one child future.
|
|
||||||
#
|
|
||||||
# Parent and child futures are "connected" to each other through `add_done_callback` functionality:
|
|
||||||
# When a child is successfully done, it sets the result of its parent (`set_result()`), and if it was
|
|
||||||
# unsuccessful to fetch the metadata, it just checks whether there are any other child futures left and if not
|
|
||||||
# it terminates the parent future (by setting its result to None) and quits.
|
|
||||||
# When a parent future is successfully done, (through the callback) it adds the info hash to the set of
|
|
||||||
# completed metadatas and puts the metadata in the queue to be committed to the database.
|
|
||||||
|
|
||||||
# create the parent future
|
|
||||||
if info_hash not in self.__parent_futures:
|
|
||||||
parent_f = event_loop.create_future()
|
|
||||||
# mypy ignore: because .child_count on Future is being monkey-patched here!
|
|
||||||
parent_f.child_count = 0 # type: ignore
|
|
||||||
parent_f.add_done_callback(lambda f: self._parent_task_done(f, info_hash))
|
|
||||||
self.__parent_futures[info_hash] = parent_f
|
|
||||||
|
|
||||||
parent_f = self.__parent_futures[info_hash]
|
|
||||||
|
|
||||||
if parent_f.done():
|
|
||||||
return
|
|
||||||
# mypy ignore: because .child_count on Future is monkey-patched
|
|
||||||
if parent_f.child_count > MAX_ACTIVE_PEERS_PER_INFO_HASH: # type: ignore
|
|
||||||
return
|
|
||||||
|
|
||||||
task = asyncio.ensure_future(bittorrent.fetch_metadata_from_peer(
|
|
||||||
info_hash, peer_addr, self.__max_metadata_size, timeout=PEER_TIMEOUT))
|
|
||||||
task.add_done_callback(lambda task: self._got_child_result(parent_f, task))
|
|
||||||
# mypy ignore: because .child_count on Future is monkey-patched
|
|
||||||
parent_f.child_count += 1 # type: ignore
|
|
||||||
parent_f.add_done_callback(lambda f: task.cancel())
|
|
||||||
|
|
||||||
def _got_child_result(self, parent_task, child_task):
|
|
||||||
parent_task.child_count -= 1
|
|
||||||
try:
|
|
||||||
metadata = child_task.result()
|
|
||||||
# Bora asked:
|
|
||||||
# Why do we check for parent_task being done here when a child got result? I mean, if parent_task is
|
|
||||||
# done before, and successful, all of its childs will be terminated and this function cannot be called
|
|
||||||
# anyway.
|
|
||||||
#
|
|
||||||
# --- https://github.com/boramalper/magnetico/pull/76#discussion_r119555423
|
|
||||||
#
|
|
||||||
# Suppose two child tasks are fetching the same metadata for a parent and they finish at the same time
|
|
||||||
# (or very close). The first one wakes up, sets the parent_task result which will cause the done
|
|
||||||
# callback to be scheduled. The scheduler might still then chooses the second child task to run next
|
|
||||||
# (why not? It's been waiting longer) before the parent has a chance to cancel it.
|
|
||||||
#
|
|
||||||
# Thus spoke Richard.
|
|
||||||
if metadata and not parent_task.done():
|
|
||||||
parent_task.set_result(metadata)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
logging.exception("child result is exception")
|
|
||||||
if parent_task.child_count <= 0 and not parent_task.done():
|
|
||||||
parent_task.set_result(None)
|
|
||||||
|
|
||||||
def _parent_task_done(self, parent_task, info_hash):
|
|
||||||
del self.__parent_futures[info_hash]
|
|
||||||
try:
|
|
||||||
metadata = parent_task.result()
|
|
||||||
if not metadata:
|
|
||||||
return
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
return
|
|
||||||
|
|
||||||
succeeded = self.__database.add_metadata(info_hash, metadata)
|
|
||||||
if not succeeded:
|
|
||||||
logging.info("Corrupt metadata for %s! Ignoring.", info_hash.hex())
|
|
||||||
|
|
||||||
async def __bootstrap(self) -> None:
|
|
||||||
event_loop = asyncio.get_event_loop()
|
|
||||||
for node in BOOTSTRAPPING_NODES:
|
|
||||||
try:
|
|
||||||
# AF_INET means ip4 only
|
|
||||||
responses = await event_loop.getaddrinfo(*node, family=socket.AF_INET)
|
|
||||||
for (family, type, proto, canonname, sockaddr) in responses:
|
|
||||||
data = self.__build_FIND_NODE_query(self.__true_id)
|
|
||||||
self.sendto(data, sockaddr)
|
|
||||||
except Exception:
|
|
||||||
logging.exception("An exception occurred during bootstrapping!")
|
|
||||||
|
|
||||||
def __make_neighbours(self) -> None:
|
|
||||||
for node_id, addr in self._routing_table.items():
|
|
||||||
self.sendto(self.__build_FIND_NODE_query(node_id[:15] + self.__true_id[:5]), addr)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def __decode_nodes(infos: bytes) -> typing.List[typing.Tuple[NodeID, NodeAddress]]:
|
|
||||||
""" Reference Implementation:
|
|
||||||
nodes = []
|
|
||||||
for i in range(0, len(infos), 26):
|
|
||||||
info = infos[i: i + 26]
|
|
||||||
node_id = info[:20]
|
|
||||||
node_host = socket.inet_ntoa(info[20:24])
|
|
||||||
node_port = int.from_bytes(info[24:], "big")
|
|
||||||
nodes.append((node_id, (node_host, node_port)))
|
|
||||||
return nodes
|
|
||||||
"""
|
|
||||||
""" Optimized Version: """
|
|
||||||
# Because dot-access also has a cost
|
|
||||||
inet_ntoa = socket.inet_ntoa
|
|
||||||
int_from_bytes = int.from_bytes
|
|
||||||
return [
|
|
||||||
(infos[i:i+20], (inet_ntoa(infos[i+20:i+24]), int_from_bytes(infos[i+24:i+26], "big")))
|
|
||||||
for i in range(0, len(infos), 26)
|
|
||||||
]
|
|
||||||
|
|
||||||
def __calculate_token(self, addr: NodeAddress, info_hash: InfoHash) -> bytes:
|
|
||||||
# Believe it or not, faster than using built-in hash (including conversion from int -> bytes of course)
|
|
||||||
checksum = zlib.adler32(b"%s%s%d%s" % (self.__token_secret, socket.inet_aton(addr[0]), addr[1], info_hash))
|
|
||||||
return checksum.to_bytes(4, "big")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def __build_FIND_NODE_query(id_: bytes) -> bytes: # pylint: disable=invalid-name
|
|
||||||
""" Reference Implementation:
|
|
||||||
bencode.dumps({
|
|
||||||
b"y": b"q",
|
|
||||||
b"q": b"find_node",
|
|
||||||
b"t": b"aa",
|
|
||||||
b"a": {
|
|
||||||
b"id": id_,
|
|
||||||
b"target": self.__random_bytes(20)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
"""
|
|
||||||
""" Optimized Version: """
|
|
||||||
return b"d1:ad2:id20:%s6:target20:%se1:q9:find_node1:t2:aa1:y1:qe" % (
|
|
||||||
id_,
|
|
||||||
os.urandom(20)
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def __build_GET_PEERS_response(id_: bytes, transaction_id: bytes, token: bytes) -> bytes: # pylint: disable=invalid-name
|
|
||||||
""" Reference Implementation:
|
|
||||||
|
|
||||||
bencode.dumps({
|
|
||||||
b"y": b"r",
|
|
||||||
b"t": transaction_id,
|
|
||||||
b"r": {
|
|
||||||
b"id": info_hash[:15] + self.__true_id[:5],
|
|
||||||
b"nodes": b"",
|
|
||||||
b"token": self.__calculate_token(addr, info_hash)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
"""
|
|
||||||
""" Optimized Version: """
|
|
||||||
return b"d1:rd2:id20:%s5:nodes0:5:token%d:%se1:t%d:%s1:y1:re" % (
|
|
||||||
id_, len(token), token, len(transaction_id), transaction_id
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def __build_ANNOUNCE_PEER_response(id_: bytes, transaction_id: bytes) -> bytes: # pylint: disable=invalid-name
|
|
||||||
""" Reference Implementation:
|
|
||||||
|
|
||||||
bencode.dumps({
|
|
||||||
b"y": b"r",
|
|
||||||
b"t": transaction_id,
|
|
||||||
b"r": {
|
|
||||||
b"id": node_id[:15] + self.__true_id[:5]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
"""
|
|
||||||
""" Optimized Version: """
|
|
||||||
return b"d1:rd2:id20:%se1:t%d:%s1:y1:re" % (id_, len(transaction_id), transaction_id)
|
|
@ -1,140 +0,0 @@
|
|||||||
# magneticod - Autonomous BitTorrent DHT crawler and metadata fetcher.
|
|
||||||
# Copyright (C) 2017 Mert Bora ALPER <bora@boramalper.org>
|
|
||||||
# Dedicated to Cemile Binay, in whose hands I thrived.
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
|
||||||
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
|
|
||||||
# later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
||||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
|
||||||
# <http://www.gnu.org/licenses/>.
|
|
||||||
import logging
|
|
||||||
import sqlite3
|
|
||||||
import time
|
|
||||||
import typing
|
|
||||||
import os
|
|
||||||
|
|
||||||
from magneticod import bencode
|
|
||||||
|
|
||||||
from .constants import PENDING_INFO_HASHES
|
|
||||||
|
|
||||||
|
|
||||||
class Database:
|
|
||||||
def __init__(self, database) -> None:
|
|
||||||
self.__db_conn = self.__connect(database)
|
|
||||||
|
|
||||||
# We buffer metadata to flush many entries at once, for performance reasons.
|
|
||||||
# list of tuple (info_hash, name, total_size, discovered_on)
|
|
||||||
self.__pending_metadata = [] # type: typing.List[typing.Tuple[bytes, str, int, int]]
|
|
||||||
# list of tuple (info_hash, size, path)
|
|
||||||
self.__pending_files = [] # type: typing.List[typing.Tuple[bytes, int, bytes]]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def __connect(database) -> sqlite3.Connection:
|
|
||||||
os.makedirs(os.path.split(database)[0], exist_ok=True)
|
|
||||||
db_conn = sqlite3.connect(database, isolation_level=None)
|
|
||||||
|
|
||||||
db_conn.execute("PRAGMA journal_mode=WAL;")
|
|
||||||
db_conn.execute("PRAGMA temp_store=1;")
|
|
||||||
db_conn.execute("PRAGMA foreign_keys=ON;")
|
|
||||||
|
|
||||||
with db_conn:
|
|
||||||
db_conn.execute("CREATE TABLE IF NOT EXISTS torrents ("
|
|
||||||
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
|
||||||
"info_hash BLOB NOT NULL UNIQUE,"
|
|
||||||
"name TEXT NOT NULL,"
|
|
||||||
"total_size INTEGER NOT NULL CHECK(total_size > 0),"
|
|
||||||
"discovered_on INTEGER NOT NULL CHECK(discovered_on > 0)"
|
|
||||||
");")
|
|
||||||
db_conn.execute("CREATE INDEX IF NOT EXISTS info_hash_index ON torrents (info_hash);")
|
|
||||||
db_conn.execute("CREATE TABLE IF NOT EXISTS files ("
|
|
||||||
"id INTEGER PRIMARY KEY,"
|
|
||||||
"torrent_id INTEGER REFERENCES torrents ON DELETE CASCADE ON UPDATE RESTRICT,"
|
|
||||||
"size INTEGER NOT NULL,"
|
|
||||||
"path TEXT NOT NULL"
|
|
||||||
");")
|
|
||||||
|
|
||||||
return db_conn
|
|
||||||
|
|
||||||
def add_metadata(self, info_hash: bytes, metadata: bytes) -> bool:
|
|
||||||
files = []
|
|
||||||
discovered_on = int(time.time())
|
|
||||||
try:
|
|
||||||
info = bencode.loads(metadata)
|
|
||||||
|
|
||||||
assert b"/" not in info[b"name"]
|
|
||||||
name = info[b"name"].decode("utf-8")
|
|
||||||
|
|
||||||
if b"files" in info: # Multiple File torrent:
|
|
||||||
for file in info[b"files"]:
|
|
||||||
assert type(file[b"length"]) is int
|
|
||||||
# Refuse trailing slash in any of the path items
|
|
||||||
assert not any(b"/" in item for item in file[b"path"])
|
|
||||||
path = "/".join(i.decode("utf-8") for i in file[b"path"])
|
|
||||||
files.append((info_hash, file[b"length"], path))
|
|
||||||
else: # Single File torrent:
|
|
||||||
assert type(info[b"length"]) is int
|
|
||||||
files.append((info_hash, info[b"length"], name))
|
|
||||||
# TODO: Make sure this catches ALL, AND ONLY operational errors
|
|
||||||
except (bencode.BencodeDecodingError, AssertionError, KeyError, AttributeError, UnicodeDecodeError, TypeError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
self.__pending_metadata.append((info_hash, name, sum(f[1] for f in files), discovered_on))
|
|
||||||
# MYPY BUG: error: Argument 1 to "__iadd__" of "list" has incompatible type List[Tuple[bytes, Any, str]];
|
|
||||||
# expected Iterable[Tuple[bytes, int, bytes]]
|
|
||||||
# List is an Iterable man...
|
|
||||||
self.__pending_files += files # type: ignore
|
|
||||||
|
|
||||||
logging.info("Added: `%s`", name)
|
|
||||||
|
|
||||||
# Automatically check if the buffer is full, and commit to the SQLite database if so.
|
|
||||||
if len(self.__pending_metadata) >= PENDING_INFO_HASHES:
|
|
||||||
self.__commit_metadata()
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def is_infohash_new(self, info_hash):
|
|
||||||
if info_hash in [x[0] for x in self.__pending_metadata]:
|
|
||||||
return False
|
|
||||||
cur = self.__db_conn.cursor()
|
|
||||||
try:
|
|
||||||
cur.execute("SELECT count(info_hash) FROM torrents where info_hash = ?;", [info_hash])
|
|
||||||
x, = cur.fetchone()
|
|
||||||
return x == 0
|
|
||||||
finally:
|
|
||||||
cur.close()
|
|
||||||
|
|
||||||
def __commit_metadata(self) -> None:
|
|
||||||
cur = self.__db_conn.cursor()
|
|
||||||
cur.execute("BEGIN;")
|
|
||||||
# noinspection PyBroadException
|
|
||||||
try:
|
|
||||||
cur.executemany(
|
|
||||||
"INSERT INTO torrents (info_hash, name, total_size, discovered_on) VALUES (?, ?, ?, ?);",
|
|
||||||
self.__pending_metadata
|
|
||||||
)
|
|
||||||
cur.executemany(
|
|
||||||
"INSERT INTO files (torrent_id, size, path) "
|
|
||||||
"VALUES ((SELECT id FROM torrents WHERE info_hash=?), ?, ?);",
|
|
||||||
self.__pending_files
|
|
||||||
)
|
|
||||||
cur.execute("COMMIT;")
|
|
||||||
logging.info("%d metadata (%d files) are committed to the database.",
|
|
||||||
len(self.__pending_metadata), len(self.__pending_files))
|
|
||||||
self.__pending_metadata.clear()
|
|
||||||
self.__pending_files.clear()
|
|
||||||
except:
|
|
||||||
cur.execute("ROLLBACK;")
|
|
||||||
logging.exception("Could NOT commit metadata to the database! (%d metadata are pending)",
|
|
||||||
len(self.__pending_metadata))
|
|
||||||
finally:
|
|
||||||
cur.close()
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
if self.__pending_metadata:
|
|
||||||
self.__commit_metadata()
|
|
||||||
self.__db_conn.close()
|
|
@ -1,296 +0,0 @@
|
|||||||
# magneticod - Autonomous BitTorrent DHT crawler and metadata fetcher.
|
|
||||||
# Copyright (C) 2017 Mert Bora ALPER <bora@boramalper.org>
|
|
||||||
# Dedicated to Cemile Binay, in whose hands I thrived.
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
|
||||||
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
|
|
||||||
# later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
||||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
|
||||||
# <http://www.gnu.org/licenses/>.
|
|
||||||
import enum
|
|
||||||
import typing
|
|
||||||
|
|
||||||
import cerberus
|
|
||||||
|
|
||||||
from magneticod import types
|
|
||||||
from ..codecs import bencode
|
|
||||||
from .. import transports
|
|
||||||
|
|
||||||
|
|
||||||
class Protocol:
|
|
||||||
def __init__(self):
|
|
||||||
self._transport = transports.bittorrent.TCPTransport()
|
|
||||||
|
|
||||||
self._transport.on_the_bittorrent_handshake_completed = self._when_the_bittorrent_handshake_completed
|
|
||||||
self._transport.on_message = self._when_message
|
|
||||||
self._transport.on_keepalive = self._when_keepalive
|
|
||||||
|
|
||||||
# When we initiate extension handshake, we set the keys of this dictionary with the ExtensionType's we show
|
|
||||||
# interest in, and if the remote peer is also interested in them, we record which type code the remote peer
|
|
||||||
# prefers for them in this dictionary, but until then, the keys have the None value. If our interest for an
|
|
||||||
# ExtensionType is not mutual, we remove the key from the dictionary.
|
|
||||||
self._enabled_extensions = {} # typing.Dict[ExtensionType, typing.Optional[int]]
|
|
||||||
|
|
||||||
async def launch(self) -> None:
|
|
||||||
await self._transport.launch()
|
|
||||||
|
|
||||||
# Offered Functionality
|
|
||||||
# =====================
|
|
||||||
def initiate_the_bittorrent_handshake(self, reserved: bytes, info_hash: types.InfoHash, peer_id: types.PeerID) \
|
|
||||||
-> None:
|
|
||||||
self._transport.initiate_the_bittorrent_handshake(reserved, info_hash, peer_id)
|
|
||||||
|
|
||||||
def send_keepalive(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def send_choke_message(self) -> None:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def send_unchoke_message(self) -> None:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def send_interested_message(self) -> None:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def send_not_interested_message(self) -> None:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def send_have_message(self) -> None:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def send_bitfield_message(self) -> None:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def send_request_message(self) -> None:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def send_piece_message(self) -> None:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def send_cancel_message(self) -> None:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def send_extension_handshake(
|
|
||||||
self,
|
|
||||||
supported_extensions: typing.Set[ExtensionType],
|
|
||||||
local_port: int=-1,
|
|
||||||
name_and_version: str="magneticod 0.x.x"
|
|
||||||
) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_the_bittorrent_handshake_completed(reserved: bytes, info_hash: types.InfoHash, peer_id: types.PeerID) \
|
|
||||||
-> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_keepalive() -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_choke_message() -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_unchoke_message() -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_interested_message() -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_not_interested_message() -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_have_message(index: int) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_bitfield_message() -> None:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_request_message(index: int, begin: int, length: int) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_piece_message(index: int, begin: int, piece: int) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_cancel_message(index: int, begin: int, length: int) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_extension_handshake_completed(payload: types.Dictionary) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Private Functionality
|
|
||||||
# =====================
|
|
||||||
def _when_the_bittorrent_handshake_completed(
|
|
||||||
self,
|
|
||||||
reserved: bytes,
|
|
||||||
info_hash: types.InfoHash,
|
|
||||||
peer_id: types.PeerID
|
|
||||||
) -> None:
|
|
||||||
self.on_the_bittorrent_handshake_completed(reserved, info_hash, peer_id)
|
|
||||||
|
|
||||||
def _when_keepalive(self) -> None:
|
|
||||||
self.on_keepalive()
|
|
||||||
|
|
||||||
def _when_message(self, type_: bytes, payload: typing.ByteString) -> None:
|
|
||||||
if type_ == MessageTypes.CHOKE:
|
|
||||||
self.on_choke_message()
|
|
||||||
elif type_ == MessageTypes.UNCHOKE:
|
|
||||||
self.on_unchoke_message()
|
|
||||||
elif type_ == MessageTypes.INTERESTED:
|
|
||||||
self.on_interested_message()
|
|
||||||
elif type_ == MessageTypes.NOT_INTERESTED:
|
|
||||||
self.on_not_interested_message()
|
|
||||||
elif type_ == MessageTypes.HAVE:
|
|
||||||
index = int.from_bytes(payload[:4], "big")
|
|
||||||
self.on_have_message(index)
|
|
||||||
elif type_ == MessageTypes.BITFIELD:
|
|
||||||
raise NotImplementedError()
|
|
||||||
elif type_ == MessageTypes.REQUEST:
|
|
||||||
index = int.from_bytes(payload[:4], "big")
|
|
||||||
begin = int.from_bytes(payload[4:8], "big")
|
|
||||||
length = int.from_bytes(payload[8:12], "big")
|
|
||||||
self.on_request_message(index, begin, length)
|
|
||||||
elif type_ == MessageTypes.PIECE:
|
|
||||||
index = int.from_bytes(payload[:4], "big")
|
|
||||||
begin = int.from_bytes(payload[4:8], "big")
|
|
||||||
piece = int.from_bytes(payload[8:12], "big")
|
|
||||||
self.on_piece_message(index, begin, piece)
|
|
||||||
elif type_ == MessageTypes.CANCEL:
|
|
||||||
index = int.from_bytes(payload[:4], "big")
|
|
||||||
begin = int.from_bytes(payload[4:8], "big")
|
|
||||||
length = int.from_bytes(payload[8:12], "big")
|
|
||||||
self.on_cancel_message(index, begin, length)
|
|
||||||
elif type_ == MessageTypes.EXTENDED:
|
|
||||||
self._when_extended_message(type_=payload[:1], payload=payload[1:])
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _when_extended_message(self, type_: bytes, payload: typing.ByteString) -> None:
|
|
||||||
if type_ == 0:
|
|
||||||
self._when_extension_handshake(payload)
|
|
||||||
elif type_ == ExtensionType.UT_METADATA.value:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _when_extension_handshake(self, payload: typing.ByteString) -> None:
|
|
||||||
dictionary_schema = {
|
|
||||||
b"m": {
|
|
||||||
"type": "dict",
|
|
||||||
"keyschema": {"type": "binary", "empty": False},
|
|
||||||
"valueschema": {"type": "integer", "min": 0},
|
|
||||||
"required": True,
|
|
||||||
},
|
|
||||||
b"p": {
|
|
||||||
"type": "integer",
|
|
||||||
"min": 1,
|
|
||||||
"max": 2**16 - 1,
|
|
||||||
"required": False
|
|
||||||
},
|
|
||||||
b"v": {
|
|
||||||
"type": "binary",
|
|
||||||
"empty": False,
|
|
||||||
"required": False
|
|
||||||
},
|
|
||||||
b"yourip": {
|
|
||||||
"type": "binary",
|
|
||||||
# It's actually EITHER 4 OR 16, not anything in-between. We need to validate this ourselves!
|
|
||||||
"minlength": 4,
|
|
||||||
"maxlength": 16,
|
|
||||||
"required": False
|
|
||||||
},
|
|
||||||
b"ipv6": {
|
|
||||||
"type": "binary",
|
|
||||||
"minlength": 16,
|
|
||||||
"maxlength": 16,
|
|
||||||
"required": False
|
|
||||||
},
|
|
||||||
b"ipv4": {
|
|
||||||
"type": "binary",
|
|
||||||
"minlength": 4,
|
|
||||||
"maxlength": 4,
|
|
||||||
"required": False
|
|
||||||
},
|
|
||||||
b"reqq": {
|
|
||||||
"type": "integer",
|
|
||||||
"min": 0,
|
|
||||||
"required": False
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
dictionary = bencode.decode(payload)
|
|
||||||
except bencode.DecodeError:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not cerberus.Validator(dictionary_schema).validate(dictionary):
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check which extensions, that we show interest in, are enabled by the remote peer.
|
|
||||||
if ExtensionType.UT_METADATA in self._enabled_extensions and b"ut_metadata" in dictionary[b"m"]:
|
|
||||||
self._enabled_extensions[ExtensionType.UT_METADATA] = dictionary[b"m"][b"ut_metadata"]
|
|
||||||
|
|
||||||
# As there can be multiple SUBSEQUENT extension-handshake-messages, check for the existence of b"metadata_size"
|
|
||||||
# in the top level dictionary ONLY IF b"ut_metadata" exists in b"m". `ut_metadata` might be enabled before,
|
|
||||||
# and other subsequent extension-handshake-messages do not need to include 'metadata_size` field in the top
|
|
||||||
# level dictionary whilst enabling-and/or-disabling other extension features.
|
|
||||||
if (b"ut_metadata" in dictionary[b"m"]) ^ (b"metadata_size" not in dictionary):
|
|
||||||
return
|
|
||||||
|
|
||||||
self.on_extension_handshake_completed(dictionary)
|
|
||||||
|
|
||||||
@enum.unique
|
|
||||||
class MessageTypes(enum.IntEnum):
|
|
||||||
CHOKE = 0
|
|
||||||
UNCHOKE = 1
|
|
||||||
INTERESTED = 2
|
|
||||||
NOT_INTERESTED = 3
|
|
||||||
HAVE = 4
|
|
||||||
BITFIELD = 5
|
|
||||||
REQUEST = 6
|
|
||||||
PIECE = 7
|
|
||||||
CANCEL = 8
|
|
||||||
EXTENDED = 20
|
|
||||||
|
|
||||||
|
|
||||||
@enum.unique
|
|
||||||
class ReservedFeature(enum.Enum):
|
|
||||||
# What do values mean?
|
|
||||||
# The first number is the offset, and the second number is the bit to set. For instance,
|
|
||||||
# EXTENSION_PROTOCOL = (5, 0x10) means that reserved[5] & 0x10 should be true.
|
|
||||||
DHT = (7, 0x01)
|
|
||||||
EXTENSION_PROTOCOL = (5, 0x10)
|
|
||||||
|
|
||||||
|
|
||||||
def reserved_feature_set_to_reserved(reserved_feature_set: typing.Set[ReservedFeature]) -> bytes:
|
|
||||||
reserved = 8 * b"\x00"
|
|
||||||
for reserved_feature in reserved_feature_set:
|
|
||||||
reserved[reserved_feature.value[0]] |= reserved_feature.value[1]
|
|
||||||
return reserved
|
|
||||||
|
|
||||||
|
|
||||||
def reserved_to_reserved_feature_set(reserved: bytes) -> typing.Set[ReservedFeature]:
|
|
||||||
return {
|
|
||||||
reserved_feature
|
|
||||||
for reserved_feature in ReservedFeature
|
|
||||||
if reserved[reserved_feature.value[0]] & reserved_feature.value[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@enum.unique
|
|
||||||
class ExtensionType(enum.IntEnum):
|
|
||||||
UT_METADATA = 1
|
|
@ -1,396 +0,0 @@
|
|||||||
# magneticod - Autonomous BitTorrent DHT crawler and metadata fetcher.
|
|
||||||
# Copyright (C) 2017 Mert Bora ALPER <bora@boramalper.org>
|
|
||||||
# Dedicated to Cemile Binay, in whose hands I thrived.
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
|
||||||
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
|
|
||||||
# later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
||||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
|
||||||
# <http://www.gnu.org/licenses/>.
|
|
||||||
import asyncio
|
|
||||||
import enum
|
|
||||||
import typing
|
|
||||||
|
|
||||||
import cerberus
|
|
||||||
|
|
||||||
from . import transport
|
|
||||||
|
|
||||||
|
|
||||||
class Protocol:
|
|
||||||
def __init__(self, client_version: bytes):
|
|
||||||
self._client_version = client_version
|
|
||||||
self._transport = transport.Transport()
|
|
||||||
|
|
||||||
self._transport.on_message = self.__when_message
|
|
||||||
|
|
||||||
async def launch(self, address: transport.Address):
|
|
||||||
await asyncio.get_event_loop().create_datagram_endpoint(lambda: self._transport, local_addr=address)
|
|
||||||
|
|
||||||
# Offered Functionality
|
|
||||||
# =====================
|
|
||||||
def make_query(self, query: BaseQuery, address: transport.Address) -> None:
|
|
||||||
return self._transport.send_message(query.to_message(b"\0\0", self._client_version), address)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_ping_query(query: PingQuery, address: transport.Address) \
|
|
||||||
-> typing.Optional[typing.Union[PingResponse, Error]]:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_find_node_query(query: FindNodeQuery, address: transport.Address) \
|
|
||||||
-> typing.Optional[typing.Union[FindNodeResponse, Error]]:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_get_peers_query(query: GetPeersQuery, address: transport.Address) \
|
|
||||||
-> typing.Optional[typing.Union[GetPeersResponse, Error]]:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_announce_peer_query(query: AnnouncePeerQuery, address: transport.Address) \
|
|
||||||
-> typing.Optional[typing.Union[AnnouncePeerResponse, Error]]:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_ping_OR_announce_peer_response(response: PingResponse, address: transport.Address) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_find_node_response(response: FindNodeResponse, address: transport.Address) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_get_peers_response(response: GetPeersResponse, address: transport.Address) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_error(error: Error, address: transport.Address) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Private Functionality
|
|
||||||
# =====================
|
|
||||||
def __when_message(self, message: typing.Dict[bytes, typing.Any], address: transport.Address) -> None:
|
|
||||||
# We need to ignore unknown fields in the messages, in consideration of forward-compatibility, but that also
|
|
||||||
# requires us to be careful about the "order" we are following. For instance, every single query can also be
|
|
||||||
# misunderstood as a ping query, since they all have `id` as an argument. Hence, we start validating against the
|
|
||||||
# query/response type that is most distinguishing against all other.
|
|
||||||
|
|
||||||
if BaseQuery.validate_message(message):
|
|
||||||
args = message[b"a"]
|
|
||||||
if AnnouncePeerQuery.validate_message(message):
|
|
||||||
response = self.on_announce_peer_query(AnnouncePeerQuery(
|
|
||||||
args[b"id"], args[b"info_hash"], args[b"port"], args[b"token"], args[b"implied_port"]
|
|
||||||
), address)
|
|
||||||
elif GetPeersQuery.validate_message(message):
|
|
||||||
response = self.on_get_peers_query(GetPeersQuery(args[b"id"], args[b"info_hash"]), address)
|
|
||||||
elif FindNodeQuery.validate_message(message):
|
|
||||||
response = self.on_find_node_query(FindNodeQuery(args[b"id"], args[b"target"]), address)
|
|
||||||
elif PingQuery.validate_message(message):
|
|
||||||
response = self.on_ping_query(PingQuery(args[b"id"]), address)
|
|
||||||
else:
|
|
||||||
# Unknown Query received!
|
|
||||||
response = None
|
|
||||||
if response:
|
|
||||||
self._transport.send_message(response.to_message(message[b"t"], self._client_version), address)
|
|
||||||
|
|
||||||
elif BaseResponse.validate_message(message):
|
|
||||||
return_values = message[b"r"]
|
|
||||||
if GetPeersResponse.validate_message(message):
|
|
||||||
if b"nodes" in return_values:
|
|
||||||
self.on_get_peers_response(GetPeersResponse(
|
|
||||||
return_values[b"id"], return_values[b"token"], nodes=return_values[b"nodes"]
|
|
||||||
), address)
|
|
||||||
else:
|
|
||||||
self.on_get_peers_response(GetPeersResponse(
|
|
||||||
return_values[b"id"], return_values[b"token"], values=return_values[b"values"]
|
|
||||||
), address)
|
|
||||||
elif FindNodeResponse.validate_message(message):
|
|
||||||
self.on_find_node_response(FindNodeResponse(return_values[b"id"], return_values[b"nodes"]), address)
|
|
||||||
elif PingResponse.validate_message(message):
|
|
||||||
self.on_ping_OR_announce_peer_response(PingResponse(return_values[b"id"]), address)
|
|
||||||
else:
|
|
||||||
# Unknown Response received!
|
|
||||||
pass
|
|
||||||
|
|
||||||
elif Error.validate_message(message):
|
|
||||||
if Error.validate_message(message):
|
|
||||||
self.on_error(Error(message[b"e"][0], message[b"e"][1]), address)
|
|
||||||
else:
|
|
||||||
# Erroneous Error received!
|
|
||||||
pass
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Unknown message received!
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
NodeID = typing.NewType("NodeID", bytes)
|
|
||||||
InfoHash = typing.NewType("InfoHash", bytes)
|
|
||||||
NodeInfo = typing.NamedTuple("NodeInfo", [
|
|
||||||
("id", NodeID),
|
|
||||||
("address", transport.Address),
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
class BaseQuery:
|
|
||||||
method_name = b""
|
|
||||||
_arguments_schema = {
|
|
||||||
b"id": {"type": "binary", "minlength": 20, "maxlength": 20, "required": True}
|
|
||||||
}
|
|
||||||
__validator = cerberus.Validator()
|
|
||||||
|
|
||||||
def __init__(self, id_: NodeID):
|
|
||||||
self.id = id_
|
|
||||||
|
|
||||||
def to_message(self, transaction_id: bytes, client_version: bytes) -> typing.Dict[bytes, typing.Any]:
|
|
||||||
return {
|
|
||||||
b"t": transaction_id,
|
|
||||||
b"y": b"q",
|
|
||||||
b"v": client_version,
|
|
||||||
b"q": self.method_name,
|
|
||||||
b"a": self.__dict__
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def validate_message(cls, message: typing.Dict[bytes, typing.Any]) -> bool:
|
|
||||||
if cls.__validator.schema is None:
|
|
||||||
cls.__validator.schema = cls.__get_message_schema()
|
|
||||||
|
|
||||||
return cls.__validator.validate(message)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def __get_message_schema(cls):
|
|
||||||
return {
|
|
||||||
b"t": {"type": "binary", "empty": False, "required": True},
|
|
||||||
b"y": {"type": "binary", "empty": False, "required": True},
|
|
||||||
b"v": {"type": "binary", "empty": False, "required": False},
|
|
||||||
b"q": {"type": "binary", "empty": False, "required": True},
|
|
||||||
b"a": cls._arguments_schema
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class PingQuery(BaseQuery):
|
|
||||||
method_name = b"ping"
|
|
||||||
|
|
||||||
def __init__(self, id_: NodeID):
|
|
||||||
super().__init__(id_)
|
|
||||||
|
|
||||||
|
|
||||||
class FindNodeQuery(BaseQuery):
|
|
||||||
method_name = b"find_node"
|
|
||||||
_arguments_schema = {
|
|
||||||
**super()._arguments_schema,
|
|
||||||
b"target": {"type": "binary", "minlength": 20, "maxlength": 20, "required": True}
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, id_: NodeID, target: NodeID):
|
|
||||||
super().__init__(id_)
|
|
||||||
self.target = target
|
|
||||||
|
|
||||||
|
|
||||||
class GetPeersQuery(BaseQuery):
|
|
||||||
method_name = b"get_peers"
|
|
||||||
_arguments_schema = {
|
|
||||||
**super()._arguments_schema,
|
|
||||||
b"info_hash": {"type": "binary", "minlength": 20, "maxlength": 20, "required": True}
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, id_: NodeID, info_hash: InfoHash):
|
|
||||||
super().__init__(id_)
|
|
||||||
self.info_hash = info_hash
|
|
||||||
|
|
||||||
|
|
||||||
class AnnouncePeerQuery(BaseQuery):
|
|
||||||
method_name = b"announce_peer"
|
|
||||||
_arguments_schema = {
|
|
||||||
**super()._arguments_schema,
|
|
||||||
b"info_hash": {"type": "binary", "minlength": 20, "maxlength": 20, "required": True},
|
|
||||||
b"port": {"type": "integer", "min": 1, "max": 2**16 - 1, "required": True},
|
|
||||||
b"token": {"type": "binary", "empty": False, "required": True},
|
|
||||||
b"implied_port": {"type": "integer", "required": False}
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, id_: NodeID, info_hash: InfoHash, port: int, token: bytes, implied_port: int=0):
|
|
||||||
super().__init__(id_)
|
|
||||||
self.info_hash = info_hash
|
|
||||||
self.port = port
|
|
||||||
self.token = token
|
|
||||||
self.implied_port = implied_port
|
|
||||||
|
|
||||||
|
|
||||||
class BaseResponse:
|
|
||||||
_return_values_schema = {
|
|
||||||
b"id": {"type": "binary", "minlength": 20, "maxlength": 20, "required": True}
|
|
||||||
}
|
|
||||||
__validator = cerberus.Validator()
|
|
||||||
|
|
||||||
def __init__(self, id_: NodeID):
|
|
||||||
self.id = id_
|
|
||||||
|
|
||||||
def to_message(self, transaction_id: bytes, client_version: bytes) -> typing.Dict[bytes, typing.Any]:
|
|
||||||
return {
|
|
||||||
b"t": transaction_id,
|
|
||||||
b"y": b"r",
|
|
||||||
b"v": client_version,
|
|
||||||
b"r": self._return_values()
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def validate_message(cls, message: typing.Dict[bytes, typing.Any]) -> bool:
|
|
||||||
if cls.__validator.schema is None:
|
|
||||||
cls.__validator.schema = cls.__get_message_schema()
|
|
||||||
|
|
||||||
return cls.__validator.validate(message)
|
|
||||||
|
|
||||||
def _return_values(self) -> typing.Dict[bytes, typing.Any]:
|
|
||||||
return {b"id": self.id}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def __get_message_schema(cls):
|
|
||||||
return {
|
|
||||||
b"t": {"type": "binary", "empty": False, "required": True},
|
|
||||||
b"y": {"type": "binary", "empty": False, "required": True},
|
|
||||||
b"v": {"type": "binary", "empty": False, "required": False},
|
|
||||||
b"r": cls._return_values_schema
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class PingResponse(BaseResponse):
|
|
||||||
def __init__(self, id_: NodeID):
|
|
||||||
super().__init__(id_)
|
|
||||||
|
|
||||||
|
|
||||||
class FindNodeResponse(BaseResponse):
|
|
||||||
_return_values_schema = {
|
|
||||||
**super()._return_values_schema,
|
|
||||||
b"nodes": {"type": "binary", "required": True}
|
|
||||||
}
|
|
||||||
__validator = cerberus.Validator()
|
|
||||||
|
|
||||||
def __init__(self, id_: NodeID, nodes: typing.List[NodeInfo]):
|
|
||||||
super().__init__(id_)
|
|
||||||
self.nodes = nodes
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def validate_message(cls, message: typing.Dict[bytes, typing.Any]) -> bool:
|
|
||||||
if cls.__validator.schema is None:
|
|
||||||
cls.__validator.schema = cls.__get_message_schema()
|
|
||||||
|
|
||||||
if not cls.__validator.validate(message):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Unfortunately, Cerberus cannot check some fine details.
|
|
||||||
# For instance, the length of the `nodes` field in the return values of the response message has to be a
|
|
||||||
# multiple of 26, as "contact information for nodes is encoded as a 26-byte string" (BEP 5).
|
|
||||||
if not message[b"r"][b"nodes"] % 26 == 0:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _return_values(self) -> typing.Dict[bytes, typing.Any]:
|
|
||||||
return {
|
|
||||||
**super()._return_values(),
|
|
||||||
b"nodes": self.nodes # TODO: this is not right obviously, encode & decode!
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class GetPeersResponse(BaseResponse):
|
|
||||||
_return_values_schema = {
|
|
||||||
**super()._return_values_schema,
|
|
||||||
b"token": {"type": "binary", "empty": False, "required": True},
|
|
||||||
b"values": {
|
|
||||||
"type": "list",
|
|
||||||
"schema": {"type": "binary", "minlength": 6, "maxlength": 6},
|
|
||||||
"excludes": b"nodes",
|
|
||||||
"empty": False,
|
|
||||||
"require": True
|
|
||||||
},
|
|
||||||
b"nodes": {"type": "binary", "excludes": b"values", "empty": True, "require": True}
|
|
||||||
}
|
|
||||||
__validator = cerberus.Validator()
|
|
||||||
|
|
||||||
def __init__(self, id_: NodeID, token: bytes, *, values: typing.Optional[typing.List[transport.Address]]=None,
|
|
||||||
nodes: typing.Optional[typing.List[NodeInfo]]=None
|
|
||||||
):
|
|
||||||
if not (values and nodes):
|
|
||||||
raise ValueError("Supply either `values` or `nodes` or neither but not both.")
|
|
||||||
|
|
||||||
super().__init__(id_)
|
|
||||||
self.token = token
|
|
||||||
self.values = values,
|
|
||||||
self.nodes = nodes
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def validate_message(cls, message: typing.Dict[bytes, typing.Any]) -> bool:
|
|
||||||
if cls.__validator.schema is None:
|
|
||||||
cls.__validator.schema = cls.__get_message_schema()
|
|
||||||
|
|
||||||
if not cls.__validator.validate(message):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Unfortunately, Cerberus cannot check some fine details.
|
|
||||||
# For instance, the length of the `nodes` field in the return values of the response message has to be a
|
|
||||||
# multiple of 26, as "contact information for nodes is encoded as a 26-byte string" (BEP 5).
|
|
||||||
if b"nodes" in message[b"r"] ^ message[b"r"][b"nodes"] % 26 == 0:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class AnnouncePeerResponse(BaseResponse):
|
|
||||||
def __init__(self, id_: NodeID):
|
|
||||||
super().__init__(id_)
|
|
||||||
|
|
||||||
|
|
||||||
@enum.unique
|
|
||||||
class ErrorCodes(enum.IntEnum):
|
|
||||||
GENERIC = 201
|
|
||||||
SERVER = 202
|
|
||||||
PROTOCOL = 203
|
|
||||||
METHOD_UNKNOWN = 204
|
|
||||||
|
|
||||||
|
|
||||||
class Error:
|
|
||||||
__validator = cerberus.Validator()
|
|
||||||
|
|
||||||
def __init__(self, code: ErrorCodes, error_message: bytes):
|
|
||||||
self.code = code
|
|
||||||
self.error_message = error_message
|
|
||||||
|
|
||||||
def to_message(self, transaction_id: bytes, client_version: bytes) -> typing.Dict[bytes, typing.Any]:
|
|
||||||
return {
|
|
||||||
b"t": transaction_id,
|
|
||||||
b"y": b"e",
|
|
||||||
b"v": client_version,
|
|
||||||
b"e": [self.code, self.error_message]
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def validate_message(cls, message: typing.Dict[bytes, typing.Any]) -> bool:
|
|
||||||
if cls.__validator.schema is None:
|
|
||||||
cls.__validator.schema = cls.__get_message_schema()
|
|
||||||
|
|
||||||
if not cls.__validator.validate(message):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Unfortunately, Cerberus cannot check some fine details.
|
|
||||||
# For instance, the `e` field of the error message should be an array with first element being an integer, and
|
|
||||||
# the second element being a (binary) string.
|
|
||||||
if not (isinstance(message[b"e"], int) and isinstance(message[b"e"], bytes)):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def __get_message_schema(cls):
|
|
||||||
return {
|
|
||||||
b"t": {"type": "binary", "empty": False, "required": True},
|
|
||||||
b"y": {"type": "binary", "empty": False, "required": True},
|
|
||||||
b"v": {"type": "binary", "empty": False, "required": False},
|
|
||||||
b"e": {"type": "list", "minlength": 2, "maxlength": 2, "required": True}
|
|
||||||
}
|
|
@ -1,63 +0,0 @@
|
|||||||
# magneticod - Autonomous BitTorrent DHT crawler and metadata fetcher.
|
|
||||||
# Copyright (C) 2017 Mert Bora ALPER <bora@boramalper.org>
|
|
||||||
# Dedicated to Cemile Binay, in whose hands I thrived.
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
|
||||||
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
|
|
||||||
# later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
||||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
|
||||||
# <http://www.gnu.org/licenses/>.
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from ..protocols import bittorrent as bt_protocol
|
|
||||||
from magneticod import types
|
|
||||||
|
|
||||||
|
|
||||||
class MetadataService:
|
|
||||||
def __init__(self, peer_id: types.PeerID, info_hash: types.InfoHash):
|
|
||||||
self._protocol = bt_protocol.Protocol()
|
|
||||||
|
|
||||||
self._protocol.on_the_bittorrent_handshake_completed = self._when_the_bittorrent_handshake_completed
|
|
||||||
self._protocol.on_extension_handshake_completed = self._when_extension_handshake_completed
|
|
||||||
|
|
||||||
self._peer_id = peer_id
|
|
||||||
self._info_hash = info_hash
|
|
||||||
|
|
||||||
async def launch(self) -> None:
|
|
||||||
await self._protocol.launch()
|
|
||||||
|
|
||||||
self._protocol.initiate_the_bittorrent_handshake(
|
|
||||||
bt_protocol.reserved_feature_set_to_reserved({
|
|
||||||
bt_protocol.ReservedFeature.EXTENSION_PROTOCOL,
|
|
||||||
bt_protocol.ReservedFeature.DHT
|
|
||||||
}),
|
|
||||||
self._info_hash,
|
|
||||||
self._peer_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Offered Functionality
|
|
||||||
# =====================
|
|
||||||
@staticmethod
|
|
||||||
def on_fatal_failure() -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Private Functionality
|
|
||||||
# =====================
|
|
||||||
def _when_the_bittorrent_handshake_completed(
|
|
||||||
self,
|
|
||||||
reserved: bytes,
|
|
||||||
info_hash: types.InfoHash,
|
|
||||||
peer_id: types.PeerID
|
|
||||||
) -> None:
|
|
||||||
if bt_protocol.ReservedFeature.EXTENSION_PROTOCOL not in bt_protocol.reserved_to_reserved_feature_set(reserved):
|
|
||||||
logging.info("Peer does NOT support the extension protocol.")
|
|
||||||
self.on_fatal_failure()
|
|
||||||
self._protocol.send_extension_handshake({bt_protocol.ExtensionType.UT_METADATA})
|
|
||||||
|
|
||||||
def _when_extension_handshake_completed(self, payload: types.Dictionary) -> None:
|
|
||||||
pass
|
|
@ -1,92 +0,0 @@
|
|||||||
# magneticod - Autonomous BitTorrent DHT crawler and metadata fetcher.
|
|
||||||
# Copyright (C) 2017 Mert Bora ALPER <bora@boramalper.org>
|
|
||||||
# Dedicated to Cemile Binay, in whose hands I thrived.
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
|
||||||
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
|
|
||||||
# later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
||||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
|
||||||
# <http://www.gnu.org/licenses/>.
|
|
||||||
import asyncio
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
import typing
|
|
||||||
|
|
||||||
from magneticod import constants
|
|
||||||
from . import protocol
|
|
||||||
|
|
||||||
|
|
||||||
class PeerService:
|
|
||||||
def __init__(self):
|
|
||||||
self._protocol = protocol.Protocol(b"mc00")
|
|
||||||
|
|
||||||
self._protocol.on_get_peers_query = self._when_get_peers_query
|
|
||||||
self._protocol.on_announce_peer_query = self._when_announce_peer_query
|
|
||||||
self._protocol.on_find_node_response = self._when_find_node_response
|
|
||||||
|
|
||||||
self._true_node_id = os.urandom(20)
|
|
||||||
self._token_secret = os.urandom(4)
|
|
||||||
self._routing_table = {} # typing.Dict[protocol.NodeID, protocol.transport.Address]
|
|
||||||
|
|
||||||
self._tick_task = None
|
|
||||||
|
|
||||||
async def launch(self, address: protocol.transport.Address):
|
|
||||||
await self._protocol.launch(address)
|
|
||||||
self._tick_task = asyncio.ensure_future(self._tick_periodically())
|
|
||||||
|
|
||||||
# Offered Functionality
|
|
||||||
# =====================
|
|
||||||
@staticmethod
|
|
||||||
def on_peer(info_hash: protocol.InfoHash, address: protocol.transport.Address) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Private Functionality
|
|
||||||
# =====================
|
|
||||||
async def _tick_periodically(self) -> None:
|
|
||||||
while True:
|
|
||||||
if not self._routing_table:
|
|
||||||
await self._bootstrap()
|
|
||||||
else:
|
|
||||||
self._make_neighbors()
|
|
||||||
self._routing_table.clear()
|
|
||||||
await asyncio.sleep(constants.TICK_INTERVAL)
|
|
||||||
|
|
||||||
async def _bootstrap(self) -> None:
|
|
||||||
event_loop = asyncio.get_event_loop()
|
|
||||||
for node in constants.BOOTSTRAPPING_NODES:
|
|
||||||
for *_, address in await event_loop.getaddrinfo(*node, family=socket.AF_INET):
|
|
||||||
self._protocol.make_query(protocol.FindNodeQuery(self._true_node_id, os.urandom(20)), address)
|
|
||||||
|
|
||||||
def _make_neighbors(self) -> None:
|
|
||||||
for id_, address in self._routing_table.items():
|
|
||||||
self._protocol.make_query(
|
|
||||||
protocol.FindNodeQuery(id_[:15] + self._true_node_id[:5], os.urandom(20)),
|
|
||||||
address
|
|
||||||
)
|
|
||||||
|
|
||||||
def _when_get_peers_query(self, query: protocol.GetPeersQuery, address: protocol.transport.Address) \
|
|
||||||
-> typing.Optional[typing.Union[protocol.GetPeersResponse, protocol.Error]]:
|
|
||||||
return protocol.GetPeersResponse(query.info_hash[:15] + self._true_node_id[:5], self._calculate_token(address))
|
|
||||||
|
|
||||||
def _when_announce_peer_query(self, query: protocol.AnnouncePeerQuery, address: protocol.transport.Address) \
|
|
||||||
-> typing.Optional[typing.Union[protocol.AnnouncePeerResponse, protocol.Error]]:
|
|
||||||
if query.implied_port:
|
|
||||||
peer_address = (address[0], address[1])
|
|
||||||
else:
|
|
||||||
peer_address = (address[0], query.port)
|
|
||||||
self.on_info_hash_and_peer(query.info_hash, peer_address)
|
|
||||||
|
|
||||||
return protocol.AnnouncePeerResponse(query.info_hash[:15] + self._true_node_id[:5])
|
|
||||||
|
|
||||||
def _when_find_node_response(self, response: protocol.FindNodeResponse, address: protocol.transport.Address) \
|
|
||||||
-> None:
|
|
||||||
self._routing_table.update({node.id: node.address for node in response.nodes if node.address != 0})
|
|
||||||
|
|
||||||
def _calculate_token(self, address: protocol.transport.Address) -> bytes:
|
|
||||||
return hashlib.sha1(b"%s%d" % (socket.inet_aton(address[0]), socket.htons(address[1]))).digest()
|
|
@ -1,102 +0,0 @@
|
|||||||
# magneticod - Autonomous BitTorrent DHT crawler and metadata fetcher.
|
|
||||||
# Copyright (C) 2017 Mert Bora ALPER <bora@boramalper.org>
|
|
||||||
# Dedicated to Cemile Binay, in whose hands I thrived.
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
|
||||||
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
|
|
||||||
# later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
||||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
|
||||||
# <http://www.gnu.org/licenses/>.
|
|
||||||
import asyncio
|
|
||||||
import typing
|
|
||||||
|
|
||||||
from magneticod import types
|
|
||||||
|
|
||||||
|
|
||||||
class TCPTransport(asyncio.Protocol):
|
|
||||||
def __init__(self):
|
|
||||||
self._stream_transport = asyncio.Transport()
|
|
||||||
|
|
||||||
self._incoming = bytearray()
|
|
||||||
|
|
||||||
self._awaiting_the_bittorrent_handshake = True
|
|
||||||
|
|
||||||
async def launch(self):
|
|
||||||
await asyncio.get_event_loop().create_connection(lambda: self, "0.0.0.0", 0)
|
|
||||||
|
|
||||||
# Offered Functionality
|
|
||||||
# =====================
|
|
||||||
def initiate_the_bittorrent_handshake(self, reserved: bytes, info_hash: types.InfoHash, peer_id: types.PeerID) -> None:
|
|
||||||
self._stream_transport.write(b"\x13BitTorrent protocol%s%s%s" % (
|
|
||||||
reserved,
|
|
||||||
info_hash,
|
|
||||||
peer_id
|
|
||||||
))
|
|
||||||
|
|
||||||
def send_keepalive(self) -> None:
|
|
||||||
self._stream_transport.write(b"\x00\x00\x00\x00")
|
|
||||||
|
|
||||||
def send_message(self, type_: bytes, payload: typing.ByteString) -> None:
|
|
||||||
if len(type_) != 1:
|
|
||||||
raise ValueError("Argument `type_` must be a single byte!")
|
|
||||||
length = 1 + len(payload)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_keepalive() -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_message(type_: bytes, payload: typing.ByteString) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_the_bittorrent_handshake_completed(
|
|
||||||
reserved: typing.ByteString,
|
|
||||||
info_hash: types.InfoHash,
|
|
||||||
peer_id: types.PeerID
|
|
||||||
) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Private Functionality
|
|
||||||
# =====================
|
|
||||||
def connection_made(self, transport: asyncio.Transport) -> None:
|
|
||||||
self._stream_transport = transport
|
|
||||||
|
|
||||||
def data_received(self, data: typing.ByteString) -> None:
|
|
||||||
self._incoming += data
|
|
||||||
|
|
||||||
if self._awaiting_the_bittorrent_handshake:
|
|
||||||
if len(self._incoming) >= 68:
|
|
||||||
assert self._incoming.startswith(b"\x13BitTorrent protocol")
|
|
||||||
self.on_the_bittorrent_handshake_completed(
|
|
||||||
reserved=self._incoming[20:28],
|
|
||||||
info_hash=self._incoming[28:48],
|
|
||||||
peer_id=self._incoming[48:68]
|
|
||||||
)
|
|
||||||
self._incoming = self._incoming[68:]
|
|
||||||
self._awaiting_the_bittorrent_handshake = False
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Continue or Start the "usual" processing from here below
|
|
||||||
|
|
||||||
if len(self._incoming) >= 4 and len(self._incoming) - 1 >= int.from_bytes(self._incoming[:4], "big"):
|
|
||||||
if int.from_bytes(self._incoming[:4], "big"):
|
|
||||||
self.on_keepalive()
|
|
||||||
else:
|
|
||||||
self.on_message(self._incoming[4], self._incoming[5:])
|
|
||||||
|
|
||||||
def eof_received(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def connection_lost(self, exc: Exception) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class UTPTransport:
|
|
||||||
pass
|
|
@ -1,114 +0,0 @@
|
|||||||
# magneticod - Autonomous BitTorrent DHT crawler and metadata fetcher.
|
|
||||||
# Copyright (C) 2017 Mert Bora ALPER <bora@boramalper.org>
|
|
||||||
# Dedicated to Cemile Binay, in whose hands I thrived.
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
|
||||||
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
|
|
||||||
# later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
||||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
|
||||||
# <http://www.gnu.org/licenses/>.
|
|
||||||
import asyncio
|
|
||||||
import collections
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import typing
|
|
||||||
|
|
||||||
from . import codec
|
|
||||||
|
|
||||||
Address = typing.Tuple[str, int]
|
|
||||||
|
|
||||||
MessageQueueEntry = typing.NamedTuple("MessageQueueEntry", [
|
|
||||||
("queued_on", int),
|
|
||||||
("message", codec.Message),
|
|
||||||
("address", Address)
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
class Transport(asyncio.DatagramProtocol):
|
|
||||||
"""
|
|
||||||
Mainline DHT Transport
|
|
||||||
|
|
||||||
The signature `class Transport(asyncio.DatagramProtocol)` seems almost oxymoron, but it's indeed more sensible than
|
|
||||||
it first seems. `Transport` handles ALL that is related to transporting messages, which includes receiving them
|
|
||||||
(`asyncio.DatagramProtocol.datagram_received`), sending them (`asyncio.DatagramTransport.send_to`), pausing and
|
|
||||||
resuming writing as requested by the asyncio, and also handling operational errors.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self._datagram_transport = asyncio.DatagramTransport()
|
|
||||||
self._write_allowed = asyncio.Event()
|
|
||||||
self._queue_nonempty = asyncio.Event()
|
|
||||||
self._message_queue = collections.deque() # type: typing.Deque[MessageQueueEntry]
|
|
||||||
self._messenger_task = asyncio.Task(self._send_messages())
|
|
||||||
|
|
||||||
# Offered Functionality
|
|
||||||
# =====================
|
|
||||||
def send_message(self, message: codec.Message, address: Address) -> None:
|
|
||||||
self._message_queue.append(MessageQueueEntry(int(time.monotonic()), message, address))
|
|
||||||
if not self._queue_nonempty.is_set():
|
|
||||||
self._queue_nonempty.set()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def on_message(message: codec.Message, address: Address):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Private Functionality
|
|
||||||
# =====================
|
|
||||||
def connection_made(self, transport: asyncio.DatagramTransport) -> None:
|
|
||||||
self._datagram_transport = transport
|
|
||||||
self._write_allowed.set()
|
|
||||||
|
|
||||||
def datagram_received(self, data: bytes, address: Address) -> None:
|
|
||||||
# Ignore nodes that "uses" port 0, as we cannot communicate with them reliably across the different systems.
|
|
||||||
# See https://tools.cisco.com/security/center/viewAlert.x?alertId=19935 for slightly more details
|
|
||||||
if address[1] == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
message = codec.decode(data)
|
|
||||||
except codec.DecodeError:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not isinstance(message, dict):
|
|
||||||
return
|
|
||||||
|
|
||||||
self.on_message(message, address)
|
|
||||||
|
|
||||||
def error_received(self, exc: OSError):
|
|
||||||
logging.debug("Mainline DHT received error!", exc_info=exc)
|
|
||||||
|
|
||||||
def pause_writing(self):
|
|
||||||
self._write_allowed.clear()
|
|
||||||
|
|
||||||
def resume_writing(self):
|
|
||||||
self._write_allowed.set()
|
|
||||||
|
|
||||||
def connection_lost(self, exc: Exception):
|
|
||||||
if exc:
|
|
||||||
logging.fatal("Mainline DHT lost connection! (See the following log entry for the exception.)",
|
|
||||||
exc_info=exc
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logging.fatal("Mainline DHT lost connection!")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
async def _send_messages(self) -> None:
|
|
||||||
while True:
|
|
||||||
await asyncio.wait([self._write_allowed.wait(), self._queue_nonempty.wait()])
|
|
||||||
try:
|
|
||||||
queued_on, message, address = self._message_queue.pop()
|
|
||||||
except IndexError:
|
|
||||||
self._queue_nonempty.clear()
|
|
||||||
continue
|
|
||||||
|
|
||||||
if time.monotonic() - queued_on > 60:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._datagram_transport.sendto(codec.encode(message), address)
|
|
@ -1,8 +0,0 @@
|
|||||||
import typing
|
|
||||||
|
|
||||||
InfoHash = typing.ByteString
|
|
||||||
NodeID = typing.ByteString
|
|
||||||
PeerID = typing.ByteString
|
|
||||||
IPAddress = typing.Tuple[str, int]
|
|
||||||
|
|
||||||
Dictionary = typing.Dict[bytes, typing.Any]
|
|
@ -1,51 +0,0 @@
|
|||||||
from setuptools import find_packages, setup, Extension
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
def read_file(path):
|
|
||||||
with open(path) as file:
|
|
||||||
return file.read()
|
|
||||||
|
|
||||||
|
|
||||||
def run_setup():
|
|
||||||
install_requirements = [
|
|
||||||
"appdirs >= 1.4.3",
|
|
||||||
"humanfriendly",
|
|
||||||
"better_bencode >= 0.2.1",
|
|
||||||
"cerberus >= 1.1"
|
|
||||||
]
|
|
||||||
|
|
||||||
if sys.platform in ["linux", "darwin"]:
|
|
||||||
install_requirements.append("uvloop >= 0.8.0")
|
|
||||||
|
|
||||||
setup(
|
|
||||||
name="magneticod",
|
|
||||||
version="0.6.0",
|
|
||||||
description="Autonomous BitTorrent DHT crawler and metadata fetcher.",
|
|
||||||
long_description=read_file("README.rst"),
|
|
||||||
url="http://magnetico.org",
|
|
||||||
author="Mert Bora ALPER",
|
|
||||||
author_email="bora@boramalper.org",
|
|
||||||
license="GNU Affero General Public License v3 or later (AGPLv3+)",
|
|
||||||
packages=find_packages(),
|
|
||||||
zip_safe=False,
|
|
||||||
entry_points={
|
|
||||||
"console_scripts": ["magneticod=magneticod.__main__:main"]
|
|
||||||
},
|
|
||||||
|
|
||||||
install_requires=install_requirements,
|
|
||||||
|
|
||||||
classifiers=[
|
|
||||||
"Development Status :: 2 - Pre-Alpha",
|
|
||||||
"Environment :: No Input/Output (Daemon)",
|
|
||||||
"Intended Audience :: End Users/Desktop",
|
|
||||||
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
|
||||||
"Natural Language :: English",
|
|
||||||
"Operating System :: POSIX :: Linux",
|
|
||||||
"Programming Language :: Python :: 3 :: Only",
|
|
||||||
"Programming Language :: Python :: Implementation :: CPython",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
run_setup()
|
|
@ -1,10 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=magneticod: autonomous BitTorrent DHT crawler and metadata fetcher
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
ExecStart=~/.local/bin/magneticod --node-addr 0.0.0.0:PORT_NUMBER
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
4
pkg/.gitignore
vendored
Normal file
4
pkg/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# Ignore everything in this directory
|
||||||
|
*
|
||||||
|
# Except this file
|
||||||
|
!.gitignore
|
46
src/magneticod/Gopkg.toml
Normal file
46
src/magneticod/Gopkg.toml
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
|
||||||
|
# Gopkg.toml example
|
||||||
|
#
|
||||||
|
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
|
||||||
|
# for detailed Gopkg.toml documentation.
|
||||||
|
#
|
||||||
|
# required = ["github.com/user/thing/cmd/thing"]
|
||||||
|
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
|
||||||
|
#
|
||||||
|
# [[constraint]]
|
||||||
|
# name = "github.com/user/project"
|
||||||
|
# version = "1.0.0"
|
||||||
|
#
|
||||||
|
# [[constraint]]
|
||||||
|
# name = "github.com/user/project2"
|
||||||
|
# branch = "dev"
|
||||||
|
# source = "github.com/myfork/project2"
|
||||||
|
#
|
||||||
|
# [[override]]
|
||||||
|
# name = "github.com/x/y"
|
||||||
|
# version = "2.4.0"
|
||||||
|
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
branch = "master"
|
||||||
|
name = "github.com/anacrolix/missinggo"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
branch = "master"
|
||||||
|
name = "github.com/anacrolix/torrent"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
branch = "master"
|
||||||
|
name = "github.com/bradfitz/iter"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "github.com/jessevdk/go-flags"
|
||||||
|
version = "1.3.0"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "github.com/mattn/go-sqlite3"
|
||||||
|
version = "1.2.0"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "go.uber.org/zap"
|
||||||
|
version = "1.5.0"
|
67
src/magneticod/bittorrent/operations.go
Normal file
67
src/magneticod/bittorrent/operations.go
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
package bittorrent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/anacrolix/torrent"
|
||||||
|
"github.com/anacrolix/torrent/metainfo"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func (ms *MetadataSink) awaitMetadata(infoHash metainfo.Hash, peer torrent.Peer) {
|
||||||
|
zap.L().Sugar().Debugf("awaiting %x...", infoHash[:])
|
||||||
|
t, isNew := ms.client.AddTorrentInfoHash(infoHash)
|
||||||
|
t.AddPeers([]torrent.Peer{peer})
|
||||||
|
if !isNew {
|
||||||
|
// If the recently added torrent is not new, then quit as we do not want multiple
|
||||||
|
// awaitMetadata goroutines waiting on the same torrent.
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
defer t.Drop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the torrent client to receive the metadata for the torrent, meanwhile allowing
|
||||||
|
// termination to be handled gracefully.
|
||||||
|
select {
|
||||||
|
case <- ms.termination:
|
||||||
|
return
|
||||||
|
|
||||||
|
case <- t.GotInfo():
|
||||||
|
}
|
||||||
|
zap.L().Sugar().Warnf("==== GOT INFO for %x", infoHash[:])
|
||||||
|
|
||||||
|
info := t.Info()
|
||||||
|
var files []metainfo.FileInfo
|
||||||
|
if len(info.Files) == 0 {
|
||||||
|
if strings.ContainsRune(info.Name, '/') {
|
||||||
|
// A single file torrent cannot have any '/' characters in its name. We treat it as
|
||||||
|
// illegal.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
files = []metainfo.FileInfo{{Length: info.Length, Path:[]string{info.Name}}}
|
||||||
|
} else {
|
||||||
|
// TODO: We have to make sure that anacrolix/torrent checks for '/' character in file paths
|
||||||
|
// before concatenating them. This is currently assumed here. We should write a test for it.
|
||||||
|
files = info.Files
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalSize uint64
|
||||||
|
for _, file := range files {
|
||||||
|
if file.Length < 0 {
|
||||||
|
// All files' sizes must be greater than or equal to zero, otherwise treat them as
|
||||||
|
// illegal and ignore.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
totalSize += uint64(file.Length)
|
||||||
|
}
|
||||||
|
|
||||||
|
ms.flush(Metadata{
|
||||||
|
InfoHash: infoHash[:],
|
||||||
|
Name: info.Name,
|
||||||
|
TotalSize: totalSize,
|
||||||
|
DiscoveredOn: time.Now().Unix(),
|
||||||
|
Files: files,
|
||||||
|
})
|
||||||
|
}
|
86
src/magneticod/bittorrent/sink.go
Normal file
86
src/magneticod/bittorrent/sink.go
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
package bittorrent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"github.com/anacrolix/torrent"
|
||||||
|
"github.com/anacrolix/torrent/metainfo"
|
||||||
|
|
||||||
|
"magneticod/dht/mainline"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
type Metadata struct {
|
||||||
|
InfoHash []byte
|
||||||
|
// Name should be thought of "Title" of the torrent. For single-file torrents, it is the name
|
||||||
|
// of the file, and for multi-file torrents, it is the name of the root directory.
|
||||||
|
Name string
|
||||||
|
TotalSize uint64
|
||||||
|
DiscoveredOn int64
|
||||||
|
// Files must be populated for both single-file and multi-file torrents!
|
||||||
|
Files []metainfo.FileInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type MetadataSink struct {
|
||||||
|
activeInfoHashes []metainfo.Hash
|
||||||
|
client *torrent.Client
|
||||||
|
drain chan Metadata
|
||||||
|
terminated bool
|
||||||
|
termination chan interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func NewMetadataSink(laddr net.TCPAddr) *MetadataSink {
|
||||||
|
ms := new(MetadataSink)
|
||||||
|
var err error
|
||||||
|
ms.client, err = torrent.NewClient(&torrent.Config{
|
||||||
|
ListenAddr: laddr.String(),
|
||||||
|
DisableTrackers: true,
|
||||||
|
DisablePEX: true,
|
||||||
|
// TODO: Should we disable DHT to force the client to use the peers we supplied only, or not?
|
||||||
|
NoDHT: false,
|
||||||
|
PreferNoEncryption: true,
|
||||||
|
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Fatal("Fetcher could NOT create a new torrent client!", zap.Error(err))
|
||||||
|
}
|
||||||
|
ms.drain = make(chan Metadata)
|
||||||
|
ms.termination = make(chan interface{})
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (ms *MetadataSink) Sink(res mainline.TrawlingResult) {
|
||||||
|
if ms.terminated {
|
||||||
|
zap.L().Panic("Trying to Sink() an already closed MetadataSink!")
|
||||||
|
}
|
||||||
|
|
||||||
|
ms.activeInfoHashes = append(ms.activeInfoHashes, res.InfoHash)
|
||||||
|
go ms.awaitMetadata(res.InfoHash, res.Peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (ms *MetadataSink) Drain() <-chan Metadata {
|
||||||
|
if ms.terminated {
|
||||||
|
zap.L().Panic("Trying to Drain() an already closed MetadataSink!")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ms.drain
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (ms *MetadataSink) Terminate() {
|
||||||
|
ms.terminated = true
|
||||||
|
close(ms.termination)
|
||||||
|
ms.client.Close()
|
||||||
|
close(ms.drain)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (ms *MetadataSink) flush(metadata Metadata) {
|
||||||
|
if ms.terminated {
|
||||||
|
ms.drain <- metadata
|
||||||
|
}
|
||||||
|
}
|
260
src/magneticod/dht/mainline/codec.go
Normal file
260
src/magneticod/dht/mainline/codec.go
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
|
||||||
|
// TODO: This file, as a whole, needs a little skim-through to clear things up, sprinkle a little
|
||||||
|
// documentation here and there, and also to make the test coverage 100%.
|
||||||
|
// It, most importantly, lacks IPv6 support, if it's not altogether messy and unreliable
|
||||||
|
// (hint: it is).
|
||||||
|
|
||||||
|
package mainline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"encoding/binary"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/anacrolix/torrent/bencode"
|
||||||
|
"github.com/anacrolix/missinggo/iter"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
// Query method (one of 4: "ping", "find_node", "get_peers", "announce_peer")
|
||||||
|
Q string `bencode:"q,omitempty"`
|
||||||
|
// named QueryArguments sent with a query
|
||||||
|
A QueryArguments `bencode:"a,omitempty"`
|
||||||
|
// required: transaction ID
|
||||||
|
T []byte `bencode:"t"`
|
||||||
|
// required: type of the message: q for QUERY, r for RESPONSE, e for ERROR
|
||||||
|
Y string `bencode:"y"`
|
||||||
|
// RESPONSE type only
|
||||||
|
R ResponseValues `bencode:"r,omitempty"`
|
||||||
|
// ERROR type only
|
||||||
|
E Error `bencode:"e,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type QueryArguments struct {
|
||||||
|
// ID of the quirying Node
|
||||||
|
ID []byte `bencode:"id"`
|
||||||
|
// InfoHash of the torrent
|
||||||
|
InfoHash []byte `bencode:"info_hash,omitempty"`
|
||||||
|
// ID of the node sought
|
||||||
|
Target []byte `bencode:"target,omitempty"`
|
||||||
|
// Token received from an earlier get_peers query
|
||||||
|
Token []byte `bencode:"token,omitempty"`
|
||||||
|
// Senders torrent port
|
||||||
|
Port int `bencode:"port,omitempty"`
|
||||||
|
// Use senders apparent DHT port
|
||||||
|
ImpliedPort int `bencode:"implied_port,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type ResponseValues struct {
|
||||||
|
// ID of the querying node
|
||||||
|
ID []byte `bencode:"id"`
|
||||||
|
// K closest nodes to the requested target
|
||||||
|
Nodes CompactNodeInfos `bencode:"nodes,omitempty"`
|
||||||
|
// Token for future announce_peer
|
||||||
|
Token []byte `bencode:"token,omitempty"`
|
||||||
|
// Torrent peers
|
||||||
|
Values []CompactPeer `bencode:"values,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
Code int
|
||||||
|
Message []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Represents peer address in either IPv6 or IPv4 form.
|
||||||
|
type CompactPeer struct {
|
||||||
|
IP net.IP
|
||||||
|
Port int
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type CompactPeers []CompactPeer
|
||||||
|
|
||||||
|
|
||||||
|
type CompactNodeInfo struct {
|
||||||
|
ID []byte
|
||||||
|
Addr net.UDPAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type CompactNodeInfos []CompactNodeInfo
|
||||||
|
|
||||||
|
|
||||||
|
// This allows bencode.Unmarshal to do better than a string or []byte.
|
||||||
|
func (cps *CompactPeers) UnmarshalBencode(b []byte) (err error) {
|
||||||
|
var bb []byte
|
||||||
|
err = bencode.Unmarshal(b, &bb)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*cps, err = UnmarshalCompactPeers(bb)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cps CompactPeers) MarshalBinary() (ret []byte, err error) {
|
||||||
|
ret = make([]byte, len(cps)*6)
|
||||||
|
for i, cp := range cps {
|
||||||
|
copy(ret[6*i:], cp.IP.To4())
|
||||||
|
binary.BigEndian.PutUint16(ret[6*i+4:], uint16(cp.Port))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (cp CompactPeer) MarshalBencode() (ret []byte, err error) {
|
||||||
|
ip := cp.IP
|
||||||
|
if ip4 := ip.To4(); ip4 != nil {
|
||||||
|
ip = ip4
|
||||||
|
}
|
||||||
|
ret = make([]byte, len(ip)+2)
|
||||||
|
copy(ret, ip)
|
||||||
|
binary.BigEndian.PutUint16(ret[len(ip):], uint16(cp.Port))
|
||||||
|
return bencode.Marshal(ret)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (cp *CompactPeer) UnmarshalBinary(b []byte) error {
|
||||||
|
switch len(b) {
|
||||||
|
case 18:
|
||||||
|
cp.IP = make([]byte, 16)
|
||||||
|
case 6:
|
||||||
|
cp.IP = make([]byte, 4)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("bad compact peer string: %q", b)
|
||||||
|
}
|
||||||
|
copy(cp.IP, b)
|
||||||
|
b = b[len(cp.IP):]
|
||||||
|
cp.Port = int(binary.BigEndian.Uint16(b))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (cp *CompactPeer) UnmarshalBencode(b []byte) (err error) {
|
||||||
|
var _b []byte
|
||||||
|
err = bencode.Unmarshal(b, &_b)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return cp.UnmarshalBinary(_b)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func UnmarshalCompactPeers(b []byte) (ret []CompactPeer, err error) {
|
||||||
|
num := len(b) / 6
|
||||||
|
ret = make([]CompactPeer, num)
|
||||||
|
for i := range iter.N(num) {
|
||||||
|
off := i * 6
|
||||||
|
err = ret[i].UnmarshalBinary(b[off : off+6])
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// This allows bencode.Unmarshal to do better than a string or []byte.
|
||||||
|
func (cnis *CompactNodeInfos) UnmarshalBencode(b []byte) (err error) {
|
||||||
|
var bb []byte
|
||||||
|
err = bencode.Unmarshal(b, &bb)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*cnis, err = UnmarshalCompactNodeInfos(bb)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func UnmarshalCompactNodeInfos(b []byte) (ret []CompactNodeInfo, err error) {
|
||||||
|
if len(b) % 26 != 0 {
|
||||||
|
err = fmt.Errorf("compact node is not a multiple of 26")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
num := len(b) / 26
|
||||||
|
ret = make([]CompactNodeInfo, num)
|
||||||
|
for i := range iter.N(num) {
|
||||||
|
off := i * 26
|
||||||
|
ret[i].ID = make([]byte, 20)
|
||||||
|
err = ret[i].UnmarshalBinary(b[off : off+26])
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (cni *CompactNodeInfo) UnmarshalBinary(b []byte) error {
|
||||||
|
copy(cni.ID[:], b)
|
||||||
|
b = b[len(cni.ID):]
|
||||||
|
cni.Addr.IP = make([]byte, 4)
|
||||||
|
copy(cni.Addr.IP, b)
|
||||||
|
b = b[len(cni.Addr.IP):]
|
||||||
|
cni.Addr.Port = int(binary.BigEndian.Uint16(b))
|
||||||
|
cni.Addr.Zone = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (cnis CompactNodeInfos) MarshalBencode() ([]byte, error) {
|
||||||
|
ret := make([]byte, 0) // TODO: this doesn't look idiomatic at all, is this the right way?
|
||||||
|
|
||||||
|
for _, cni := range cnis {
|
||||||
|
ret = append(ret, cni.MarshalBinary()...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bencode.Marshal(ret)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (cni CompactNodeInfo) MarshalBinary() []byte {
|
||||||
|
ret := make([]byte, 20)
|
||||||
|
|
||||||
|
copy(ret, cni.ID)
|
||||||
|
ret = append(ret, cni.Addr.IP.To4()...)
|
||||||
|
|
||||||
|
portEncoding := make([]byte, 2)
|
||||||
|
binary.BigEndian.PutUint16(portEncoding, uint16(cni.Addr.Port))
|
||||||
|
ret = append(ret, portEncoding...)
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (e Error) MarshalBencode() ([]byte, error) {
|
||||||
|
return []byte(fmt.Sprintf("li%de%d:%se", e.Code, len(e.Message), e.Message)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (e *Error) UnmarshalBencode(b []byte) (err error) {
|
||||||
|
var code, msgLen int
|
||||||
|
|
||||||
|
regex := regexp.MustCompile(`li([0-9]+)e([0-9]+):(.+)e`)
|
||||||
|
// I don't know how to use regexp.Regexp.FindAllSubmatch:
|
||||||
|
// TODO: Why three level deep slices?
|
||||||
|
// TODO: What is @n?
|
||||||
|
matches := regex.FindAllSubmatch(b, 1)[0][1:]
|
||||||
|
if _, err := fmt.Sscanf(string(matches[0]), "%d", &code); err != nil {
|
||||||
|
return fmt.Errorf("could not parse the error code: %s", err.Error())
|
||||||
|
}
|
||||||
|
if _, err := fmt.Sscanf(string(matches[1]), "%d", &msgLen); err != nil {
|
||||||
|
return fmt.Errorf("could not parse the error message length: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches[2]) != msgLen {
|
||||||
|
return
|
||||||
|
return fmt.Errorf("error message have different lengths (%d vs %d) \"%s\"!", len(matches[2]), msgLen, matches[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Code = code
|
||||||
|
e.Message = matches[2]
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
234
src/magneticod/dht/mainline/codec_test.go
Normal file
234
src/magneticod/dht/mainline/codec_test.go
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
package mainline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/anacrolix/torrent/bencode"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
var codecTest_validInstances = []struct{
|
||||||
|
data []byte
|
||||||
|
msg Message
|
||||||
|
}{
|
||||||
|
// ping Query:
|
||||||
|
{
|
||||||
|
data: []byte("d1:ad2:id20:abcdefghij0123456789e1:q4:ping1:t2:aa1:y1:qe"),
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("aa"),
|
||||||
|
Y: "q",
|
||||||
|
Q: "ping",
|
||||||
|
A: QueryArguments{
|
||||||
|
ID: []byte("abcdefghij0123456789"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ping or announce_peer Response:
|
||||||
|
// Also, includes NUL and EOT characters as transaction ID (`t`).
|
||||||
|
{
|
||||||
|
data: []byte("d1:rd2:id20:mnopqrstuvwxyz123456e1:t2:\x00\x041:y1:re"),
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("\x00\x04"),
|
||||||
|
Y: "r",
|
||||||
|
R: ResponseValues{
|
||||||
|
ID: []byte("mnopqrstuvwxyz123456"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// find_node Query:
|
||||||
|
{
|
||||||
|
data: []byte("d1:ad2:id20:abcdefghij01234567896:target20:mnopqrstuvwxyz123456e1:q9:find_node1:t2:\x09\x0a1:y1:qe"),
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("\x09\x0a"),
|
||||||
|
Y: "q",
|
||||||
|
Q: "find_node",
|
||||||
|
A: QueryArguments{
|
||||||
|
ID: []byte("abcdefghij0123456789"),
|
||||||
|
Target: []byte("mnopqrstuvwxyz123456"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// find_node Response with no nodes (`nodes` key still exists):
|
||||||
|
{
|
||||||
|
data: []byte("d1:rd2:id20:0123456789abcdefghij5:nodes0:e1:t2:aa1:y1:re"),
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("aa"),
|
||||||
|
Y: "r",
|
||||||
|
R: ResponseValues{
|
||||||
|
ID: []byte("0123456789abcdefghij"),
|
||||||
|
Nodes: []CompactNodeInfo{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// find_node Response with a single node:
|
||||||
|
{
|
||||||
|
data: []byte("d1:rd2:id20:0123456789abcdefghij5:nodes26:abcdefghijklmnopqrst\x8b\x82\x8e\xf5\x0cae1:t2:aa1:y1:re"),
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("aa"),
|
||||||
|
Y: "r",
|
||||||
|
R: ResponseValues{
|
||||||
|
ID: []byte("0123456789abcdefghij"),
|
||||||
|
Nodes: []CompactNodeInfo{
|
||||||
|
{
|
||||||
|
ID: []byte("abcdefghijklmnopqrst"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\x8b\x82\x8e\xf5"), Port: 3169, Zone: ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// find_node Response with 8 nodes (all the same except the very last one):
|
||||||
|
{
|
||||||
|
data: []byte("d1:rd2:id20:0123456789abcdefghij5:nodes208:abcdefghijklmnopqrst\x8b\x82\x8e\xf5\x0caabcdefghijklmnopqrst\x8b\x82\x8e\xf5\x0caabcdefghijklmnopqrst\x8b\x82\x8e\xf5\x0caabcdefghijklmnopqrst\x8b\x82\x8e\xf5\x0caabcdefghijklmnopqrst\x8b\x82\x8e\xf5\x0caabcdefghijklmnopqrst\x8b\x82\x8e\xf5\x0caabcdefghijklmnopqrst\x8b\x82\x8e\xf5\x0cazyxwvutsrqponmlkjihg\xf5\x8e\x82\x8b\x1b\x13e1:t2:aa1:y1:re"),
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("aa"),
|
||||||
|
Y: "r",
|
||||||
|
R: ResponseValues{
|
||||||
|
ID: []byte("0123456789abcdefghij"),
|
||||||
|
Nodes: []CompactNodeInfo{
|
||||||
|
{
|
||||||
|
ID: []byte("abcdefghijklmnopqrst"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\x8b\x82\x8e\xf5"), Port: 3169, Zone: ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: []byte("abcdefghijklmnopqrst"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\x8b\x82\x8e\xf5"), Port: 3169, Zone: ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: []byte("abcdefghijklmnopqrst"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\x8b\x82\x8e\xf5"), Port: 3169, Zone: ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: []byte("abcdefghijklmnopqrst"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\x8b\x82\x8e\xf5"), Port: 3169, Zone: ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: []byte("abcdefghijklmnopqrst"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\x8b\x82\x8e\xf5"), Port: 3169, Zone: ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: []byte("abcdefghijklmnopqrst"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\x8b\x82\x8e\xf5"), Port: 3169, Zone: ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: []byte("abcdefghijklmnopqrst"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\x8b\x82\x8e\xf5"), Port: 3169, Zone: ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: []byte("zyxwvutsrqponmlkjihg"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\xf5\x8e\x82\x8b"), Port: 6931, Zone: ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// get_peers Query:
|
||||||
|
{
|
||||||
|
data: []byte("d1:ad2:id20:abcdefghij01234567899:info_hash20:mnopqrstuvwxyz123456e1:q9:get_peers1:t2:aa1:y1:qe"),
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("aa"),
|
||||||
|
Y: "q",
|
||||||
|
Q: "get_peers",
|
||||||
|
A: QueryArguments{
|
||||||
|
ID: []byte("abcdefghij0123456789"),
|
||||||
|
InfoHash: []byte("mnopqrstuvwxyz123456"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// get_peers Response with 2 peers (`values`):
|
||||||
|
{
|
||||||
|
data: []byte("d1:rd2:id20:abcdefghij01234567895:token8:aoeusnth6:valuesl6:axje.u6:idhtnmee1:t2:aa1:y1:re"),
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("aa"),
|
||||||
|
Y: "r",
|
||||||
|
R: ResponseValues{
|
||||||
|
ID: []byte("abcdefghij0123456789"),
|
||||||
|
Token: []byte("aoeusnth"),
|
||||||
|
Values: []CompactPeer{
|
||||||
|
{IP: []byte("axje"), Port: 11893},
|
||||||
|
{IP: []byte("idht"), Port: 28269},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// get_peers Response with 2 closest nodes (`nodes`):
|
||||||
|
{
|
||||||
|
data: []byte("d1:rd2:id20:abcdefghij01234567895:nodes52:abcdefghijklmnopqrst\x8b\x82\x8e\xf5\x0cazyxwvutsrqponmlkjihg\xf5\x8e\x82\x8b\x1b\x135:token8:aoeusnthe1:t2:aa1:y1:re"),
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("aa"),
|
||||||
|
Y: "r",
|
||||||
|
R: ResponseValues{
|
||||||
|
ID: []byte("abcdefghij0123456789"),
|
||||||
|
Token: []byte("aoeusnth"),
|
||||||
|
Nodes: []CompactNodeInfo{
|
||||||
|
{
|
||||||
|
ID: []byte("abcdefghijklmnopqrst"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\x8b\x82\x8e\xf5"), Port: 3169, Zone: ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: []byte("zyxwvutsrqponmlkjihg"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\xf5\x8e\x82\x8b"), Port: 6931, Zone: ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// announce_peer Query without optional `implied_port` argument:
|
||||||
|
{
|
||||||
|
data: []byte("d1:ad2:id20:abcdefghij01234567899:info_hash20:mnopqrstuvwxyz1234564:porti6881e5:token8:aoeusnthe1:q13:announce_peer1:t2:aa1:y1:qe"),
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("aa"),
|
||||||
|
Y: "q",
|
||||||
|
Q: "announce_peer",
|
||||||
|
A: QueryArguments{
|
||||||
|
ID: []byte("abcdefghij0123456789"),
|
||||||
|
InfoHash: []byte("mnopqrstuvwxyz123456"),
|
||||||
|
Port: 6881,
|
||||||
|
Token: []byte("aoeusnth"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: []byte("d1:eli201e23:A Generic Error Ocurrede1:t2:aa1:y1:ee"),
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("aa"),
|
||||||
|
Y: "e",
|
||||||
|
E: Error{Code: 201, Message: []byte("A Generic Error Ocurred")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// TODO: Test Error where E.Message is an empty string, and E.Message contains invalid Unicode characters.
|
||||||
|
// TODO: Add announce_peer Query with optional `implied_port` argument.
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestUnmarshal(t *testing.T) {
|
||||||
|
for i, instance := range codecTest_validInstances {
|
||||||
|
msg := Message{}
|
||||||
|
err := bencode.Unmarshal(instance.data, &msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Error while unmarshalling valid data #%d: %v", i + 1, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if reflect.DeepEqual(msg, instance.msg) != true {
|
||||||
|
t.Errorf("Valid data #%d unmarshalled wrong!\n\tGot : %+v\n\tExpected: %+v",
|
||||||
|
i + 1, msg, instance.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestMarshal(t *testing.T) {
|
||||||
|
for i, instance := range codecTest_validInstances {
|
||||||
|
data, err := bencode.Marshal(instance.msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Error while marshalling valid msg #%d: %v", i + 1, err)
|
||||||
|
}
|
||||||
|
if bytes.Equal(data, instance.data) != true {
|
||||||
|
t.Errorf("Valid msg #%d marshalled wrong!\n\tGot : %q\n\tExpected: %q",
|
||||||
|
i + 1, data, instance.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
300
src/magneticod/dht/mainline/protocol.go
Normal file
300
src/magneticod/dht/mainline/protocol.go
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
package mainline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha1"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
type Protocol struct {
|
||||||
|
previousTokenSecret, currentTokenSecret []byte
|
||||||
|
tokenLock sync.Mutex
|
||||||
|
transport *Transport
|
||||||
|
eventHandlers ProtocolEventHandlers
|
||||||
|
started bool
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type ProtocolEventHandlers struct {
|
||||||
|
OnPingQuery func(*Message, net.Addr)
|
||||||
|
OnFindNodeQuery func(*Message, net.Addr)
|
||||||
|
OnGetPeersQuery func(*Message, net.Addr)
|
||||||
|
OnAnnouncePeerQuery func(*Message, net.Addr)
|
||||||
|
OnGetPeersResponse func(*Message, net.Addr)
|
||||||
|
OnFindNodeResponse func(*Message, net.Addr)
|
||||||
|
OnPingORAnnouncePeerResponse func(*Message, net.Addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func NewProtocol(laddr net.UDPAddr, eventHandlers ProtocolEventHandlers) (p *Protocol) {
|
||||||
|
p = new(Protocol)
|
||||||
|
p.transport = NewTransport(laddr, p.onMessage)
|
||||||
|
p.eventHandlers = eventHandlers
|
||||||
|
|
||||||
|
p.currentTokenSecret, p.previousTokenSecret = make([]byte, 20), make([]byte, 20)
|
||||||
|
_, err := rand.Read(p.currentTokenSecret)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Fatal("Could NOT generate random bytes for token secret!", zap.Error(err))
|
||||||
|
}
|
||||||
|
copy(p.previousTokenSecret, p.currentTokenSecret)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (p *Protocol) Start() {
|
||||||
|
if p.started {
|
||||||
|
zap.L().Panic("Attempting to Start() a mainline/Transport that has been already started! (Programmer error.)")
|
||||||
|
}
|
||||||
|
p.started = true
|
||||||
|
|
||||||
|
p.transport.Start()
|
||||||
|
go p.updateTokenSecret()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (p *Protocol) Terminate() {
|
||||||
|
p.transport.Terminate()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (p *Protocol) onMessage(msg *Message, addr net.Addr) {
|
||||||
|
switch msg.Y {
|
||||||
|
case "q":
|
||||||
|
switch msg.Q {
|
||||||
|
case "ping":
|
||||||
|
if !validatePingQueryMessage(msg) {
|
||||||
|
zap.L().Debug("An invalid ping query received!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Check whether there is a registered event handler for the ping queries, before
|
||||||
|
// attempting to call.
|
||||||
|
if p.eventHandlers.OnPingQuery != nil {
|
||||||
|
p.eventHandlers.OnPingQuery(msg, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "find_node":
|
||||||
|
if !validateFindNodeQueryMessage(msg) {
|
||||||
|
zap.L().Debug("An invalid find_node query received!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.eventHandlers.OnFindNodeQuery != nil {
|
||||||
|
p.eventHandlers.OnFindNodeQuery(msg, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "get_peers":
|
||||||
|
if !validateGetPeersQueryMessage(msg) {
|
||||||
|
zap.L().Debug("An invalid get_peers query received!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.eventHandlers.OnGetPeersQuery != nil {
|
||||||
|
p.eventHandlers.OnGetPeersQuery(msg, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "announce_peer":
|
||||||
|
if !validateAnnouncePeerQueryMessage(msg) {
|
||||||
|
zap.L().Debug("An invalid announce_peer query received!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.eventHandlers.OnAnnouncePeerQuery != nil {
|
||||||
|
p.eventHandlers.OnAnnouncePeerQuery(msg, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
zap.L().Debug("A KRPC query of an unknown method received!",
|
||||||
|
zap.String("method", msg.Q))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "r":
|
||||||
|
// get_peers > find_node > ping / announce_peer
|
||||||
|
if len(msg.R.Token) != 0 { // The message should be a get_peers response.
|
||||||
|
if !validateGetPeersResponseMessage(msg) {
|
||||||
|
zap.L().Debug("An invalid get_peers response received!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.eventHandlers.OnGetPeersResponse != nil{
|
||||||
|
p.eventHandlers.OnGetPeersResponse(msg, addr)
|
||||||
|
}
|
||||||
|
} else if len(msg.R.Nodes) != 0 { // The message should be a find_node response.
|
||||||
|
if !validateFindNodeResponseMessage(msg) {
|
||||||
|
zap.L().Debug("An invalid find_node response received!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.eventHandlers.OnFindNodeResponse != nil{
|
||||||
|
p.eventHandlers.OnFindNodeResponse(msg, addr)
|
||||||
|
}
|
||||||
|
} else { // The message should be a ping or an announce_peer response.
|
||||||
|
if !validatePingORannouncePeerResponseMessage(msg) {
|
||||||
|
zap.L().Debug("An invalid ping OR announce_peer response received!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.eventHandlers.OnPingORAnnouncePeerResponse != nil {
|
||||||
|
p.eventHandlers.OnPingORAnnouncePeerResponse(msg, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "e":
|
||||||
|
zap.L().Sugar().Debugf("Protocol error received: `%s` (%d)", msg.E.Message, msg.E.Code)
|
||||||
|
default:
|
||||||
|
zap.L().Debug("A KRPC message of an unknown type received!",
|
||||||
|
zap.String("type", msg.Y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (p *Protocol) SendMessage(msg *Message, addr net.Addr) {
|
||||||
|
p.transport.WriteMessages(msg, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func NewPingQuery(id []byte) *Message {
|
||||||
|
panic("Not implemented yet!")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func NewFindNodeQuery(id []byte, target []byte) *Message {
|
||||||
|
return &Message{
|
||||||
|
Y: "q",
|
||||||
|
T: []byte("\x00"),
|
||||||
|
Q: "find_node",
|
||||||
|
A: QueryArguments{
|
||||||
|
ID: id,
|
||||||
|
Target: target,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func NewGetPeersQuery(id []byte, info_hash []byte) *Message {
|
||||||
|
panic("Not implemented yet!")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func NewAnnouncePeerQuery(id []byte, implied_port bool, info_hash []byte, port uint16,
|
||||||
|
token []byte) *Message {
|
||||||
|
|
||||||
|
panic("Not implemented yet!")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func NewPingResponse(t []byte, id []byte) *Message {
|
||||||
|
return &Message{
|
||||||
|
Y: "r",
|
||||||
|
T: t,
|
||||||
|
R: ResponseValues{
|
||||||
|
ID: id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func NewFindNodeResponse(t []byte, id []byte, nodes []CompactNodeInfo) *Message {
|
||||||
|
panic("Not implemented yet!")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func NewGetPeersResponseWithValues(t []byte, id []byte, token []byte, values []CompactPeer) *Message {
|
||||||
|
panic("Not implemented yet!")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func NewGetPeersResponseWithNodes(t []byte, id []byte, token []byte, nodes []CompactNodeInfo) *Message {
|
||||||
|
return &Message{
|
||||||
|
Y: "r",
|
||||||
|
T: t,
|
||||||
|
R: ResponseValues{
|
||||||
|
ID: id,
|
||||||
|
Token: token,
|
||||||
|
Nodes: nodes,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func NewAnnouncePeerResponse(t []byte, id []byte) *Message {
|
||||||
|
// Because they are indistinguishable.
|
||||||
|
return NewPingResponse(t, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (p *Protocol) CalculateToken(address net.IP) []byte {
|
||||||
|
p.tokenLock.Lock()
|
||||||
|
defer p.tokenLock.Unlock()
|
||||||
|
sum := sha1.Sum(append(p.currentTokenSecret, address...))
|
||||||
|
return sum[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (p *Protocol) VerifyToken(address net.IP, token []byte) bool {
|
||||||
|
p.tokenLock.Lock()
|
||||||
|
defer p.tokenLock.Unlock()
|
||||||
|
panic("VerifyToken() not implemented yet!")
|
||||||
|
// TODO
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (p *Protocol) updateTokenSecret() {
|
||||||
|
for range time.Tick(10 * time.Minute) {
|
||||||
|
p.tokenLock.Lock()
|
||||||
|
copy(p.previousTokenSecret, p.currentTokenSecret)
|
||||||
|
_, err := rand.Read(p.currentTokenSecret)
|
||||||
|
if err != nil {
|
||||||
|
p.tokenLock.Unlock()
|
||||||
|
zap.L().Fatal("Could NOT generate random bytes for token secret!", zap.Error(err))
|
||||||
|
}
|
||||||
|
p.tokenLock.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func validatePingQueryMessage(msg *Message) bool {
|
||||||
|
return len(msg.A.ID) == 20
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func validateFindNodeQueryMessage(msg *Message) bool {
|
||||||
|
return len(msg.A.ID) == 20 &&
|
||||||
|
len(msg.A.Target) == 20
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func validateGetPeersQueryMessage(msg *Message) bool {
|
||||||
|
return len(msg.A.ID) == 20 &&
|
||||||
|
len(msg.A.InfoHash) == 20
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func validateAnnouncePeerQueryMessage(msg *Message) bool {
|
||||||
|
return len(msg.A.ID) == 20 &&
|
||||||
|
len(msg.A.InfoHash) == 20 &&
|
||||||
|
msg.A.Port > 0 &&
|
||||||
|
len(msg.A.Token) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func validatePingORannouncePeerResponseMessage(msg *Message) bool {
|
||||||
|
return len(msg.R.ID) == 20
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateFindNodeResponseMessage(msg *Message) bool {
|
||||||
|
if len(msg.R.ID) != 20 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: check nodes field
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func validateGetPeersResponseMessage(msg *Message) bool {
|
||||||
|
return len(msg.R.ID) == 20 &&
|
||||||
|
len(msg.R.Token) > 0
|
||||||
|
|
||||||
|
// TODO: check for values or nodes
|
||||||
|
}
|
200
src/magneticod/dht/mainline/protocol_test.go
Normal file
200
src/magneticod/dht/mainline/protocol_test.go
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
package mainline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
var protocolTest_validInstances = []struct {
|
||||||
|
validator func(*Message) bool
|
||||||
|
msg Message
|
||||||
|
} {
|
||||||
|
// ping Query:
|
||||||
|
{
|
||||||
|
validator: validatePingQueryMessage,
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("aa"),
|
||||||
|
Y: "q",
|
||||||
|
Q: "ping",
|
||||||
|
A: QueryArguments{
|
||||||
|
ID: []byte("abcdefghij0123456789"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ping or announce_peer Response:
|
||||||
|
// Also, includes NUL and EOT characters as transaction ID (`t`).
|
||||||
|
{
|
||||||
|
validator: validatePingORannouncePeerResponseMessage,
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("\x00\x04"),
|
||||||
|
Y: "r",
|
||||||
|
R: ResponseValues{
|
||||||
|
ID: []byte("mnopqrstuvwxyz123456"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// find_node Query:
|
||||||
|
{
|
||||||
|
validator: validateFindNodeQueryMessage,
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("\x09\x0a"),
|
||||||
|
Y: "q",
|
||||||
|
Q: "find_node",
|
||||||
|
A: QueryArguments{
|
||||||
|
ID: []byte("abcdefghij0123456789"),
|
||||||
|
Target: []byte("mnopqrstuvwxyz123456"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// find_node Response with no nodes (`nodes` key still exists):
|
||||||
|
{
|
||||||
|
validator: validateFindNodeResponseMessage,
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("aa"),
|
||||||
|
Y: "r",
|
||||||
|
R: ResponseValues{
|
||||||
|
ID: []byte("0123456789abcdefghij"),
|
||||||
|
Nodes: []CompactNodeInfo{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// find_node Response with a single node:
|
||||||
|
{
|
||||||
|
validator: validateFindNodeResponseMessage,
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("aa"),
|
||||||
|
Y: "r",
|
||||||
|
R: ResponseValues{
|
||||||
|
ID: []byte("0123456789abcdefghij"),
|
||||||
|
Nodes: []CompactNodeInfo{
|
||||||
|
{
|
||||||
|
ID: []byte("abcdefghijklmnopqrst"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\x8b\x82\x8e\xf5"), Port: 3169, Zone: ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// find_node Response with 8 nodes (all the same except the very last one):
|
||||||
|
{
|
||||||
|
validator: validateFindNodeResponseMessage,
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("aa"),
|
||||||
|
Y: "r",
|
||||||
|
R: ResponseValues{
|
||||||
|
ID: []byte("0123456789abcdefghij"),
|
||||||
|
Nodes: []CompactNodeInfo{
|
||||||
|
{
|
||||||
|
ID: []byte("abcdefghijklmnopqrst"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\x8b\x82\x8e\xf5"), Port: 3169, Zone: ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: []byte("abcdefghijklmnopqrst"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\x8b\x82\x8e\xf5"), Port: 3169, Zone: ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: []byte("abcdefghijklmnopqrst"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\x8b\x82\x8e\xf5"), Port: 3169, Zone: ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: []byte("abcdefghijklmnopqrst"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\x8b\x82\x8e\xf5"), Port: 3169, Zone: ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: []byte("abcdefghijklmnopqrst"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\x8b\x82\x8e\xf5"), Port: 3169, Zone: ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: []byte("abcdefghijklmnopqrst"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\x8b\x82\x8e\xf5"), Port: 3169, Zone: ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: []byte("abcdefghijklmnopqrst"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\x8b\x82\x8e\xf5"), Port: 3169, Zone: ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: []byte("zyxwvutsrqponmlkjihg"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\xf5\x8e\x82\x8b"), Port: 6931, Zone: ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// get_peers Query:
|
||||||
|
{
|
||||||
|
validator: validateGetPeersQueryMessage,
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("aa"),
|
||||||
|
Y: "q",
|
||||||
|
Q: "get_peers",
|
||||||
|
A: QueryArguments{
|
||||||
|
ID: []byte("abcdefghij0123456789"),
|
||||||
|
InfoHash: []byte("mnopqrstuvwxyz123456"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// get_peers Response with 2 peers (`values`):
|
||||||
|
{
|
||||||
|
validator: validateGetPeersResponseMessage,
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("aa"),
|
||||||
|
Y: "r",
|
||||||
|
R: ResponseValues{
|
||||||
|
ID: []byte("abcdefghij0123456789"),
|
||||||
|
Token: []byte("aoeusnth"),
|
||||||
|
Values: []CompactPeer{
|
||||||
|
{IP: []byte("axje"), Port: 11893},
|
||||||
|
{IP: []byte("idht"), Port: 28269},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// get_peers Response with 2 closest nodes (`nodes`):
|
||||||
|
{
|
||||||
|
validator: validateGetPeersResponseMessage,
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("aa"),
|
||||||
|
Y: "r",
|
||||||
|
R: ResponseValues{
|
||||||
|
ID: []byte("abcdefghij0123456789"),
|
||||||
|
Token: []byte("aoeusnth"),
|
||||||
|
Nodes: []CompactNodeInfo{
|
||||||
|
{
|
||||||
|
ID: []byte("abcdefghijklmnopqrst"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\x8b\x82\x8e\xf5"), Port: 3169, Zone: ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: []byte("zyxwvutsrqponmlkjihg"),
|
||||||
|
Addr: net.UDPAddr{IP: []byte("\xf5\x8e\x82\x8b"), Port: 6931, Zone: ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// announce_peer Query without optional `implied_port` argument:
|
||||||
|
{
|
||||||
|
validator: validateAnnouncePeerQueryMessage,
|
||||||
|
msg: Message{
|
||||||
|
T: []byte("aa"),
|
||||||
|
Y: "q",
|
||||||
|
Q: "announce_peer",
|
||||||
|
A: QueryArguments{
|
||||||
|
ID: []byte("abcdefghij0123456789"),
|
||||||
|
InfoHash: []byte("mnopqrstuvwxyz123456"),
|
||||||
|
Port: 6881,
|
||||||
|
Token: []byte("aoeusnth"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// TODO: Add announce_peer Query with optional `implied_port` argument.
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestValidators(t *testing.T) {
|
||||||
|
for i, instance := range protocolTest_validInstances {
|
||||||
|
if isValid := instance.validator(&instance.msg); !isValid {
|
||||||
|
t.Errorf("False-positive for valid msg #%d!", i + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
200
src/magneticod/dht/mainline/service.go
Normal file
200
src/magneticod/dht/mainline/service.go
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
package mainline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/anacrolix/torrent"
|
||||||
|
"github.com/anacrolix/torrent/metainfo"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TrawlingResult struct {
|
||||||
|
InfoHash metainfo.Hash
|
||||||
|
Peer torrent.Peer
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type TrawlingService struct {
|
||||||
|
// Private
|
||||||
|
protocol *Protocol
|
||||||
|
started bool
|
||||||
|
eventHandlers TrawlingServiceEventHandlers
|
||||||
|
|
||||||
|
trueNodeID []byte
|
||||||
|
// []byte type would be a much better fit for the keys but unfortunately (and quite
|
||||||
|
// understandably) slices cannot be used as keys (since they are not hashable), and using arrays
|
||||||
|
// (or even the conversion between each other) is a pain; hence map[string]net.UDPAddr
|
||||||
|
// ^~~~~~
|
||||||
|
routingTable map[string]net.Addr
|
||||||
|
routingTableMutex *sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type TrawlingServiceEventHandlers struct {
|
||||||
|
OnResult func(TrawlingResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func NewTrawlingService(laddr net.UDPAddr, eventHandlers TrawlingServiceEventHandlers) *TrawlingService {
|
||||||
|
service := new(TrawlingService)
|
||||||
|
service.protocol = NewProtocol(
|
||||||
|
laddr,
|
||||||
|
ProtocolEventHandlers{
|
||||||
|
OnGetPeersQuery: service.onGetPeersQuery,
|
||||||
|
OnAnnouncePeerQuery: service.onAnnouncePeerQuery,
|
||||||
|
OnFindNodeResponse: service.onFindNodeResponse,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
service.trueNodeID = make([]byte, 20)
|
||||||
|
service.routingTable = make(map[string]net.Addr)
|
||||||
|
service.routingTableMutex = new(sync.Mutex)
|
||||||
|
service.eventHandlers = eventHandlers
|
||||||
|
|
||||||
|
_, err := rand.Read(service.trueNodeID)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Panic("Could NOT generate random bytes for node ID!")
|
||||||
|
}
|
||||||
|
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (s *TrawlingService) Start() {
|
||||||
|
if s.started {
|
||||||
|
zap.L().Panic("Attempting to Start() a mainline/TrawlingService that has been already started! (Programmer error.)")
|
||||||
|
}
|
||||||
|
s.started = true
|
||||||
|
|
||||||
|
s.protocol.Start()
|
||||||
|
go s.trawl()
|
||||||
|
|
||||||
|
zap.L().Info("Trawling Service started!")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (s *TrawlingService) Terminate() {
|
||||||
|
s.protocol.Terminate()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (s *TrawlingService) trawl() {
|
||||||
|
for range time.Tick(1 * time.Second) {
|
||||||
|
s.routingTableMutex.Lock()
|
||||||
|
if len(s.routingTable) == 0 {
|
||||||
|
s.bootstrap()
|
||||||
|
} else {
|
||||||
|
zap.L().Info("Latest status:", zap.Int("n", len(s.routingTable)))
|
||||||
|
s.findNeighbors()
|
||||||
|
s.routingTable = make(map[string]net.Addr)
|
||||||
|
}
|
||||||
|
s.routingTableMutex.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (s *TrawlingService) bootstrap() {
|
||||||
|
bootstrappingNodes := []string{
|
||||||
|
"router.bittorrent.com:6881",
|
||||||
|
"dht.transmissionbt.com:6881",
|
||||||
|
"dht.libtorrent.org:25401",
|
||||||
|
}
|
||||||
|
zap.L().Info("Bootstrapping as routing table is empty...")
|
||||||
|
for _, node := range bootstrappingNodes {
|
||||||
|
target := make([]byte, 20)
|
||||||
|
_, err := rand.Read(target)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Panic("Could NOT generate random bytes during bootstrapping!")
|
||||||
|
}
|
||||||
|
|
||||||
|
addr, err := net.ResolveUDPAddr("udp", node)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Error("Could NOT resolve (UDP) address of the bootstrapping node!",
|
||||||
|
zap.String("node", node))
|
||||||
|
}
|
||||||
|
|
||||||
|
s.protocol.SendMessage(NewFindNodeQuery(s.trueNodeID, target), addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (s *TrawlingService) findNeighbors() {
|
||||||
|
target := make([]byte, 20)
|
||||||
|
for nodeID, addr := range s.routingTable {
|
||||||
|
_, err := rand.Read(target)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Panic("Could NOT generate random bytes during bootstrapping!")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.protocol.SendMessage(
|
||||||
|
NewFindNodeQuery(append([]byte(nodeID[:15]), s.trueNodeID[:5]...), target),
|
||||||
|
addr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (s *TrawlingService) onGetPeersQuery(query *Message, addr net.Addr) {
|
||||||
|
s.protocol.SendMessage(
|
||||||
|
NewGetPeersResponseWithNodes(
|
||||||
|
query.T,
|
||||||
|
append(query.A.ID[:15], s.trueNodeID[:5]...),
|
||||||
|
s.protocol.CalculateToken(net.ParseIP(addr.String()))[:],
|
||||||
|
[]CompactNodeInfo{},
|
||||||
|
),
|
||||||
|
addr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (s *TrawlingService) onAnnouncePeerQuery(query *Message, addr net.Addr) {
|
||||||
|
var peerPort int
|
||||||
|
if query.A.ImpliedPort != 0 {
|
||||||
|
peerPort = addr.(*net.UDPAddr).Port
|
||||||
|
} else {
|
||||||
|
peerPort = query.A.Port
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: It looks ugly, am I doing it right? --Bora
|
||||||
|
// (Converting slices to arrays in Go shouldn't have been such a pain...)
|
||||||
|
var peerId, infoHash [20]byte
|
||||||
|
copy(peerId[:], []byte("\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"))
|
||||||
|
copy(infoHash[:], query.A.InfoHash)
|
||||||
|
s.eventHandlers.OnResult(TrawlingResult{
|
||||||
|
InfoHash: infoHash,
|
||||||
|
Peer: torrent.Peer{
|
||||||
|
// As we don't know the ID of the remote peer, set it empty.
|
||||||
|
Id: peerId,
|
||||||
|
IP: addr.(*net.UDPAddr).IP,
|
||||||
|
Port: peerPort,
|
||||||
|
// "Ha" indicates that we discovered the peer through DHT Announce Peer (query); not
|
||||||
|
// sure how anacrolix/torrent utilizes that information though.
|
||||||
|
Source: "Ha",
|
||||||
|
// We don't know whether the remote peer supports encryption either, but let's pretend
|
||||||
|
// that it doesn't.
|
||||||
|
SupportsEncryption: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
s.protocol.SendMessage(
|
||||||
|
NewAnnouncePeerResponse(
|
||||||
|
query.T,
|
||||||
|
append(query.A.ID[:15], s.trueNodeID[:5]...),
|
||||||
|
),
|
||||||
|
addr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (s *TrawlingService) onFindNodeResponse(response *Message, addr net.Addr) {
|
||||||
|
s.routingTableMutex.Lock()
|
||||||
|
defer s.routingTableMutex.Unlock()
|
||||||
|
|
||||||
|
for _, node := range response.R.Nodes {
|
||||||
|
if node.Addr.Port != 0 { // Ignore nodes who "use" port 0.
|
||||||
|
s.routingTable[string(node.ID)] = &node.Addr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
99
src/magneticod/dht/mainline/transport.go
Normal file
99
src/magneticod/dht/mainline/transport.go
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
package mainline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"github.com/anacrolix/torrent/bencode"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
type Transport struct {
|
||||||
|
conn *net.UDPConn
|
||||||
|
laddr net.UDPAddr
|
||||||
|
started bool
|
||||||
|
|
||||||
|
// OnMessage is the function that will be called when Transport receives a packet that is
|
||||||
|
// successfully unmarshalled as a syntactically correct Message (but -of course- the checking
|
||||||
|
// the semantic correctness of the Message is left to Protocol).
|
||||||
|
onMessage func(*Message, net.Addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func NewTransport(laddr net.UDPAddr, onMessage func(*Message, net.Addr)) (*Transport) {
|
||||||
|
transport := new(Transport)
|
||||||
|
transport.onMessage = onMessage
|
||||||
|
transport.laddr = laddr
|
||||||
|
|
||||||
|
return transport
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (t *Transport) Start() {
|
||||||
|
// Why check whether the Transport `t` started or not, here and not -for instance- in
|
||||||
|
// t.Terminate()?
|
||||||
|
// Because in t.Terminate() the programmer (i.e. you & me) would stumble upon an error while
|
||||||
|
// trying close an uninitialised net.UDPConn or something like that: it's mostly harmless
|
||||||
|
// because its effects are immediate. But if you try to start a Transport `t` for the second
|
||||||
|
// (or the third, 4th, ...) time, it will keep spawning goroutines and any small mistake may
|
||||||
|
// end up in a debugging horror.
|
||||||
|
// Here ends my justification.
|
||||||
|
if t.started {
|
||||||
|
zap.L().Panic("Attempting to Start() a mainline/Transport that has been already started! (Programmer error.)")
|
||||||
|
}
|
||||||
|
t.started = true
|
||||||
|
|
||||||
|
var err error
|
||||||
|
t.conn, err = net.ListenUDP("udp", &t.laddr)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Fatal("Could NOT create a UDP socket!", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
go t.readMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (t *Transport) Terminate() {
|
||||||
|
t.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// readMessages is a goroutine!
|
||||||
|
func (t *Transport) readMessages() {
|
||||||
|
buffer := make([]byte, 65536)
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, addr, err := t.conn.ReadFrom(buffer)
|
||||||
|
if err != nil {
|
||||||
|
// TODO: isn't there a more reliable way to detect if UDPConn is closed?
|
||||||
|
if strings.HasSuffix(err.Error(), "use of closed network connection") {
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
zap.L().Debug("Could NOT read an UDP packet!", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg Message
|
||||||
|
err = bencode.Unmarshal(buffer[:n], &msg)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Debug("Could NOT unmarshal packet data!", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.onMessage(&msg, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (t *Transport) WriteMessages(msg *Message, addr net.Addr) {
|
||||||
|
data, err := bencode.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Panic("Could NOT marshal an outgoing message! (Programmer error.)")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = t.conn.WriteTo(data, addr)
|
||||||
|
// TODO: isn't there a more reliable way to detect if UDPConn is closed?
|
||||||
|
if err != nil && !strings.HasSuffix(err.Error(), "use of closed network connection") {
|
||||||
|
zap.L().Debug("Could NOT write an UDP packet!", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
55
src/magneticod/dht/mainline/transport_test.go
Normal file
55
src/magneticod/dht/mainline/transport_test.go
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package mainline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func TestReadFromOnClosedConn(t *testing.T) {
|
||||||
|
// Initialization
|
||||||
|
laddr, err := net.ResolveUDPAddr("udp", "0.0.0.0:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping due to an error during initialization!")
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := net.ListenUDP("udp", laddr)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping due to an error during initialization!")
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer := make([]byte, 65536)
|
||||||
|
|
||||||
|
// Setting Up
|
||||||
|
conn.Close()
|
||||||
|
|
||||||
|
// Testing
|
||||||
|
_, _, err = conn.ReadFrom(buffer)
|
||||||
|
if !(err != nil && strings.HasSuffix(err.Error(), "use of closed network connection")) {
|
||||||
|
t.Fatalf("Unexpected suffix in the error message!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestWriteToOnClosedConn(t *testing.T) {
|
||||||
|
// Initialization
|
||||||
|
laddr, err := net.ResolveUDPAddr("udp", "0.0.0.0:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping due to an error during initialization!")
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := net.ListenUDP("udp", laddr)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping due to an error during initialization!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setting Up
|
||||||
|
conn.Close()
|
||||||
|
|
||||||
|
// Testing
|
||||||
|
_, err = conn.WriteTo([]byte("estarabim"), laddr)
|
||||||
|
if !(err != nil && strings.HasSuffix(err.Error(), "use of closed network connection")) {
|
||||||
|
t.Fatalf("Unexpected suffix in the error message!")
|
||||||
|
}
|
||||||
|
}
|
64
src/magneticod/dht/managers.go
Normal file
64
src/magneticod/dht/managers.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package dht
|
||||||
|
|
||||||
|
import (
|
||||||
|
"magneticod/dht/mainline"
|
||||||
|
"net"
|
||||||
|
"github.com/bradfitz/iter"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
type TrawlingManager struct {
|
||||||
|
// private
|
||||||
|
output chan mainline.TrawlingResult
|
||||||
|
services []*mainline.TrawlingService
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func NewTrawlingManager(mlAddrs []net.UDPAddr) *TrawlingManager {
|
||||||
|
manager := new(TrawlingManager)
|
||||||
|
manager.output = make(chan mainline.TrawlingResult)
|
||||||
|
|
||||||
|
if mlAddrs != nil {
|
||||||
|
for _, addr := range mlAddrs {
|
||||||
|
manager.services = append(manager.services, mainline.NewTrawlingService(
|
||||||
|
addr,
|
||||||
|
mainline.TrawlingServiceEventHandlers{
|
||||||
|
OnResult: manager.onResult,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addr := net.UDPAddr{IP: []byte("\x00\x00\x00\x00"), Port: 0}
|
||||||
|
for range iter.N(1) {
|
||||||
|
manager.services = append(manager.services, mainline.NewTrawlingService(
|
||||||
|
addr,
|
||||||
|
mainline.TrawlingServiceEventHandlers{
|
||||||
|
OnResult: manager.onResult,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, service := range manager.services {
|
||||||
|
service.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (m *TrawlingManager) onResult(res mainline.TrawlingResult) {
|
||||||
|
m.output <- res
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (m *TrawlingManager) Output() <-chan mainline.TrawlingResult {
|
||||||
|
return m.output
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (m *TrawlingManager) Terminate() {
|
||||||
|
for _, service := range m.services {
|
||||||
|
service.Terminate()
|
||||||
|
}
|
||||||
|
}
|
260
src/magneticod/main.go
Normal file
260
src/magneticod/main.go
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"github.com/jessevdk/go-flags"
|
||||||
|
|
||||||
|
// "magneticod/bittorrent"
|
||||||
|
"magneticod/dht"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
"regexp"
|
||||||
|
"magneticod/bittorrent"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
type cmdFlags struct {
|
||||||
|
Database string `long:"database" description:"URL of the database."`
|
||||||
|
|
||||||
|
MlTrawlerAddrs []string `long:"ml-trawler-addrs" description:"Address(es) to be used by trawling DHT (Mainline) nodes." default:"0.0.0.0:0"`
|
||||||
|
TrawlingInterval uint `long:"trawling-interval" description:"Trawling interval in integer seconds."`
|
||||||
|
|
||||||
|
// TODO: is this even supported by anacrolix/torrent?
|
||||||
|
FetcherAddr string `long:"fetcher-addr" description:"Address(es) to be used by ephemeral peers fetching torrent metadata." default:"0.0.0.0:0"`
|
||||||
|
FetcherTimeout uint `long:"fetcher-timeout" description:"Number of integer seconds before a fetcher timeouts."`
|
||||||
|
// TODO: is this even supported by anacrolix/torrent?
|
||||||
|
MaxMetadataSize uint `long:"max-metadata-size" description:"Maximum metadata size -which must be greater than zero- in bytes."`
|
||||||
|
|
||||||
|
MlStatisticianAddrs []string `long:"ml-statistician-addrs" description:"Address(es) to be used by ephemeral nodes fetching latest statistics about individual torrents." default:"0.0.0.0:0"`
|
||||||
|
StatisticianTimeout uint `long:"statistician-timeout" description:"Number of integer seconds before a statistician timeouts."`
|
||||||
|
|
||||||
|
// TODO: is this even supported by anacrolix/torrent?
|
||||||
|
LeechAddr string `long:"leech-addr" description:"Address(es) to be used by ephemeral peers fetching README files." default:"0.0.0.0:0"`
|
||||||
|
LeechTimeout uint `long:"leech-timeout" description:"Number of integer seconds before a leech timeouts."`
|
||||||
|
MaxDescriptionSize uint `long:"max-description-size" description:"Maximum size -which must be greater than zero- of a description file in bytes"`
|
||||||
|
DescriptionNames []string `long:"description-names" description:"Regular expression(s) which will be tested against the name of the description files, in the supplied order."`
|
||||||
|
|
||||||
|
Verbose []bool `short:"v" long:"verbose" description:"Increases verbosity."`
|
||||||
|
|
||||||
|
// ==== Deprecated Flags ====
|
||||||
|
// TODO: don't even support deprecated flags!
|
||||||
|
|
||||||
|
// DatabaseFile is akin to Database flag, except that it was used when SQLite was the only
|
||||||
|
// persistence backend ever conceived, so it's the path* to the database file, which was -by
|
||||||
|
// default- located in wherever appdata module on Python said:
|
||||||
|
// On GNU/Linux : `/home/<USER>/.local/share/magneticod/database.sqlite3`
|
||||||
|
// On Windows : TODO?
|
||||||
|
// On MacOS (OS X) : TODO?
|
||||||
|
// On BSDs? : TODO?
|
||||||
|
// On anywhere else: TODO?
|
||||||
|
// TODO: Is the path* absolute or can be relative as well?
|
||||||
|
// DatabaseFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type opFlags struct {
|
||||||
|
Database string
|
||||||
|
|
||||||
|
MlTrawlerAddrs []net.UDPAddr
|
||||||
|
TrawlingInterval uint
|
||||||
|
|
||||||
|
FetcherAddr net.TCPAddr
|
||||||
|
FetcherTimeout uint
|
||||||
|
// TODO: is this even supported by anacrolix/torrent?
|
||||||
|
MaxMetadataSize uint
|
||||||
|
|
||||||
|
MlStatisticianAddrs []net.UDPAddr
|
||||||
|
StatisticianTimeout uint
|
||||||
|
|
||||||
|
LeechAddr net.TCPAddr
|
||||||
|
LeechTimeout uint
|
||||||
|
MaxDescriptionSize uint
|
||||||
|
DescriptionNames []regexp.Regexp
|
||||||
|
|
||||||
|
Verbosity uint
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
atom := zap.NewAtomicLevel()
|
||||||
|
// Logging levels: ("debug", "info", "warn", "error", "dpanic", "panic", and "fatal").
|
||||||
|
logger := zap.New(zapcore.NewCore(
|
||||||
|
zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()),
|
||||||
|
zapcore.Lock(os.Stderr),
|
||||||
|
atom,
|
||||||
|
))
|
||||||
|
defer logger.Sync()
|
||||||
|
zap.ReplaceGlobals(logger)
|
||||||
|
|
||||||
|
zap.L().Info("magneticod v0.7.0 has been started.")
|
||||||
|
zap.L().Info("Copyright (C) 2017 Mert Bora ALPER <bora@boramalper.org>.")
|
||||||
|
zap.L().Info("Dedicated to Cemile Binay, in whose hands I thrived.")
|
||||||
|
|
||||||
|
// opFlags is the "operational flags"
|
||||||
|
opFlags := parseFlags()
|
||||||
|
|
||||||
|
logger.Sugar().Warn(">>>", opFlags.MlTrawlerAddrs)
|
||||||
|
|
||||||
|
switch opFlags.Verbosity {
|
||||||
|
case 0:
|
||||||
|
atom.SetLevel(zap.WarnLevel)
|
||||||
|
case 1:
|
||||||
|
atom.SetLevel(zap.InfoLevel)
|
||||||
|
// Default: i.e. in case of 2 or more.
|
||||||
|
default:
|
||||||
|
atom.SetLevel(zap.DebugLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
zap.ReplaceGlobals(logger)
|
||||||
|
|
||||||
|
/*
|
||||||
|
updating_manager := nil
|
||||||
|
statistics_sink := nil
|
||||||
|
completing_manager := nil
|
||||||
|
file_sink := nil
|
||||||
|
*/
|
||||||
|
// Handle Ctrl-C gracefully.
|
||||||
|
interrupt_chan := make(chan os.Signal)
|
||||||
|
signal.Notify(interrupt_chan, os.Interrupt)
|
||||||
|
|
||||||
|
database, err := NewDatabase(opFlags.Database)
|
||||||
|
if err != nil {
|
||||||
|
logger.Sugar().Fatalf("Could not open the database at `%s`: %s", opFlags.Database, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
trawlingManager := dht.NewTrawlingManager(opFlags.MlTrawlerAddrs)
|
||||||
|
metadataSink := bittorrent.NewMetadataSink(opFlags.FetcherAddr)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case result := <-trawlingManager.Output():
|
||||||
|
logger.Info("result: ", zap.String("hash", result.InfoHash.String()))
|
||||||
|
metadataSink.Sink(result)
|
||||||
|
|
||||||
|
case metadata := <-metadataSink.Drain():
|
||||||
|
if err := database.AddNewTorrent(metadata); err != nil {
|
||||||
|
logger.Sugar().Fatalf("Could not add new torrent %x to the database: %s", metadata.InfoHash, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-interrupt_chan:
|
||||||
|
trawlingManager.Terminate()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
|
||||||
|
}()
|
||||||
|
|
||||||
|
/*
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
|
||||||
|
case updating_manager.Output():
|
||||||
|
|
||||||
|
case statistics_sink.Sink():
|
||||||
|
|
||||||
|
case completing_manager.Output():
|
||||||
|
|
||||||
|
case file_sink.Sink():
|
||||||
|
*/
|
||||||
|
|
||||||
|
<-interrupt_chan
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func parseFlags() (opFlags) {
|
||||||
|
var cmdF cmdFlags
|
||||||
|
|
||||||
|
_, err := flags.Parse(&cmdF)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Fatal("Error while parsing command-line flags: ", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
mlTrawlerAddrs, err := hostPortsToUDPAddrs(cmdF.MlTrawlerAddrs)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Fatal("Erroneous ml-trawler-addrs argument supplied: ", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
fetcherAddr, err := hostPortsToTCPAddr(cmdF.FetcherAddr)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Fatal("Erroneous fetcher-addr argument supplied: ", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
mlStatisticianAddrs, err := hostPortsToUDPAddrs(cmdF.MlStatisticianAddrs)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Fatal("Erroneous ml-statistician-addrs argument supplied: ", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
leechAddr, err := hostPortsToTCPAddr(cmdF.LeechAddr)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Fatal("Erroneous leech-addrs argument supplied: ", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
var descriptionNames []regexp.Regexp
|
||||||
|
for _, expr := range cmdF.DescriptionNames {
|
||||||
|
regex, err := regexp.Compile(expr)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Fatal("Erroneous description-names argument supplied: ", zap.Error(err))
|
||||||
|
}
|
||||||
|
descriptionNames = append(descriptionNames, *regex)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
opF := opFlags{
|
||||||
|
Database: cmdF.Database,
|
||||||
|
|
||||||
|
MlTrawlerAddrs: mlTrawlerAddrs,
|
||||||
|
TrawlingInterval: cmdF.TrawlingInterval,
|
||||||
|
|
||||||
|
FetcherAddr: fetcherAddr,
|
||||||
|
FetcherTimeout: cmdF.FetcherTimeout,
|
||||||
|
MaxMetadataSize: cmdF.MaxMetadataSize,
|
||||||
|
|
||||||
|
MlStatisticianAddrs: mlStatisticianAddrs,
|
||||||
|
StatisticianTimeout: cmdF.StatisticianTimeout,
|
||||||
|
|
||||||
|
LeechAddr: leechAddr,
|
||||||
|
LeechTimeout: cmdF.LeechTimeout,
|
||||||
|
MaxDescriptionSize: cmdF.MaxDescriptionSize,
|
||||||
|
DescriptionNames: descriptionNames,
|
||||||
|
|
||||||
|
Verbosity: uint(len(cmdF.Verbose)),
|
||||||
|
}
|
||||||
|
|
||||||
|
return opF
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func hostPortsToUDPAddrs(hostport []string) ([]net.UDPAddr, error) {
|
||||||
|
udpAddrs := make([]net.UDPAddr, len(hostport))
|
||||||
|
|
||||||
|
for i, hp := range hostport {
|
||||||
|
udpAddr, err := net.ResolveUDPAddr("udp", hp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
udpAddrs[i] = *udpAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
return udpAddrs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func hostPortsToTCPAddr(hostport string) (net.TCPAddr, error) {
|
||||||
|
tcpAddr, err := net.ResolveTCPAddr("tcp", hostport)
|
||||||
|
if err != nil {
|
||||||
|
return net.TCPAddr{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return *tcpAddr, nil
|
||||||
|
}
|
228
src/magneticod/persistence.go
Normal file
228
src/magneticod/persistence.go
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"database/sql"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"magneticod/bittorrent"
|
||||||
|
|
||||||
|
"path"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
type engineType uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
SQLITE engineType = 0
|
||||||
|
POSTGRESQL = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
type Database struct {
|
||||||
|
database *sql.DB
|
||||||
|
engine engineType
|
||||||
|
newTorrents chan bittorrent.Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// NewDatabase creates a new Database.
|
||||||
|
//
|
||||||
|
// url either starts with "sqlite:" or "postgresql:"
|
||||||
|
func NewDatabase(rawurl string) (*Database, error) {
|
||||||
|
db := Database{}
|
||||||
|
|
||||||
|
dbURL, err := url.Parse(rawurl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch dbURL.Scheme {
|
||||||
|
case "sqlite":
|
||||||
|
db.engine = SQLITE
|
||||||
|
// All this pain is to make sure that an empty file exist (in case the database is not there
|
||||||
|
// yet) so that sql.Open won't fail.
|
||||||
|
dbDir, _ := path.Split(dbURL.Path)
|
||||||
|
if err := os.MkdirAll(dbDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("for directory `%s`: %s", dbDir, err.Error())
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(dbURL.Path, os.O_CREATE, 0666)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("for file `%s`: %s", dbURL.Path, err.Error())
|
||||||
|
}
|
||||||
|
if err := f.Sync(); err != nil {
|
||||||
|
return nil, fmt.Errorf("for file `%s`: %s", dbURL.Path, err.Error())
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("for file `%s`: %s", dbURL.Path, err.Error())
|
||||||
|
}
|
||||||
|
db.database, err = sql.Open("sqlite3", dbURL.RawPath)
|
||||||
|
|
||||||
|
case "postgresql":
|
||||||
|
db.engine = POSTGRESQL
|
||||||
|
db.database, err = sql.Open("postgresql", rawurl)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown URI scheme (or malformed URI)!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for errors from sql.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sql.Open()! %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = db.database.Ping(); err != nil {
|
||||||
|
return nil, fmt.Errorf("DB.Ping()! %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.setupDatabase(); err != nil {
|
||||||
|
return nil, fmt.Errorf("setupDatabase()! %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
db.newTorrents = make(chan bittorrent.Metadata, 10)
|
||||||
|
|
||||||
|
return &db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// AddNewTorrent adds a new torrent to the *queue* to be flushed to the persistent database.
|
||||||
|
func (db *Database) AddNewTorrent(torrent bittorrent.Metadata) error {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case db.newTorrents <- torrent:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
// newTorrents queue was full: flush and try again and again (and again)...
|
||||||
|
err := db.flushNewTorrents()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (db *Database) flushNewTorrents() error {
|
||||||
|
tx, err := db.database.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sql.DB.Begin()! %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var nTorrents, nFiles uint
|
||||||
|
for torrent := range db.newTorrents {
|
||||||
|
res, err := tx.Exec("INSERT INTO torrents (info_hash, name, total_size, discovered_on) VALUES ($1, $2, $3, $4);",
|
||||||
|
torrent.InfoHash, torrent.Name, torrent.TotalSize, torrent.DiscoveredOn)
|
||||||
|
if err != nil {
|
||||||
|
ourError := fmt.Errorf("error while INSERTing INTO torrents! %s", err.Error())
|
||||||
|
if err := tx.Rollback(); err != nil {
|
||||||
|
return fmt.Errorf("%s\tmeanwhile, could not rollback the current transaction either! %s", ourError.Error(), err.Error())
|
||||||
|
}
|
||||||
|
return ourError
|
||||||
|
}
|
||||||
|
var lastInsertId int64
|
||||||
|
if lastInsertId, err = res.LastInsertId(); err != nil {
|
||||||
|
return fmt.Errorf("sql.Result.LastInsertId()! %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range torrent.Files {
|
||||||
|
_, err := tx.Exec("INSERT INTO files (torrent_id, size, path) VALUES($1, $2, $3);",
|
||||||
|
lastInsertId, file.Length, file.Path)
|
||||||
|
if err != nil {
|
||||||
|
ourError := fmt.Errorf("error while INSERTing INTO files! %s", err.Error())
|
||||||
|
if err := tx.Rollback(); err != nil {
|
||||||
|
return fmt.Errorf("%s\tmeanwhile, could not rollback the current transaction either! %s", ourError.Error(), err.Error())
|
||||||
|
}
|
||||||
|
return ourError
|
||||||
|
}
|
||||||
|
nFiles++
|
||||||
|
}
|
||||||
|
nTorrents++
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sql.Tx.Commit()! %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
zap.L().Sugar().Infof("%d torrents (%d files) are flushed to the database successfully.",
|
||||||
|
nTorrents, nFiles)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (db *Database) Close() {
|
||||||
|
// Be careful to not to get into an infinite loop. =)
|
||||||
|
db.database.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (db *Database) setupDatabase() error {
|
||||||
|
switch db.engine {
|
||||||
|
case SQLITE:
|
||||||
|
return setupSqliteDatabase(db.database)
|
||||||
|
|
||||||
|
case POSTGRESQL:
|
||||||
|
zap.L().Fatal("setupDatabase() is not implemented for PostgreSQL yet!")
|
||||||
|
|
||||||
|
default:
|
||||||
|
zap.L().Sugar().Fatalf("Unknown database engine value %d! (programmer error)", db.engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func setupSqliteDatabase(database *sql.DB) error {
|
||||||
|
// Enable Write-Ahead Logging for SQLite as "WAL provides more concurrency as readers do not
|
||||||
|
// block writers and a writer does not block readers. Reading and writing can proceed
|
||||||
|
// concurrently."
|
||||||
|
// Caveats:
|
||||||
|
// * Might be unsupported by OSes other than Windows and UNIXes.
|
||||||
|
// * Does not work over a network filesystem.
|
||||||
|
// * Transactions that involve changes against multiple ATTACHed databases are not atomic
|
||||||
|
// across all databases as a set.
|
||||||
|
// See: https://www.sqlite.org/wal.html
|
||||||
|
//
|
||||||
|
// Force SQLite to use disk, instead of memory, for all temporary files to reduce the memory
|
||||||
|
// footprint.
|
||||||
|
//
|
||||||
|
// Enable foreign key constraints in SQLite which are crucial to prevent programmer errors on
|
||||||
|
// our side.
|
||||||
|
_, err := database.Exec(
|
||||||
|
`PRAGMA journal_mode=WAL;
|
||||||
|
PRAGMA temp_store=1;
|
||||||
|
PRAGMA foreign_keys=ON;`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = database.Exec(
|
||||||
|
`CREATE TABLE IF NOT EXISTS torrents (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
info_hash BLOB NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
total_size INTEGER NOT NULL CHECK(total_size > 0),
|
||||||
|
discovered_on INTEGER NOT NULL CHECK(discovered_on > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS info_hash_index ON torrents (info_hash);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
torrent_id INTEGER REFERENCES torrents ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
path TEXT NOT NULL
|
||||||
|
);`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
26
src/magneticow/Gopkg.toml
Normal file
26
src/magneticow/Gopkg.toml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
# Gopkg.toml example
|
||||||
|
#
|
||||||
|
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
|
||||||
|
# for detailed Gopkg.toml documentation.
|
||||||
|
#
|
||||||
|
# required = ["github.com/user/thing/cmd/thing"]
|
||||||
|
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
|
||||||
|
#
|
||||||
|
# [[constraint]]
|
||||||
|
# name = "github.com/user/project"
|
||||||
|
# version = "1.0.0"
|
||||||
|
#
|
||||||
|
# [[constraint]]
|
||||||
|
# name = "github.com/user/project2"
|
||||||
|
# branch = "dev"
|
||||||
|
# source = "github.com/myfork/project2"
|
||||||
|
#
|
||||||
|
# [[override]]
|
||||||
|
# name = "github.com/x/y"
|
||||||
|
# version = "2.4.0"
|
||||||
|
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "github.com/gorilla/mux"
|
||||||
|
version = "1.4.0"
|
63
src/magneticow/main.go
Normal file
63
src/magneticow/main.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
// magneticow - Lightweight web interface for magnetico.
|
||||||
|
// Copyright (C) 2017 Mert Bora ALPER <bora@boramalper.org>
|
||||||
|
// Dedicated to Cemile Binay, in whose hands I thrived.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify it under the terms of the
|
||||||
|
// GNU General Public License as published by the Free Software Foundation, either version 3 of the
|
||||||
|
// License, or (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||||
|
// even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
// General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License along with this program. If
|
||||||
|
// not, see <http://www.gnu.org/licenses/>.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
router := mux.NewRouter()
|
||||||
|
router.HandleFunc("/", rootHandler)
|
||||||
|
router.HandleFunc("/torrents", torrentsHandler)
|
||||||
|
router.HandleFunc("/torrents/{infohash}", torrentsInfohashHandler)
|
||||||
|
router.HandleFunc("/torrents/{infohash}/{name}", torrentsInfohashNameHandler)
|
||||||
|
router.HandleFunc("/statistics", statisticsHandler)
|
||||||
|
router.HandleFunc("/feed", feedHandler)
|
||||||
|
http.ListenAndServe(":8080", router)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func rootHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func torrentsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func torrentsInfohashHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func torrentsInfohashNameHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func statisticsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func feedHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user