~martijnbraam/lsip6

521fae8ffc752809b33556bd7f7333c4d23f7a5a — Martijn Braam 3 months ago
Initial commit
5 files changed, 383 insertions(+), 0 deletions(-)

A .gitignore
A README.md
A lsip6/__init__.py
A lsip6/__main__.py
A setup.py
A  => .gitignore +141 -0
@@ 1,141 @@
### Python template
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
#  Usually these files are written by a python script from a template
#  before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/

# Translations
*.mo
*.pot

# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal

# Flask stuff:
instance/
.webassets-cache

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/

# PyBuilder
.pybuilder/
target/

# Jupyter Notebook
.ipynb_checkpoints

# IPython
profile_default/
ipython_config.py

# pyenv
#   For a library or package, you might want to ignore these files since the code is
#   intended to run in multiple environments; otherwise, check them in:
# .python-version

# pipenv
#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
#   However, in case of collaboration, if having platform-specific dependencies or dependencies
#   having no cross-platform support, pipenv may install dependencies that don't work, or not
#   install all needed dependencies.
#Pipfile.lock

# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/

# Celery stuff
celerybeat-schedule
celerybeat.pid

# SageMath parsed files
*.sage.py

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# mkdocs documentation
/site

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# Pyre type checker
.pyre/

# pytype static type analyzer
.pytype/

# Cython debug symbols
cython_debug/

/.idea
\ No newline at end of file

A  => README.md +93 -0
@@ 1,93 @@
# lsip6

Most Linux devices have IPv6 enabled in the kernel. With this tool it's possible to find the link-local IPv6
address of the other end of an point-to-point link.

This tool has been made to find the IPv6 address of a postmarketOS phone connected over USB. It has also been verified
to work with a Mobian PinePhone. The tool is more flexible than that though. If you connect to any device with
an ethernet cable directly without a switch/router in between you've created a point-to-point link that works
with this tool. No need to set up DHCP or remember static IP addresses if IPv6 is running.

## Installation

```shell-session
$ sudo python3 setup.py install
```

## Usage

Without any arguments `lsip6` uses some heuristics to figure out which of the network interfaces are RNDIS USB adapters
and will try to find the IPv6 neighbor of all those adapters and print the results. It will also use the USB hardware
descriptors to come up with a name for the device. This works well with Linux phones since the USB network gadget
that is emulated has the device information set.

```shell-session
$ lsip6
PINE64 PinePhone / postmarketOS     fe80::ac63:afff:fee4:78f5%enp0s20f0u4
  SHIFT SHIFT6mq / postmarketOS     fe80::50c1:98ff:fe88:cb0c%enp0s20f0u3
```

This also works for postmarketOS devices that use the ECM gadget instead of the RNDIS gadget. In this case it's a
Nexus 5. The ECM gadget does not have a nice name set by the postmarketOS initramfs though

```shell-session
$ lsip6
Linux 5.18.1-postmarketos-qcom-msm8974 with ci_hdrc_msm RNDIS/Ethernet Gadget     fe80::f438:9ff:fe56:be29%enp0s20f0u8
```

It is also possible to specify one or more network interfaces to scan instead of letting the heuristics guess which
are the correct ones by specifying the `-i / --interface` argument. With this it's even possible to run it against regular network
links in which case it will just return one random IPv6 address since there might be many devices.

```shell-session
$ lsip6 -i enp0s20f0u3
SHIFT SHIFT6mq / postmarketOS     fe80::50c1:98ff:fe88:cb0c%enp0s20f0u3

$ lsip6 -i wlan0
Unknown device     2a00:xxxx:xxxx:xxxx::100%wlp2s0
```

With the `-a / --addr` argument the tool will only print the IPv6 address which is easier for automation. This is
especially helpful in combination with the `filter...` arguments that are possible. If any filter arguments are
supplied lsip6 will only output results matching _all_ off the filter arguments given. This is case sensitive.

```shell-session
$ lsip6 --addr
fe80::ac63:afff:fee4:78f5%enp0s20f0u4
fe80::50c1:98ff:fe88:cb0c%enp0s20f0u3

$ lsip6 PinePhone
PINE64 PinePhone / postmarketOS     fe80::ac63:afff:fee4:78f5%enp0s20f0u4

$ lsip6 -a PinePhone
fe80::ac63:afff:fee4:78f5%enp0s20f0u4

$ ssh user@`lsip6 -a PinePhone`
```

## How it works

The whole functionality of this tool is build around the IPv6 Neighbour Discovery Protocol (NDP). This is the IPv6
replacement for the functionality provided by ARP on IPv4. When sending traffic to another device on the network your
computer first needs to know the MAC address of that device. If the MAC of the target IP address is unknown the NDP
protocol is used to find this address using a Neighbor Solicitation.

When running the `lsip6` command it will send an ICMPv6 (ping) packet to the network interface to an IPv6 multicast
address. In this case that address is `ff02::1`. This is a multicast address defined by the IPv6 specification that
means "All devices on this link", basically the replacement for a broadcast.

