diff --git a/pddnsc.py b/pddnsc.py deleted file mode 100644 index e663c49..0000000 --- a/pddnsc.py +++ /dev/null @@ -1,227 +0,0 @@ -import httpx -import asyncio -from abc import ABC, abstractmethod -import toml - - -class BaseSourceProvider(ABC): - _childs = {} - registred = {} - - def __init__(self, name, config, ipv4t, ipv6t): - self.name, self.config, self.ipv4t, self.ipv6t = ( - name, - config, - ipv4t, - ipv6t, - ) - - def __str__(self): - return f"{self.__class__.__name__}: {self.name}" - - async def fetch_all(self) -> tuple[str, str, str]: - results = await asyncio.gather( - self.fetch_v4(), self.fetch_v6(), return_exceptions=True - ) - return (self.name,) + tuple( - None if isinstance(i, Exception) else i for i in results - ) - - def __init_subclass__(cls) -> None: - BaseSourceProvider._childs[cls.__name__] = cls - return super().__init_subclass__() - - @classmethod - def validate_source_config(cls, name, config): - if "provider" not in config: - return False - prov_name = config["provider"] - if prov_name not in cls._childs: - return False - return True - - @classmethod - def register_provider(cls, name, config, ipv4t, ipv6t): - if not cls.validate_source_config(name, config): - return - provider = cls._childs[config["provider"]] - cls.registred[name] = provider(name, config, ipv4t, ipv6t) - - @abstractmethod - async def fetch_v4(self) -> str: - ... - - @abstractmethod - async def fetch_v6(self) -> str: - ... - - -class BaseDNSProvider(ABC): - _childs = {} - registred = {} - - def __init__(self, name, config, ipv4t, ipv6t): - self.name, self.config = name, config - self.ipv4t, self.ipv6t = ipv4t, ipv6t - - def __init_subclass__(cls) -> None: - BaseDNSProvider._childs[cls.__name__] = cls - return super().__init_subclass__() - - def __str__(self): - return f"{self.__class__.__name__}: {self.name}" - - def best_client(self, addr_v4, addr_v6): - if addr_v6 is None and addr_v4 is not None: - return self.ipv4t - return self.ipv6t - - @classmethod - def validate_source_config(cls, name, config): - if "provider" not in config: - return False - prov_name = config["provider"] - if prov_name not in cls._childs: - return False - return True - - @classmethod - def register_provider(cls, name, config, ipv4t, ipv6t): - if not cls.validate_source_config(name, config): - return - provider = cls._childs[config["provider"]] - cls.registred[name] = provider(name, config, ipv4t, ipv6t) - - async def set_addrs(self, source_provider, addr_v4, addr_v6): - return await self.set_addrs_imp(source_provider, addr_v4, addr_v6) - - @abstractmethod - async def set_addrs_imp(self, source_provider, addr_v4, addr_v6): - ... - - -class DummySource(BaseSourceProvider): - async def fetch_v4(self) -> str: - async with httpx.AsyncClient(transport=self.ipv4t) as client: - result = await asyncio.sleep(10, result=None) - result = await asyncio.sleep(10, result=None) - return result - - async def fetch_v6(self) -> str: - async with httpx.AsyncClient(transport=self.ipv6t) as client: - result = await asyncio.sleep(10, result=None) - result = await asyncio.sleep(10, result=None) - return result - - -class FakeSource(BaseSourceProvider): - async def fetch_v4(self) -> str: - async with httpx.AsyncClient(transport=self.ipv4t) as client: - result = await asyncio.sleep( - 3.3, result=self.config.get("ipv4", "127.0.0.1") - ) - return result - - async def fetch_v6(self) -> str: - async with httpx.AsyncClient(transport=self.ipv6t) as client: - result = await asyncio.sleep(4.4, result=self.config.get("ipv6", "::1")) - return result - - -class IPIFYSource(BaseSourceProvider): - async def fetch_v4(self) -> str: - async with httpx.AsyncClient(transport=self.ipv4t) as client: - response = await client.get("https://api4.ipify.org/?format=json") - if response.status_code == httpx.codes.OK: - data = response.json() - result = None if not isinstance(data, dict) else data.get("ip") - return result - - async def fetch_v6(self) -> str: - async with httpx.AsyncClient(transport=self.ipv6t) as client: - response = await client.get("https://api6.ipify.org/?format=json") - if response.status_code == httpx.codes.OK: - data = response.json() - result = None if not isinstance(data, dict) else data.get("ip") - return result - - -class JustPrint(BaseDNSProvider): - async def set_addrs_imp(self, source_provider, addr_v4, addr_v6): - print(f">> {self.name}") - print(f"addresses from: {source_provider}") - print(f"IPv4: {addr_v4}") - print(f"IPv6: {addr_v6}") - await asyncio.sleep(2.2, result=None) - - -async def source_task(providers): - result = None - is_done = False - pending = [asyncio.create_task(p.fetch_all()) for p in providers] - while not is_done and pending: - done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED) - for x in done: - result = x.result() - if len(result) == 3 and result[1] or result[2]: - is_done = True - break - - if pending: - gather = asyncio.gather(*pending) - gather.cancel() - try: - await gather - except asyncio.CancelledError: - pass - return result - - -async def output_task(providers, result): - await asyncio.gather( - *(asyncio.create_task(p.set_addrs(*result)) for p in providers) - ) - - -async def app(ipv4t, ipv6t): - config = toml.load("settings/config.toml") - for source_name in config["sources"]: - BaseSourceProvider.register_provider( - source_name, config["sources"][source_name], ipv4t, ipv6t - ) - - for output_name in config["outputs"]: - BaseDNSProvider.register_provider( - output_name, config["outputs"][output_name], ipv4t, ipv6t - ) - - debug = config.get("debug", False) - if debug: - print("DEBUG info:") - print( - f"source classes: {[*BaseSourceProvider._childs]}, {[*map(str, BaseSourceProvider._childs.values())]}" - ) - print( - f"output classes: {[*BaseDNSProvider._childs]}, {[*map(str, BaseDNSProvider._childs.values())]}" - ) - print( - f"source providers: {[*BaseSourceProvider.registred]}, {[*map(str, BaseSourceProvider.registred.values())]}" - ) - print( - f"output providers: {[*BaseDNSProvider.registred]}, {[*map(str, BaseDNSProvider.registred.values())]}" - ) - print(config) - - result = await source_task(BaseSourceProvider.registred.values()) - await output_task(BaseDNSProvider.registred.values(), result) - - -async def main(): - async with httpx.AsyncHTTPTransport( - local_address="0.0.0.0", - ) as ipv4t, httpx.AsyncHTTPTransport(local_address="::") as ipv6t: - await app(ipv4t, ipv6t) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/pddnsc/__init__.py b/pddnsc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pddnsc/base.py b/pddnsc/base.py new file mode 100644 index 0000000..03e93e5 --- /dev/null +++ b/pddnsc/base.py @@ -0,0 +1,98 @@ +import httpx +import asyncio +from abc import ABC, abstractmethod + +class BaseSourceProvider(ABC): + _childs = {} + registred = {} + + def __init__(self, name, config, ipv4t, ipv6t): + self.name, self.config, self.ipv4t, self.ipv6t = ( + name, + config, + ipv4t, + ipv6t, + ) + + def __str__(self): + return f"{self.__class__.__name__}: {self.name}" + + async def fetch_all(self) -> tuple[str, str, str]: + results = await asyncio.gather( + self.fetch_v4(), self.fetch_v6(), return_exceptions=True + ) + return (self.name,) + tuple( + None if isinstance(i, Exception) else i for i in results + ) + + def __init_subclass__(cls) -> None: + BaseSourceProvider._childs[cls.__name__] = cls + return super().__init_subclass__() + + @classmethod + def validate_source_config(cls, name, config): + if "provider" not in config: + return False + prov_name = config["provider"] + if prov_name not in cls._childs: + return False + return True + + @classmethod + def register_provider(cls, name, config, ipv4t, ipv6t): + if not cls.validate_source_config(name, config): + return + provider = cls._childs[config["provider"]] + cls.registred[name] = provider(name, config, ipv4t, ipv6t) + + @abstractmethod + async def fetch_v4(self) -> str: + ... + + @abstractmethod + async def fetch_v6(self) -> str: + ... + + +class BaseOutputProvider(ABC): + _childs = {} + registred = {} + + def __init__(self, name, config, ipv4t, ipv6t): + self.name, self.config = name, config + self.ipv4t, self.ipv6t = ipv4t, ipv6t + + def __init_subclass__(cls) -> None: + BaseOutputProvider._childs[cls.__name__] = cls + return super().__init_subclass__() + + def __str__(self): + return f"{self.__class__.__name__}: {self.name}" + + def best_client(self, addr_v4, addr_v6): + if addr_v6 is None and addr_v4 is not None: + return self.ipv4t + return self.ipv6t + + @classmethod + def validate_source_config(cls, name, config): + if "provider" not in config: + return False + prov_name = config["provider"] + if prov_name not in cls._childs: + return False + return True + + @classmethod + def register_provider(cls, name, config, ipv4t, ipv6t): + if not cls.validate_source_config(name, config): + return + provider = cls._childs[config["provider"]] + cls.registred[name] = provider(name, config, ipv4t, ipv6t) + + async def set_addrs(self, source_provider, addr_v4, addr_v6): + return await self.set_addrs_imp(source_provider, addr_v4, addr_v6) + + @abstractmethod + async def set_addrs_imp(self, source_provider, addr_v4, addr_v6): + ... diff --git a/pddnsc/cli.py b/pddnsc/cli.py new file mode 100644 index 0000000..47fa6b3 --- /dev/null +++ b/pddnsc/cli.py @@ -0,0 +1,78 @@ +import httpx +import asyncio +from abc import ABC, abstractmethod +import toml +from .base import BaseSourceProvider, BaseOutputProvider +from . import sources +from . import outputs + +async def source_task(providers): + result = None + is_done = False + pending = [asyncio.create_task(p.fetch_all()) for p in providers] + while not is_done and pending: + done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED) + for x in done: + result = x.result() + if len(result) == 3 and result[1] or result[2]: + is_done = True + break + + if pending: + gather = asyncio.gather(*pending) + gather.cancel() + try: + await gather + except asyncio.CancelledError: + pass + return result + + +async def output_task(providers, result): + await asyncio.gather( + *(asyncio.create_task(p.set_addrs(*result)) for p in providers) + ) + + +async def app(ipv4t, ipv6t): + config = toml.load("settings/config.toml") + for source_name in config["sources"]: + BaseSourceProvider.register_provider( + source_name, config["sources"][source_name], ipv4t, ipv6t + ) + + for output_name in config["outputs"]: + BaseOutputProvider.register_provider( + output_name, config["outputs"][output_name], ipv4t, ipv6t + ) + + debug = config.get("debug", False) + if debug: + print("DEBUG info:") + print( + f"source classes: {[*BaseSourceProvider._childs]}, {[*map(str, BaseSourceProvider._childs.values())]}" + ) + print( + f"output classes: {[*BaseOutputProvider._childs]}, {[*map(str, BaseOutputProvider._childs.values())]}" + ) + print( + f"source providers: {[*BaseSourceProvider.registred]}, {[*map(str, BaseSourceProvider.registred.values())]}" + ) + print( + f"output providers: {[*BaseOutputProvider.registred]}, {[*map(str, BaseOutputProvider.registred.values())]}" + ) + print(config) + + result = await source_task(BaseSourceProvider.registred.values()) + await output_task(BaseOutputProvider.registred.values(), result) + + +async def main(): + async with httpx.AsyncHTTPTransport( + local_address="0.0.0.0", + ) as ipv4t, httpx.AsyncHTTPTransport(local_address="::") as ipv6t: + await app(ipv4t, ipv6t) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pddnsc/outputs/__init__.py b/pddnsc/outputs/__init__.py new file mode 100644 index 0000000..c97dfea --- /dev/null +++ b/pddnsc/outputs/__init__.py @@ -0,0 +1,3 @@ +from pddnsc.plugins import load_plugins + +load_plugins(__file__) \ No newline at end of file diff --git a/pddnsc/outputs/console.py b/pddnsc/outputs/console.py new file mode 100644 index 0000000..36a2062 --- /dev/null +++ b/pddnsc/outputs/console.py @@ -0,0 +1,11 @@ +import asyncio + +from pddnsc.base import BaseOutputProvider + +class JustPrint(BaseOutputProvider): + async def set_addrs_imp(self, source_provider, addr_v4, addr_v6): + print(f">> {self.name}") + print(f"addresses from: {source_provider}") + print(f"IPv4: {addr_v4}") + print(f"IPv6: {addr_v6}") + await asyncio.sleep(2.2, result=None) diff --git a/pddnsc/outputs/files.py b/pddnsc/outputs/files.py new file mode 100644 index 0000000..13a18df --- /dev/null +++ b/pddnsc/outputs/files.py @@ -0,0 +1,29 @@ +import asyncio +import aiofiles +import json +import hashlib + +from pddnsc.base import BaseOutputProvider + + +class StateFile(BaseOutputProvider): + async def set_addrs_imp(self, source_provider, addr_v4, addr_v6): + state = { + "ipv4": addr_v4 or "", + "ipv6": addr_v6 or "", + } + state_str = json.dumps(state) + async with aiofiles.open(self.config['filepath'], mode='w', encoding='utf-8') as f: + await f.write(state_str) + + +class StateHashFile(BaseOutputProvider): + async def set_addrs_imp(self, source_provider, addr_v4, addr_v6): + state = { + "ipv4": addr_v4 or "", + "ipv6": addr_v6 or "", + } + state_str = json.dumps(state) + sha = hashlib.sha256(state_str.encode(encoding='utf-8')) + async with aiofiles.open(self.config['filepath'], mode='w', encoding='utf-8') as f: + await f.write(sha.hexdigest()) diff --git a/pddnsc/plugins.py b/pddnsc/plugins.py new file mode 100644 index 0000000..be09711 --- /dev/null +++ b/pddnsc/plugins.py @@ -0,0 +1,22 @@ +import os +import traceback +from importlib import util + + +def load_module(path): + name = os.path.split(path)[-1] + spec = util.spec_from_file_location(name, path) + module = util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def load_plugins(init_filepath): + dirpath = os.path.dirname(os.path.abspath(init_filepath)) + for fname in os.listdir(dirpath): + if not fname.startswith('.') and \ + not fname.startswith('__') and fname.endswith('.py'): + try: + load_module(os.path.join(dirpath, fname)) + except Exception: + traceback.print_exc() diff --git a/pddnsc/sources/__init__.py b/pddnsc/sources/__init__.py new file mode 100644 index 0000000..1587c10 --- /dev/null +++ b/pddnsc/sources/__init__.py @@ -0,0 +1,3 @@ +from pddnsc.plugins import load_plugins + +load_plugins(__file__) diff --git a/pddnsc/sources/fake.py b/pddnsc/sources/fake.py new file mode 100644 index 0000000..ac0254e --- /dev/null +++ b/pddnsc/sources/fake.py @@ -0,0 +1,31 @@ +import httpx +import asyncio + +from pddnsc.base import BaseSourceProvider + +class DummySource(BaseSourceProvider): + async def fetch_v4(self) -> str: + async with httpx.AsyncClient(transport=self.ipv4t) as client: + result = await asyncio.sleep(10, result=None) + result = await asyncio.sleep(10, result=None) + return result + + async def fetch_v6(self) -> str: + async with httpx.AsyncClient(transport=self.ipv6t) as client: + result = await asyncio.sleep(10, result=None) + result = await asyncio.sleep(10, result=None) + return result + + +class FakeSource(BaseSourceProvider): + async def fetch_v4(self) -> str: + async with httpx.AsyncClient(transport=self.ipv4t) as client: + result = await asyncio.sleep( + 3.3, result=self.config.get("ipv4", "127.0.0.1") + ) + return result + + async def fetch_v6(self) -> str: + async with httpx.AsyncClient(transport=self.ipv6t) as client: + result = await asyncio.sleep(4.4, result=self.config.get("ipv6", "::1")) + return result diff --git a/pddnsc/sources/ipfy.py b/pddnsc/sources/ipfy.py new file mode 100644 index 0000000..5ab3095 --- /dev/null +++ b/pddnsc/sources/ipfy.py @@ -0,0 +1,20 @@ +import httpx + +from pddnsc.base import BaseSourceProvider + +class IPIFYSource(BaseSourceProvider): + async def fetch_v4(self) -> str: + async with httpx.AsyncClient(transport=self.ipv4t) as client: + response = await client.get("https://api4.ipify.org/?format=json") + if response.status_code == httpx.codes.OK: + data = response.json() + result = None if not isinstance(data, dict) else data.get("ip") + return result + + async def fetch_v6(self) -> str: + async with httpx.AsyncClient(transport=self.ipv6t) as client: + response = await client.get("https://api6.ipify.org/?format=json") + if response.status_code == httpx.codes.OK: + data = response.json() + result = None if not isinstance(data, dict) else data.get("ip") + return result diff --git a/requirements.txt b/requirements.txt index 5a13cf5..488197d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ httpx[http2]>=0.26,<1.0 asyncio>=3.4.3,<4 +aiofiles>=23,<24 toml>=0.10,<1 diff --git a/settings/config.toml b/settings/config.toml index 3b77480..35b7969 100644 --- a/settings/config.toml +++ b/settings/config.toml @@ -8,7 +8,11 @@ debug = true ipv6 = "fe80::1" [outputs] - [outputs.test1-out] - provider = "JustPrint" - [outputs.test2-out] + [outputs.print] provider = "JustPrint" + [outputs.file] + provider = "StateFile" + filepath = "state/state.json" + [outputs.hash-file] + provider = "StateHashFile" + filepath = "state/hash.txt"