diff --git a/archivebox/core/admin.py b/archivebox/core/admin.py
index 52e4fb5a..d201e878 100644
--- a/archivebox/core/admin.py
+++ b/archivebox/core/admin.py
@@ -29,6 +29,7 @@ from core.mixins import SearchResultsAdminMixin
from api.models import APIToken
from abid_utils.admin import ABIDModelAdmin
from queues.tasks import bg_archive_links, bg_add
+from machine.models import Machine, NetworkInterface
from index.html import snapshot_icons
from logging_util import printable_filesize
@@ -778,3 +779,53 @@ class CustomWebhookAdmin(WebhookAdmin, ABIDModelAdmin):
list_display = ('created_at', 'created_by', 'abid', *WebhookAdmin.list_display)
sort_fields = ('created_at', 'created_by', 'abid', 'referenced_model', 'endpoint', 'last_success', 'last_error')
readonly_fields = ('created_at', 'modified_at', 'abid_info', *WebhookAdmin.readonly_fields)
+
+
+@admin.register(Machine, site=archivebox_admin)
+class MachineAdmin(ABIDModelAdmin):
+ list_display = ('abid', 'created_at', 'hostname', 'ips', 'os_platform', 'hw_in_docker', 'hw_in_vm', 'hw_manufacturer', 'hw_product', 'os_arch', 'os_family', 'os_release', 'hw_uuid')
+ sort_fields = ('abid', 'created_at', 'hostname', 'ips', 'os_platform', 'hw_in_docker', 'hw_in_vm', 'hw_manufacturer', 'hw_product', 'os_arch', 'os_family', 'os_release', 'hw_uuid')
+ # search_fields = ('id', 'abid', 'guid', 'hostname', 'hw_manufacturer', 'hw_product', 'hw_uuid', 'os_arch', 'os_family', 'os_platform', 'os_kernel', 'os_release')
+
+ readonly_fields = ('guid', 'created_at', 'modified_at', 'abid_info', 'ips')
+ fields = (*readonly_fields, 'hostname', 'hw_in_docker', 'hw_in_vm', 'hw_manufacturer', 'hw_product', 'hw_uuid', 'os_arch', 'os_family', 'os_platform', 'os_kernel', 'os_release', 'stats')
+
+ list_filter = ('hw_in_docker', 'hw_in_vm', 'os_arch', 'os_family', 'os_platform')
+ ordering = ['-created_at']
+ list_per_page = 100
+
+ @admin.display(
+ description='Public IP',
+ ordering='networkinterface__ip_public',
+ )
+ def ips(self, machine):
+ return format_html(
+ '{}
',
+ machine.abid,
+ ', '.join(machine.networkinterface_set.values_list('ip_public', flat=True)),
+ )
+
+@admin.register(NetworkInterface, site=archivebox_admin)
+class NetworkInterfaceAdmin(ABIDModelAdmin):
+ list_display = ('abid', 'created_at', 'machine_info', 'ip_public', 'dns_server', 'isp', 'country', 'region', 'city', 'iface', 'ip_local', 'mac_address')
+ sort_fields = ('abid', 'created_at', 'machine_info', 'ip_public', 'dns_server', 'isp', 'country', 'region', 'city', 'iface', 'ip_local', 'mac_address')
+ search_fields = ('abid', 'machine__abid', 'iface', 'ip_public', 'ip_local', 'mac_address', 'dns_server', 'hostname', 'isp', 'city', 'region', 'country')
+
+ readonly_fields = ('machine', 'created_at', 'modified_at', 'abid_info', 'mac_address', 'ip_public', 'ip_local', 'dns_server')
+ fields = (*readonly_fields, 'iface', 'hostname', 'isp', 'city', 'region', 'country')
+
+ list_filter = ('isp', 'country', 'region')
+ ordering = ['-created_at']
+ list_per_page = 100
+
+ @admin.display(
+ description='Machine',
+ ordering='machine__abid',
+ )
+ def machine_info(self, iface):
+ return format_html(
+ '[{}]
{}',
+ iface.machine.id,
+ iface.machine.abid,
+ iface.machine.hostname,
+ )
diff --git a/archivebox/core/settings.py b/archivebox/core/settings.py
index c76979e1..424b0acb 100644
--- a/archivebox/core/settings.py
+++ b/archivebox/core/settings.py
@@ -98,7 +98,8 @@ INSTALLED_APPS = [
'django_object_actions', # provides easy Django Admin action buttons on change views https://github.com/crccheck/django-object-actions
# Our ArchiveBox-provided apps
- #'config', # ArchiveBox config settings
+ #'config', # ArchiveBox config settings (loaded as a plugin, don't need to add it here)
+ 'machine', # handles collecting and storing information about the host machine, network interfaces, installed binaries, etc.
'queues', # handles starting and managing background workers and processes
'abid_utils', # handles ABID ID creation, handling, and models
'core', # core django model with Snapshot, ArchiveResult, etc.
diff --git a/archivebox/machine/apps.py b/archivebox/machine/apps.py
new file mode 100644
index 00000000..f5c0867b
--- /dev/null
+++ b/archivebox/machine/apps.py
@@ -0,0 +1,8 @@
+from django.apps import AppConfig
+
+
+class MachineConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+
+ name = 'machine'
+ verbose_name = 'Machine Info'
diff --git a/archivebox/machine/detect.py b/archivebox/machine/detect.py
new file mode 100644
index 00000000..4a8a838a
--- /dev/null
+++ b/archivebox/machine/detect.py
@@ -0,0 +1,317 @@
+import os
+import json
+import socket
+import urllib.request
+from typing import Dict, Any
+from pathlib import Path
+import subprocess
+import platform
+import tempfile
+from datetime import datetime
+
+import psutil
+import machineid # https://github.com/keygen-sh/py-machineid
+
+from rich import print
+
+PACKAGE_DIR = Path(__file__).parent
+DATA_DIR = Path('.').resolve()
+
+def get_vm_info():
+ hw_in_docker = bool(os.getenv('IN_DOCKER', False) in ('1', 'true', 'True', 'TRUE'))
+ hw_in_vm = False
+ try:
+ # check for traces of docker/containerd/podman in cgroup
+ with open('/proc/self/cgroup', 'r') as procfile:
+ for line in procfile:
+ cgroup = line.strip() # .split('/', 1)[-1].lower()
+ if 'docker' in cgroup or 'containerd' in cgroup or 'podman' in cgroup:
+ hw_in_docker = True
+ except Exception:
+ pass
+
+ hw_manufacturer = 'Docker' if hw_in_docker else 'Unknown'
+ hw_product = 'Container' if hw_in_docker else 'Unknown'
+ hw_uuid = machineid.id()
+
+ if platform.system().lower() == 'darwin':
+ # Get macOS machine info
+ hw_manufacturer = 'Apple'
+ hw_product = 'Mac'
+ try:
+ # Hardware:
+ # Hardware Overview:
+ # Model Name: Mac Studio
+ # Model Identifier: Mac13,1
+ # Model Number: MJMV3LL/A
+ # ...
+ # Serial Number (system): M230YYTD77
+ # Hardware UUID: 39A12B50-1972-5910-8BEE-235AD20C8EE3
+ # ...
+ result = subprocess.run(['system_profiler', 'SPHardwareDataType'], capture_output=True, text=True, check=True)
+ for line in result.stdout.split('\n'):
+ if 'Model Name:' in line:
+ hw_product = line.split(':', 1)[-1].strip()
+ elif 'Model Identifier:' in line:
+ hw_product += ' ' + line.split(':', 1)[-1].strip()
+ elif 'Hardware UUID:' in line:
+ hw_uuid = line.split(':', 1)[-1].strip()
+ except Exception:
+ pass
+ else:
+ # get Linux machine info
+ try:
+ # Getting SMBIOS data from sysfs.
+ # SMBIOS 2.8 present.
+ # argo-1 | 2024-10-01T10:40:51Z ERR error="Incoming request ended abruptly: context canceled" connIndex=2 event=1 ingressRule=0 originService=http://archivebox:8000 │
+ # Handle 0x0100, DMI type 1, 27 bytes
+ # System Information
+ # Manufacturer: DigitalOcean
+ # Product Name: Droplet
+ # Serial Number: 411922099
+ # UUID: fb65f41c-ec24-4539-beaf-f941903bdb2c
+ # ...
+ # Family: DigitalOcean_Droplet
+ dmidecode = subprocess.run(['dmidecode', '-t', 'system'], capture_output=True, text=True, check=True)
+ for line in dmidecode.stdout.split('\n'):
+ if 'Manufacturer:' in line:
+ hw_manufacturer = line.split(':', 1)[-1].strip()
+ elif 'Product Name:' in line:
+ hw_product = line.split(':', 1)[-1].strip()
+ elif 'UUID:' in line:
+ hw_uuid = line.split(':', 1)[-1].strip()
+ except Exception:
+ pass
+
+ # Check for VM fingerprint in manufacturer/product name
+ if 'qemu' in hw_product.lower() or 'vbox' in hw_product.lower() or 'lxc' in hw_product.lower() or 'vm' in hw_product.lower():
+ hw_in_vm = True
+
+ # Check for QEMU explicitly in pmap output
+ try:
+ result = subprocess.run(['pmap', '1'], capture_output=True, text=True, check=True)
+ if 'qemu' in result.stdout.lower():
+ hw_in_vm = True
+ except Exception:
+ pass
+
+ return {
+ "hw_in_docker": hw_in_docker,
+ "hw_in_vm": hw_in_vm,
+ "hw_manufacturer": hw_manufacturer,
+ "hw_product": hw_product,
+ "hw_uuid": hw_uuid,
+ }
+
+def get_public_ip() -> str:
+ def fetch_url(url: str) -> str:
+ with urllib.request.urlopen(url, timeout=5) as response:
+ return response.read().decode('utf-8').strip()
+
+ def fetch_dns(pubip_lookup_host: str) -> str:
+ return socket.gethostbyname(pubip_lookup_host).strip()
+
+ methods = [
+ (lambda: fetch_url("https://ipinfo.io/ip"), lambda r: r),
+ (lambda: fetch_url("https://api.ipify.org?format=json"), lambda r: json.loads(r)['ip']),
+ (lambda: fetch_dns("myip.opendns.com"), lambda r: r),
+ (lambda: fetch_url("http://whatismyip.akamai.com/"), lambda r: r), # try HTTP as final fallback in case of TLS/system time errors
+ ]
+
+ for fetch, parse in methods:
+ try:
+ result = parse(fetch())
+ if result:
+ return result
+ except Exception:
+ continue
+
+ raise Exception("Could not determine public IP address")
+
+def get_local_ip(remote_ip: str='1.1.1.1', remote_port: int=80) -> str:
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
+ s.connect((remote_ip, remote_port))
+ return s.getsockname()[0]
+ except Exception:
+ pass
+ return '127.0.0.1'
+
+ip_addrs = lambda addrs: (a for a in addrs if a.family == socket.AF_INET)
+mac_addrs = lambda addrs: (a for a in addrs if a.family == psutil.AF_LINK)
+
+def get_isp_info(ip=None):
+ # Get public IP
+ try:
+ ip = ip or urllib.request.urlopen('https://api.ipify.org').read().decode('utf8')
+ except Exception:
+ pass
+
+ # Get ISP name, city, and country
+ data = {}
+ try:
+ url = f'https://ipapi.co/{ip}/json/'
+ response = urllib.request.urlopen(url)
+ data = json.loads(response.read().decode())
+ except Exception:
+ pass
+
+ isp = data.get('org', 'Unknown')
+ city = data.get('city', 'Unknown')
+ region = data.get('region', 'Unknown')
+ country = data.get('country_name', 'Unknown')
+
+ # Get system DNS resolver servers
+ dns_server = None
+ try:
+ result = subprocess.run(['dig', 'example.com', 'A'], capture_output=True, text=True, check=True).stdout
+ dns_server = result.split(';; SERVER: ', 1)[-1].split('\n')[0].split('#')[0].strip()
+ except Exception:
+ pass
+
+ # Get DNS resolver's ISP name
+ # url = f'https://ipapi.co/{dns_server}/json/'
+ # dns_isp = json.loads(urllib.request.urlopen(url).read().decode()).get('org', 'Unknown')
+
+ return {
+ 'isp': isp,
+ 'city': city,
+ 'region': region,
+ 'country': country,
+ 'dns_server': dns_server,
+ # 'net_dns_isp': dns_isp,
+ }
+
+def get_host_network() -> Dict[str, Any]:
+ default_gateway_local_ip = get_local_ip()
+ gateways = psutil.net_if_addrs()
+
+ for interface, ips in gateways.items():
+ for local_ip in ip_addrs(ips):
+ if default_gateway_local_ip == local_ip.address:
+ mac_address = next(mac_addrs(ips)).address
+ public_ip = get_public_ip()
+ return {
+ "hostname": max([socket.gethostname(), platform.node()], key=len),
+ "iface": interface,
+ "mac_address": mac_address,
+ "ip_local": local_ip.address,
+ "ip_public": public_ip,
+ # "is_behind_nat": local_ip.address != public_ip,
+ **get_isp_info(public_ip),
+ }
+
+ raise Exception("Could not determine host network info")
+
+
+def get_os_info() -> Dict[str, Any]:
+ os_release = platform.release()
+ if platform.system().lower() == 'darwin':
+ os_release = 'macOS ' + platform.mac_ver()[0]
+ else:
+ try:
+ os_release = subprocess.run(['lsb_release', '-ds'], capture_output=True, text=True, check=True).stdout.strip()
+ except Exception:
+ pass
+
+ return {
+ "os_arch": platform.machine(),
+ "os_family": platform.system().lower(),
+ "os_platform": platform.platform(),
+ "os_kernel": platform.version(),
+ "os_release": os_release,
+ }
+
+def get_host_stats() -> Dict[str, Any]:
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ tmp_usage = psutil.disk_usage(str(tmp_dir))
+ app_usage = psutil.disk_usage(str(PACKAGE_DIR))
+ data_usage = psutil.disk_usage(str(DATA_DIR))
+ mem_usage = psutil.virtual_memory()
+ swap_usage = psutil.swap_memory()
+ return {
+ "cpu_boot_time": datetime.fromtimestamp(psutil.boot_time()).isoformat(),
+ "cpu_count": psutil.cpu_count(logical=False),
+ "cpu_load": psutil.getloadavg(),
+ # "cpu_pct": psutil.cpu_percent(interval=1),
+ "mem_virt_used_pct": mem_usage.percent,
+ "mem_virt_used_gb": round(mem_usage.used / 1024 / 1024 / 1024, 3),
+ "mem_virt_free_gb": round(mem_usage.free / 1024 / 1024 / 1024, 3),
+ "mem_swap_used_pct": swap_usage.percent,
+ "mem_swap_used_gb": round(swap_usage.used / 1024 / 1024 / 1024, 3),
+ "mem_swap_free_gb": round(swap_usage.free / 1024 / 1024 / 1024, 3),
+ "disk_tmp_used_pct": tmp_usage.percent,
+ "disk_tmp_used_gb": round(tmp_usage.used / 1024 / 1024 / 1024, 3),
+ "disk_tmp_free_gb": round(tmp_usage.free / 1024 / 1024 / 1024, 3), # in GB
+ "disk_app_used_pct": app_usage.percent,
+ "disk_app_used_gb": round(app_usage.used / 1024 / 1024 / 1024, 3),
+ "disk_app_free_gb": round(app_usage.free / 1024 / 1024 / 1024, 3),
+ "disk_data_used_pct": data_usage.percent,
+ "disk_data_used_gb": round(data_usage.used / 1024 / 1024 / 1024, 3),
+ "disk_data_free_gb": round(data_usage.free / 1024 / 1024 / 1024, 3),
+ }
+
+def get_host_immutable_info(host_info: Dict[str, Any]) -> Dict[str, Any]:
+ return {
+ key: value
+ for key, value in host_info.items()
+ if key in ['guid', 'net_mac', 'os_family', 'cpu_arch']
+ }
+
+def get_host_guid() -> str:
+ return machineid.hashed_id('archivebox')
+
+# Example usage
+if __name__ == "__main__":
+ host_info = {
+ 'guid': get_host_guid(),
+ 'os': get_os_info(),
+ 'vm': get_vm_info(),
+ 'net': get_host_network(),
+ 'stats': get_host_stats(),
+ }
+ print(host_info)
+
+# {
+# 'guid': '1cd2dd279f8a854...6943f2384437991a',
+# 'os': {
+# 'os_arch': 'arm64',
+# 'os_family': 'darwin',
+# 'os_platform': 'macOS-14.6.1-arm64-arm-64bit',
+# 'os_kernel': 'Darwin Kernel Version 23.6.0: Mon Jul 29 21:14:30 PDT 2024; root:xnu-10063.141.2~1/RELEASE_ARM64_T6000',
+# 'os_release': 'macOS 14.6.1'
+# },
+# 'vm': {'hw_in_docker': False, 'hw_in_vm': False, 'hw_manufacturer': 'Apple', 'hw_product': 'Mac Studio Mac13,1', 'hw_uuid': '39A12B50-...-...-...-...'},
+# 'net': {
+# 'hostname': 'somehost.sub.example.com',
+# 'iface': 'en0',
+# 'mac_address': 'ab:cd:ef:12:34:56',
+# 'ip_local': '192.168.2.18',
+# 'ip_public': '123.123.123.123',
+# 'isp': 'AS-SONICTELECOM',
+# 'city': 'Berkeley',
+# 'region': 'California',
+# 'country': 'United States',
+# 'dns_server': '192.168.1.1'
+# },
+# 'stats': {
+# 'cpu_boot_time': '2024-09-24T21:20:16',
+# 'cpu_count': 10,
+# 'cpu_load': (2.35693359375, 4.013671875, 4.1171875),
+# 'mem_virt_used_pct': 66.0,
+# 'mem_virt_used_gb': 15.109,
+# 'mem_virt_free_gb': 0.065,
+# 'mem_swap_used_pct': 89.4,
+# 'mem_swap_used_gb': 8.045,
+# 'mem_swap_free_gb': 0.955,
+# 'disk_tmp_used_pct': 26.0,
+# 'disk_tmp_used_gb': 113.1,
+# 'disk_tmp_free_gb': 322.028,
+# 'disk_app_used_pct': 56.1,
+# 'disk_app_used_gb': 2138.796,
+# 'disk_app_free_gb': 1675.996,
+# 'disk_data_used_pct': 56.1,
+# 'disk_data_used_gb': 2138.796,
+# 'disk_data_free_gb': 1675.996
+# }
+# }
diff --git a/archivebox/machine/migrations/0001_initial.py b/archivebox/machine/migrations/0001_initial.py
new file mode 100644
index 00000000..815ed70e
--- /dev/null
+++ b/archivebox/machine/migrations/0001_initial.py
@@ -0,0 +1,144 @@
+# Generated by Django 5.1.1 on 2024-10-02 04:34
+
+import archivebox.abid_utils.models
+import charidfield.fields
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = []
+
+ operations = [
+ migrations.CreateModel(
+ name="Machine",
+ fields=[
+ (
+ "id",
+ models.UUIDField(
+ default=None,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ unique=True,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "abid",
+ charidfield.fields.CharIDField(
+ blank=True,
+ db_index=True,
+ default=None,
+ help_text="ABID-format identifier for this entity (e.g. snp_01BJQMF54D093DXEAWZ6JYRPAQ)",
+ max_length=30,
+ null=True,
+ prefix="mxn_",
+ unique=True,
+ ),
+ ),
+ (
+ "created_at",
+ archivebox.abid_utils.models.AutoDateTimeField(
+ db_index=True, default=None
+ ),
+ ),
+ ("modified_at", models.DateTimeField(auto_now=True)),
+ (
+ "guid",
+ models.CharField(
+ default=None, editable=False, max_length=64, unique=True
+ ),
+ ),
+ ("hostname", models.CharField(default=None, max_length=63)),
+ ("hw_in_docker", models.BooleanField(default=False)),
+ ("hw_in_vm", models.BooleanField(default=False)),
+ ("hw_manufacturer", models.CharField(default=None, max_length=63)),
+ ("hw_product", models.CharField(default=None, max_length=63)),
+ ("hw_uuid", models.CharField(default=None, max_length=255)),
+ ("os_arch", models.CharField(default=None, max_length=15)),
+ ("os_family", models.CharField(default=None, max_length=15)),
+ ("os_platform", models.CharField(default=None, max_length=63)),
+ ("os_release", models.CharField(default=None, max_length=63)),
+ ("os_kernel", models.CharField(default=None, max_length=255)),
+ ("stats", models.JSONField(default=None)),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="NetworkInterface",
+ fields=[
+ (
+ "id",
+ models.UUIDField(
+ default=None,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ unique=True,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "abid",
+ charidfield.fields.CharIDField(
+ blank=True,
+ db_index=True,
+ default=None,
+ help_text="ABID-format identifier for this entity (e.g. snp_01BJQMF54D093DXEAWZ6JYRPAQ)",
+ max_length=30,
+ null=True,
+ prefix="ixf_",
+ unique=True,
+ ),
+ ),
+ (
+ "created_at",
+ archivebox.abid_utils.models.AutoDateTimeField(
+ db_index=True, default=None
+ ),
+ ),
+ ("modified_at", models.DateTimeField(auto_now=True)),
+ (
+ "mac_address",
+ models.CharField(default=None, editable=False, max_length=17),
+ ),
+ (
+ "ip_public",
+ models.GenericIPAddressField(default=None, editable=False),
+ ),
+ (
+ "ip_local",
+ models.GenericIPAddressField(default=None, editable=False),
+ ),
+ (
+ "dns_server",
+ models.GenericIPAddressField(default=None, editable=False),
+ ),
+ ("iface", models.CharField(default=None, max_length=15)),
+ ("hostname", models.CharField(default=None, max_length=63)),
+ ("isp", models.CharField(default=None, max_length=63)),
+ ("city", models.CharField(default=None, max_length=63)),
+ ("region", models.CharField(default=None, max_length=63)),
+ ("country", models.CharField(default=None, max_length=63)),
+ (
+ "machine",
+ models.ForeignKey(
+ default=None,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="machine.machine",
+ ),
+ ),
+ ],
+ options={
+ "unique_together": {
+ ("machine", "ip_public", "ip_local", "mac_address", "dns_server")
+ },
+ },
+ ),
+ ]
diff --git a/archivebox/machine/migrations/__init__.py b/archivebox/machine/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/archivebox/machine/models.py b/archivebox/machine/models.py
new file mode 100644
index 00000000..1d8d390a
--- /dev/null
+++ b/archivebox/machine/models.py
@@ -0,0 +1,167 @@
+__package__ = 'archivebox.machine'
+
+import socket
+
+from django.db import models
+from archivebox.abid_utils.models import ABIDModel, ABIDField, AutoDateTimeField
+
+from .detect import get_host_guid, get_os_info, get_vm_info, get_host_network, get_host_stats
+
+CURRENT_MACHINE = None
+CURRENT_INTERFACE = None
+
+class MachineManager(models.Manager):
+ def current(self) -> 'Machine':
+ global CURRENT_MACHINE
+ if CURRENT_MACHINE:
+ return CURRENT_MACHINE
+
+ guid = get_host_guid()
+ try:
+ CURRENT_MACHINE = self.get(guid=guid)
+ return CURRENT_MACHINE
+ except self.model.DoesNotExist:
+ pass
+
+ CURRENT_MACHINE = self.model(
+ guid=guid,
+ hostname=socket.gethostname(),
+ **get_os_info(),
+ **get_vm_info(),
+ stats=get_host_stats(),
+ )
+ CURRENT_MACHINE.save()
+ return CURRENT_MACHINE
+
+class Machine(ABIDModel):
+ abid_prefix = 'mxn_'
+ abid_ts_src = 'self.created_at'
+ abid_uri_src = 'self.guid'
+ abid_subtype_src = '"01"'
+ abid_rand_src = 'self.id'
+ abid_drift_allowed = False
+
+ id = models.UUIDField(primary_key=True, default=None, null=False, editable=False, unique=True, verbose_name='ID')
+ abid = ABIDField(prefix=abid_prefix)
+
+ created_at = AutoDateTimeField(default=None, null=False, db_index=True)
+ modified_at = models.DateTimeField(auto_now=True)
+
+ # IMMUTABLE PROPERTIES
+ guid = models.CharField(max_length=64, default=None, null=False, unique=True, editable=False)
+
+ # MUTABLE PROPERTIES
+ hostname = models.CharField(max_length=63, default=None, null=False)
+
+ hw_in_docker = models.BooleanField(default=False, null=False)
+ hw_in_vm = models.BooleanField(default=False, null=False)
+ hw_manufacturer = models.CharField(max_length=63, default=None, null=False) # e.g. Apple
+ hw_product = models.CharField(max_length=63, default=None, null=False) # e.g. Mac Studio Mac13,1
+ hw_uuid = models.CharField(max_length=255, default=None, null=False) # e.g. 39A12B50-...-...-...-...
+
+ os_arch = models.CharField(max_length=15, default=None, null=False) # e.g. arm64
+ os_family = models.CharField(max_length=15, default=None, null=False) # e.g. darwin
+ os_platform = models.CharField(max_length=63, default=None, null=False) # e.g. macOS-14.6.1-arm64-arm-64bit
+ os_release = models.CharField(max_length=63, default=None, null=False) # e.g. macOS 14.6.1
+ os_kernel = models.CharField(max_length=255, default=None, null=False) # e.g. Darwin Kernel Version 23.6.0: Mon Jul 29 21:14:30 PDT 2024; root:xnu-10063.141.2~1/RELEASE_ARM64_T6000
+
+ stats = models.JSONField(default=None, null=False)
+
+ objects = MachineManager()
+
+ networkinterface_set: models.Manager['NetworkInterface']
+
+
+class NetworkInterfaceManager(models.Manager):
+ def current(self) -> 'NetworkInterface':
+ global CURRENT_INTERFACE
+ if CURRENT_INTERFACE:
+ return CURRENT_INTERFACE
+
+ machine = Machine.objects.current()
+ net_info = get_host_network()
+ try:
+ CURRENT_INTERFACE = self.get(
+ machine=machine,
+ ip_public=net_info['ip_public'],
+ ip_local=net_info['ip_local'],
+ mac_address=net_info['mac_address'],
+ dns_server=net_info['dns_server'],
+ )
+ return CURRENT_INTERFACE
+ except self.model.DoesNotExist:
+ pass
+
+ CURRENT_INTERFACE = self.model(
+ machine=machine,
+ **get_host_network(),
+ )
+ CURRENT_INTERFACE.save()
+ return CURRENT_INTERFACE
+
+
+
+class NetworkInterface(ABIDModel):
+ abid_prefix = 'ixf_'
+ abid_ts_src = 'self.machine.created_at'
+ abid_uri_src = 'self.machine.guid'
+ abid_subtype_src = 'self.iface'
+ abid_rand_src = 'self.id'
+ abid_drift_allowed = False
+
+ id = models.UUIDField(primary_key=True, default=None, null=False, editable=False, unique=True, verbose_name='ID')
+ abid = ABIDField(prefix=abid_prefix)
+
+ created_at = AutoDateTimeField(default=None, null=False, db_index=True)
+ modified_at = models.DateTimeField(auto_now=True)
+
+ machine = models.ForeignKey(Machine, on_delete=models.CASCADE, default=None, null=False)
+
+ # IMMUTABLE PROPERTIES
+ mac_address = models.CharField(max_length=17, default=None, null=False, editable=False) # e.g. ab:cd:ef:12:34:56
+ ip_public = models.GenericIPAddressField(default=None, null=False, editable=False) # e.g. 123.123.123.123 or 2001:0db8:85a3:0000:0000:8a2e:0370:7334
+ ip_local = models.GenericIPAddressField(default=None, null=False, editable=False) # e.g. 192.168.2.18 or 2001:0db8:85a3:0000:0000:8a2e:0370:7334
+ dns_server = models.GenericIPAddressField(default=None, null=False, editable=False) # e.g. 8.8.8.8 or 2001:0db8:85a3:0000:0000:8a2e:0370:7334
+
+ # MUTABLE PROPERTIES
+ iface = models.CharField(max_length=15, default=None, null=False) # e.g. en0
+ hostname = models.CharField(max_length=63, default=None, null=False) # e.g. somehost.sub.example.com
+ isp = models.CharField(max_length=63, default=None, null=False) # e.g. AS-SONICTELECOM
+ city = models.CharField(max_length=63, default=None, null=False) # e.g. Berkeley
+ region = models.CharField(max_length=63, default=None, null=False) # e.g. California
+ country = models.CharField(max_length=63, default=None, null=False) # e.g. United States
+
+ objects = NetworkInterfaceManager()
+
+ class Meta:
+ unique_together = (
+ ('machine', 'ip_public', 'ip_local', 'mac_address', 'dns_server'),
+ )
+
+
+# class InstalledBinary(ABIDModel):
+# abid_prefix = 'bin_'
+# abid_ts_src = 'self.machine.created_at'
+# abid_uri_src = 'self.machine.guid'
+# abid_subtype_src = 'self.binprovider'
+# abid_rand_src = 'self.id'
+# abid_drift_allowed = False
+
+# id = models.UUIDField(primary_key=True, default=None, null=False, editable=False, unique=True, verbose_name='ID')
+# abid = ABIDField(prefix=abid_prefix)
+
+# created_at = AutoDateTimeField(default=None, null=False, db_index=True)
+# modified_at = models.DateTimeField(auto_now=True)
+
+# machine = models.ForeignKey(Machine, on_delete=models.CASCADE, default=None, null=False)
+# binprovider = models.CharField(max_length=255, default=None, null=False)
+
+# name = models.CharField(max_length=255, default=None, null=False)
+# version = models.CharField(max_length=255, default=None, null=False)
+# abspath = models.CharField(max_length=255, default=None, null=False)
+# sha256 = models.CharField(max_length=255, default=None, null=False)
+
+# class Meta:
+# unique_together = (
+# ('machine', 'binprovider', 'version', 'abspath', 'sha256'),
+# )
diff --git a/pdm.lock b/pdm.lock
index 50dea8a6..9acb852e 100644
--- a/pdm.lock
+++ b/pdm.lock
@@ -5,7 +5,7 @@
groups = ["default", "all", "ldap", "sonic"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
-content_hash = "sha256:cdf785c77dcdb8927b7743c36374dc5f2377db78622d27eb8356648d61275a0a"
+content_hash = "sha256:3c924966bd7b6d20a3e653f83b72f7c4160088f136e0d4621650c96b23f75803"
[[metadata.targets]]
requires_python = "==3.11.*"
@@ -13,7 +13,7 @@ platform = "manylinux_2_17_x86_64"
[[metadata.targets]]
requires_python = "==3.11.*"
-platform = "macos_12_0_arm64"
+platform = "macos_14_0_arm64"
[[package]]
name = "annotated-types"
@@ -713,7 +713,7 @@ files = [
[[package]]
name = "httpcore"
-version = "1.0.5"
+version = "1.0.6"
requires_python = ">=3.8"
summary = "A minimal low-level HTTP client."
groups = ["default"]
@@ -723,8 +723,8 @@ dependencies = [
"h11<0.15,>=0.13",
]
files = [
- {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"},
- {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"},
+ {file = "httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f"},
+ {file = "httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f"},
]
[[package]]
@@ -842,12 +842,12 @@ files = [
[[package]]
name = "mailchecker"
-version = "6.0.9"
+version = "6.0.10"
summary = "Cross-language email validation. Backed by a database of thousands throwable email providers."
groups = ["default"]
marker = "python_version == \"3.11\""
files = [
- {file = "mailchecker-6.0.9.tar.gz", hash = "sha256:f17e907ffe6f6faedc243f57eb0c9c951f61dec9af8e96922c1dcd093389b88d"},
+ {file = "mailchecker-6.0.10.tar.gz", hash = "sha256:d933fecb90a66459c8aa543a272890f97c02f6cbf30a3f5016ce2a1699848bee"},
]
[[package]]
@@ -993,19 +993,6 @@ dependencies = [
"requests",
]
-[[package]]
-name = "pocket"
-version = "0.3.7"
-git = "https://github.com/tapanpandita/pocket.git"
-ref = "v0.3.7"
-revision = "5a144438cc89bfc0ec94db960718ccf1f76468c1"
-summary = "api wrapper for getpocket.com"
-groups = ["default"]
-marker = "python_version == \"3.11\""
-dependencies = [
- "requests",
-]
-
[[package]]
name = "prompt-toolkit"
version = "3.0.48"
@@ -1056,6 +1043,20 @@ files = [
{file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"},
]
+[[package]]
+name = "py-machineid"
+version = "0.6.0"
+summary = "Get the unique machine ID of any host (without admin privileges)"
+groups = ["default"]
+marker = "python_version == \"3.11\""
+dependencies = [
+ "winregistry; sys_platform == \"win32\"",
+]
+files = [
+ {file = "py-machineid-0.6.0.tar.gz", hash = "sha256:00c38d8521d429a4539bdd92967234db28a1a2b4b263062b351ca002332e633f"},
+ {file = "py_machineid-0.6.0-py3-none-any.whl", hash = "sha256:63214f8a98737311716b29d279716dc121a6495f16486caf5c032433f81cdfd6"},
+]
+
[[package]]
name = "pyasn1"
version = "0.6.1"
@@ -1144,7 +1145,7 @@ files = [
[[package]]
name = "pydantic-pkgr"
-version = "0.3.7"
+version = "0.3.8"
requires_python = ">=3.10"
summary = "System package manager APIs in strongly typed Python"
groups = ["default"]
@@ -1155,8 +1156,8 @@ dependencies = [
"typing-extensions>=4.11.0",
]
files = [
- {file = "pydantic_pkgr-0.3.7-py3-none-any.whl", hash = "sha256:fdb63b2cee79d7c9d53673b9d61afa846921fd4950a8c16a8c4d2555cd0f6478"},
- {file = "pydantic_pkgr-0.3.7.tar.gz", hash = "sha256:6e575cdc3584d375eb8d5024e5e8bade1c225c2aee3af1a076951dbc1a2c1f2d"},
+ {file = "pydantic_pkgr-0.3.8-py3-none-any.whl", hash = "sha256:fefa34449feb8fc09d73d6beb8a61afe5959b1a848f0a5bba9db1d092d7099be"},
+ {file = "pydantic_pkgr-0.3.8.tar.gz", hash = "sha256:5ca12f4ee1c82ce0a2231c36b898534899a40a9e77cc4c97175fac9d1dc6e351"},
]
[[package]]
@@ -1427,19 +1428,19 @@ files = [
[[package]]
name = "rich"
-version = "13.8.1"
-requires_python = ">=3.7.0"
+version = "13.9.1"
+requires_python = ">=3.8.0"
summary = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
groups = ["default"]
marker = "python_version == \"3.11\""
dependencies = [
"markdown-it-py>=2.2.0",
"pygments<3.0.0,>=2.13.0",
- "typing-extensions<5.0,>=4.0.0; python_version < \"3.9\"",
+ "typing-extensions<5.0,>=4.0.0; python_version < \"3.11\"",
]
files = [
- {file = "rich-13.8.1-py3-none-any.whl", hash = "sha256:1760a3c0848469b97b558fc61c85233e3dafb69c7a071b4d60c38099d3cd4c06"},
- {file = "rich-13.8.1.tar.gz", hash = "sha256:8260cda28e3db6bf04d2d1ef4dbc03ba80a824c88b0e7668a0f23126a424844a"},
+ {file = "rich-13.9.1-py3-none-any.whl", hash = "sha256:b340e739f30aa58921dc477b8adaa9ecdb7cecc217be01d93730ee1bc8aa83be"},
+ {file = "rich-13.9.1.tar.gz", hash = "sha256:097cffdf85db1babe30cc7deba5ab3a29e1b9885047dab24c57e9a7f8a9c1466"},
]
[[package]]
@@ -1815,7 +1816,7 @@ files = [
[[package]]
name = "yt-dlp"
-version = "2024.8.6"
+version = "2024.9.27"
requires_python = ">=3.8"
summary = "A feature-rich command-line audio/video downloader"
groups = ["default"]
@@ -1828,11 +1829,11 @@ dependencies = [
"pycryptodomex",
"requests<3,>=2.32.2",
"urllib3<3,>=1.26.17",
- "websockets>=12.0",
+ "websockets>=13.0",
]
files = [
- {file = "yt_dlp-2024.8.6-py3-none-any.whl", hash = "sha256:ab507ff600bd9269ad4d654e309646976778f0e243eaa2f6c3c3214278bb2922"},
- {file = "yt_dlp-2024.8.6.tar.gz", hash = "sha256:e8551f26bc8bf67b99c12373cc87ed2073436c3437e53290878d0f4b4bb1f663"},
+ {file = "yt_dlp-2024.9.27-py3-none-any.whl", hash = "sha256:2717468dd697fcfcf9a89f493ba30a3830cdfb276c09750e5b561b08b9ef5f69"},
+ {file = "yt_dlp-2024.9.27.tar.gz", hash = "sha256:86605542e17e2e23ad23145b637ec308133762a15a5dedac4ae50b7973237026"},
]
[[package]]
diff --git a/pyproject.toml b/pyproject.toml
index 3313933b..434f8e8f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -86,6 +86,7 @@ dependencies = [
"base32-crockford==0.3.0",
############# Extractor Dependencies #############
"yt-dlp>=2024.8.6", # for: media
+ "py-machineid>=0.6.0",
]
# pdm lock --group=':all'
diff --git a/requirements.txt b/requirements.txt
index 4ca23b98..25feaaf1 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -46,7 +46,7 @@ executing==2.1.0; python_version == "3.11"
feedparser==6.0.11; python_version == "3.11"
ftfy==6.2.3; python_version == "3.11"
h11==0.14.0; python_version == "3.11"
-httpcore==1.0.5; python_version == "3.11"
+httpcore==1.0.6; python_version == "3.11"
httpx==0.27.2; python_version == "3.11"
huey==2.5.2; python_version == "3.11"
hyperlink==21.0.0; python_version == "3.11"
@@ -54,7 +54,7 @@ idna==3.10; python_version == "3.11"
incremental==24.7.2; python_version == "3.11"
ipython==8.27.0; python_version == "3.11"
jedi==0.19.1; python_version == "3.11"
-mailchecker==6.0.9; python_version == "3.11"
+mailchecker==6.0.10; python_version == "3.11"
markdown-it-py==3.0.0; python_version == "3.11"
matplotlib-inline==0.1.7; python_version == "3.11"
mdurl==0.1.2; python_version == "3.11"
@@ -76,7 +76,7 @@ pycparser==2.22; platform_python_implementation != "PyPy" and python_version ==
pycryptodomex==3.20.0; python_version == "3.11"
pydantic==2.9.2; python_version == "3.11"
pydantic-core==2.23.4; python_version == "3.11"
-pydantic-pkgr==0.3.7; python_version == "3.11"
+pydantic-pkgr==0.3.8; python_version == "3.11"
pydantic-settings==2.5.2; python_version == "3.11"
pygments==2.18.0; python_version == "3.11"
pyopenssl==24.2.1; python_version == "3.11"
@@ -94,7 +94,7 @@ pytz==2024.2; python_version == "3.11"
pyyaml==6.0.2; python_version == "3.11"
regex==2024.9.11; python_version == "3.11"
requests==2.32.3; python_version == "3.11"
-rich==13.8.1; python_version == "3.11"
+rich==13.9.1; python_version == "3.11"
service-identity==24.1.0; python_version == "3.11"
setuptools==75.1.0; python_version == "3.11"
sgmllib3k==1.0.0; python_version == "3.11"
@@ -122,5 +122,5 @@ wcwidth==0.2.13; python_version == "3.11"
websockets==13.1; python_version == "3.11"
xlrd==2.0.1; python_version == "3.11"
xmltodict==0.13.0; python_version == "3.11"
-yt-dlp==2024.8.6; python_version == "3.11"
+yt-dlp==2024.9.27; python_version == "3.11"
zope-interface==7.0.3; python_version == "3.11"