Sending this ping traffic will cause the devices at both ends of the link to start Neighbor Solicitation to figure out
how to talk to eachother. After this the MAC address for the devices will be cached in the kernel, this cache is then
checked by lsip6 to figure out which device exists on the other end.

The equivalent of this tool is roughly this:

```shell-session
$ ping -c 2 -I enp0s20f0u4 ff02::1
result of this ping is ignored, this is just to fill the kernel-side cache

$ ip -6 neigh show dev enp0s20f0u4
fe80::ac63:afff:fee4:78f5 lladdr ae:63:af:e4:78:f5 STALE 

The full address is then {ipaddress}%{interface} to make a usable link-local IPv6 address
```
\ No newline at end of file

A  => lsip6/__init__.py +0 -0
A  => lsip6/__main__.py +136 -0
@@ 1,136 @@
#!/usr/bin/env python3
import argparse
import glob
import os
import sys
import subprocess
import pathlib


def broadcast(interface):
    """
    Send a ping to the ipv6 multicast address on the interface the device might be at. This triggers IPv6 neighbor
    discovery on the interface so the device shows up in the neighbor address table
    """

    # Shells out to `ping` since the ping binary can send ICMP traffic without root privileges
    subprocess.run(['ping', '-c', '2', '-I', interface, 'ff02::1'], stdout=subprocess.DEVNULL,
                   stderr=subprocess.DEVNULL)


def get_neighbor(interface):
    """
    Get the IPv6 link local neighbors of the USB interface
    """
    res = subprocess.run(['ip', '-6', 'neigh', 'show', 'dev', interface], stdout=subprocess.PIPE)
    raw = res.stdout.decode()
    for line in raw.splitlines():
        if 'FAILED' in line:
            continue
        if 'lladdr' not in line:
            continue
        line = line.strip()
        part = line.split()
        return part[0]
    return None


def find_name(interface):
    """
    Find the USB device information for the USB device that provides the network interface
    """
    path = f'/sys/class/net/{interface}/device'
    if not os.path.islink(path):
        return None

    usb_device = pathlib.Path(path).resolve().parents[0]
    name = ''
    if os.path.isfile(usb_device / 'manufacturer'):
        with open(usb_device / 'manufacturer', 'r') as handle:
            name += handle.read().strip()
    if os.path.isfile(usb_device / 'product'):
        with open(usb_device / 'product', 'r') as handle:
            product = handle.read().strip()
            if product.startswith(f'{name} '):
                name = ''
            name += ' ' + product
    if os.path.isfile(usb_device / 'serial'):
        with open(usb_device / 'serial', 'r') as handle:
            name += ' / ' + handle.read().strip()

    return name.strip()


def find_possible_interfaces():
    interfaces = set()
    for path in glob.glob('/sys/class/net/*/device/interface'):
        with open(path, 'r') as handle:
            raw = handle.read()
        if 'RNDIS' in raw or 'CDC Ethernet' in raw:
            part = path.split('/')
            interfaces.add(part[4])
    return interfaces


def main():
    parser = argparse.ArgumentParser("Find USB tethered phones")
    parser.add_argument('--interface', '-i', help='Interface name', metavar='INTERFACE', action='append')
    parser.add_argument('--addr', '-a', help='Return only the address', action='store_true')
    parser.add_argument('filter', nargs='*')
    args = parser.parse_args()

    forced = False
    if args.interface is None:
        interfaces = find_possible_interfaces()
    else:
        forced = True
        interfaces = args.interface

    table = []
    for i in interfaces:
        address = get_neighbor(i)
        if address is None:
            broadcast(i)
        address = get_neighbor(i)
        if address is None:
            pass

        # Only show the interface specific failure when an interface was manually selected
        if address is None and forced:
            sys.stderr.write(f'{i}: no response\n')

        if address is None:
            continue

        name = find_name(i)
        if name is None or name == '':
            name = 'Unknown device'

        if len(args.filter) > 0:
            match_all = True
            for f in args.filter:
                if f not in name:
                    match_all = False
            if not match_all:
                continue

        table.append([name, address, i])

    if len(table) == 0:
        sys.stderr.write("No devices found\n")
        exit(1)

    name_length = 0
    for row in table:
        if len(row[0]) > name_length:
            name_length = len(row[0])

    for row in table:
        if args.addr:
            print(f'{row[1]}%{row[2]}')
            continue
        print(row[0].rjust(name_length, ' '), '   ', f'{row[1]}%{row[2]}')


if __name__ == '__main__':
    main()

A  => setup.py +13 -0
@@ 1,13 @@
from setuptools import setup

setup(name='lsip6',
      version='0.1',
      description='Find link-local IPv6 neighbors on point-to-point links',
      url='https://git.sr.ht/~martijnbraam/lsip6',
      author='Martijn Braam',
      author_email='martijn@brixit.nl',
      license='MIT',
      packages=['lsip6'],
      entry_points={
          'console_scripts': ['lsip6=lsip6.__main__:main'],
      })
\ No newline at end of file