ddns 4.1.0b3__py2.py3-none-any.whl → 4.1.1b1__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of ddns might be problematic. Click here for more details.
- ddns/__init__.py +2 -2
- ddns/__main__.py +19 -9
- ddns/config/__init__.py +171 -0
- ddns/config/cli.py +366 -0
- ddns/config/config.py +214 -0
- ddns/config/env.py +77 -0
- ddns/config/file.py +169 -0
- ddns/ip.py +50 -4
- ddns/provider/cloudflare.py +25 -6
- ddns/scheduler/__init__.py +72 -0
- ddns/scheduler/_base.py +80 -0
- ddns/scheduler/cron.py +111 -0
- ddns/scheduler/launchd.py +130 -0
- ddns/scheduler/schtasks.py +120 -0
- ddns/scheduler/systemd.py +139 -0
- ddns/util/fileio.py +2 -2
- ddns/util/try_run.py +37 -0
- {ddns-4.1.0b3.dist-info → ddns-4.1.1b1.dist-info}/METADATA +2 -2
- {ddns-4.1.0b3.dist-info → ddns-4.1.1b1.dist-info}/RECORD +23 -11
- {ddns-4.1.0b3.dist-info → ddns-4.1.1b1.dist-info}/WHEEL +0 -0
- {ddns-4.1.0b3.dist-info → ddns-4.1.1b1.dist-info}/entry_points.txt +0 -0
- {ddns-4.1.0b3.dist-info → ddns-4.1.1b1.dist-info}/licenses/LICENSE +0 -0
- {ddns-4.1.0b3.dist-info → ddns-4.1.1b1.dist-info}/top_level.txt +0 -0
ddns/scheduler/_base.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Base scheduler class for DDNS task management
|
|
4
|
+
@author: NewFuture
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from logging import Logger, getLogger # noqa: F401
|
|
10
|
+
|
|
11
|
+
from .. import __version__ as version
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseScheduler(object):
|
|
15
|
+
"""Base class for all task schedulers"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, logger=None): # type: (Logger | None) -> None
|
|
18
|
+
self.logger = (logger or getLogger()).getChild("task")
|
|
19
|
+
|
|
20
|
+
def _get_ddns_cmd(self): # type: () -> list[str]
|
|
21
|
+
"""Get DDNS command for scheduled execution as array"""
|
|
22
|
+
if hasattr(sys.modules["__main__"], "__compiled__"):
|
|
23
|
+
return [sys.argv[0]]
|
|
24
|
+
else:
|
|
25
|
+
return [sys.executable, "-m", "ddns"]
|
|
26
|
+
|
|
27
|
+
def _build_ddns_command(self, ddns_args=None): # type: (dict | None) -> list[str]
|
|
28
|
+
"""Build DDNS command with arguments as array"""
|
|
29
|
+
# Get base command as array
|
|
30
|
+
cmd_parts = self._get_ddns_cmd()
|
|
31
|
+
|
|
32
|
+
if not ddns_args:
|
|
33
|
+
return cmd_parts
|
|
34
|
+
|
|
35
|
+
# Filter out debug=False to reduce noise
|
|
36
|
+
args = {k: v for k, v in ddns_args.items() if not (k == "debug" and not v)}
|
|
37
|
+
|
|
38
|
+
for key, value in args.items():
|
|
39
|
+
if isinstance(value, bool):
|
|
40
|
+
cmd_parts.extend(["--{}".format(key), str(value).lower()])
|
|
41
|
+
elif isinstance(value, list):
|
|
42
|
+
for item in value:
|
|
43
|
+
cmd_parts.extend(["--{}".format(key), str(item)])
|
|
44
|
+
else:
|
|
45
|
+
cmd_parts.extend(["--{}".format(key), str(value)])
|
|
46
|
+
|
|
47
|
+
return cmd_parts
|
|
48
|
+
|
|
49
|
+
def _quote_command_array(self, cmd_array): # type: (list[str]) -> str
|
|
50
|
+
"""Convert command array to properly quoted command string"""
|
|
51
|
+
return " ".join('"{}"'.format(arg) if " " in arg else arg for arg in cmd_array)
|
|
52
|
+
|
|
53
|
+
def _get_description(self): # type: () -> str
|
|
54
|
+
"""Generate standard description/comment for DDNS installation"""
|
|
55
|
+
date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
56
|
+
return "auto-update v{} installed on {}".format(version, date)
|
|
57
|
+
|
|
58
|
+
def is_installed(self): # type: () -> bool
|
|
59
|
+
"""Check if DDNS task is installed"""
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
|
|
62
|
+
def get_status(self): # type: () -> dict
|
|
63
|
+
"""Get detailed status information"""
|
|
64
|
+
raise NotImplementedError
|
|
65
|
+
|
|
66
|
+
def install(self, interval, ddns_args=None): # type: (int, dict | None) -> bool
|
|
67
|
+
"""Install DDNS scheduled task"""
|
|
68
|
+
raise NotImplementedError
|
|
69
|
+
|
|
70
|
+
def uninstall(self): # type: () -> bool
|
|
71
|
+
"""Uninstall DDNS scheduled task"""
|
|
72
|
+
raise NotImplementedError
|
|
73
|
+
|
|
74
|
+
def enable(self): # type: () -> bool
|
|
75
|
+
"""Enable DDNS scheduled task"""
|
|
76
|
+
raise NotImplementedError
|
|
77
|
+
|
|
78
|
+
def disable(self): # type: () -> bool
|
|
79
|
+
"""Disable DDNS scheduled task"""
|
|
80
|
+
raise NotImplementedError
|
ddns/scheduler/cron.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Cron-based task scheduler for Unix-like systems
|
|
4
|
+
@author: NewFuture
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
|
|
11
|
+
from ..util.fileio import write_file
|
|
12
|
+
from ..util.try_run import try_run
|
|
13
|
+
from ._base import BaseScheduler
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CronScheduler(BaseScheduler):
|
|
17
|
+
"""Cron-based task scheduler for Unix-like systems"""
|
|
18
|
+
|
|
19
|
+
SCHEDULER_NAME = "cron"
|
|
20
|
+
|
|
21
|
+
KEY = "# DDNS:"
|
|
22
|
+
|
|
23
|
+
def _update_crontab(self, lines): # type: (list[str]) -> bool
|
|
24
|
+
"""Update crontab with new content"""
|
|
25
|
+
try:
|
|
26
|
+
temp_path = tempfile.mktemp(suffix=".cron")
|
|
27
|
+
write_file(temp_path, u"\n".join(lines) + u"\n") # fmt: skip
|
|
28
|
+
subprocess.check_call(["crontab", temp_path])
|
|
29
|
+
os.unlink(temp_path)
|
|
30
|
+
return True
|
|
31
|
+
except Exception as e:
|
|
32
|
+
self.logger.error("Failed to update crontab: %s", e)
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
def is_installed(self, crontab_content=None): # type: (str | None) -> bool
|
|
36
|
+
result = crontab_content or try_run(["crontab", "-l"], logger=self.logger) or ""
|
|
37
|
+
return self.KEY in result
|
|
38
|
+
|
|
39
|
+
def get_status(self):
|
|
40
|
+
status = {"scheduler": "cron", "installed": False} # type: dict[str, str | bool | int | None]
|
|
41
|
+
# Get crontab content once and reuse it for all checks
|
|
42
|
+
crontab_content = try_run(["crontab", "-l"], logger=self.logger) or ""
|
|
43
|
+
lines = crontab_content.splitlines()
|
|
44
|
+
line = next((i for i in lines if self.KEY in i), "").strip()
|
|
45
|
+
|
|
46
|
+
if line: # Task is installed
|
|
47
|
+
status["installed"] = True
|
|
48
|
+
status["enabled"] = bool(line and not line.startswith("#"))
|
|
49
|
+
else: # Task not installed
|
|
50
|
+
status["enabled"] = False
|
|
51
|
+
|
|
52
|
+
cmd_groups = line.split(self.KEY, 1) if line else ["", ""]
|
|
53
|
+
parts = cmd_groups[0].strip(" #\t").split() if cmd_groups[0] else []
|
|
54
|
+
status["interval"] = int(parts[0][2:]) if len(parts) >= 5 and parts[0].startswith("*/") else None
|
|
55
|
+
status["command"] = " ".join(parts[5:]) if len(parts) >= 6 else None
|
|
56
|
+
status["description"] = cmd_groups[1].strip() if len(cmd_groups) > 1 else None
|
|
57
|
+
|
|
58
|
+
return status
|
|
59
|
+
|
|
60
|
+
def install(self, interval, ddns_args=None):
|
|
61
|
+
ddns_commands = self._build_ddns_command(ddns_args)
|
|
62
|
+
# Convert array to properly quoted command string for cron
|
|
63
|
+
ddns_command = self._quote_command_array(ddns_commands)
|
|
64
|
+
description = self._get_description()
|
|
65
|
+
cron_entry = '*/{} * * * * cd "{}" && {} # DDNS: {}'.format(interval, os.getcwd(), ddns_command, description)
|
|
66
|
+
|
|
67
|
+
crontext = try_run(["crontab", "-l"], logger=self.logger) or ""
|
|
68
|
+
lines = [line for line in crontext.splitlines() if self.KEY not in line]
|
|
69
|
+
lines.append(cron_entry)
|
|
70
|
+
|
|
71
|
+
if self._update_crontab(lines):
|
|
72
|
+
return True
|
|
73
|
+
else:
|
|
74
|
+
self.logger.error("Failed to install DDNS cron job")
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
def uninstall(self):
|
|
78
|
+
return self._modify_cron_lines("uninstall")
|
|
79
|
+
|
|
80
|
+
def enable(self):
|
|
81
|
+
return self._modify_cron_lines("enable")
|
|
82
|
+
|
|
83
|
+
def disable(self):
|
|
84
|
+
return self._modify_cron_lines("disable")
|
|
85
|
+
|
|
86
|
+
def _modify_cron_lines(self, action): # type: (str) -> bool
|
|
87
|
+
"""Helper to enable, disable, or uninstall cron lines"""
|
|
88
|
+
crontext = try_run(["crontab", "-l"], logger=self.logger)
|
|
89
|
+
if not crontext or self.KEY not in crontext:
|
|
90
|
+
self.logger.info("No crontab found")
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
modified_lines = []
|
|
94
|
+
for line in crontext.rstrip("\n").splitlines():
|
|
95
|
+
if self.KEY not in line:
|
|
96
|
+
modified_lines.append(line)
|
|
97
|
+
elif action == "uninstall":
|
|
98
|
+
continue # Skip DDNS lines (remove them)
|
|
99
|
+
elif action == "enable" and line.strip().startswith("#"):
|
|
100
|
+
uncommented = line.lstrip(" #\t").lstrip() # Enable: uncomment the line
|
|
101
|
+
modified_lines.append(uncommented if uncommented else line)
|
|
102
|
+
elif action == "disable" and not line.strip().startswith("#"):
|
|
103
|
+
modified_lines.append("# " + line) # Disable: comment the line
|
|
104
|
+
else:
|
|
105
|
+
raise ValueError("Invalid action: {}".format(action))
|
|
106
|
+
|
|
107
|
+
if self._update_crontab(modified_lines):
|
|
108
|
+
return True
|
|
109
|
+
else:
|
|
110
|
+
self.logger.error("Failed to %s DDNS cron job", action)
|
|
111
|
+
return False
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
macOS launchd-based task scheduler
|
|
4
|
+
@author: NewFuture
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from ..util.fileio import read_file_safely, write_file
|
|
11
|
+
from ..util.try_run import try_run
|
|
12
|
+
from ._base import BaseScheduler
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LaunchdScheduler(BaseScheduler):
|
|
16
|
+
"""macOS launchd-based task scheduler"""
|
|
17
|
+
|
|
18
|
+
LABEL = "cc.newfuture.ddns"
|
|
19
|
+
|
|
20
|
+
def _get_plist_path(self):
|
|
21
|
+
return os.path.expanduser("~/Library/LaunchAgents/{}.plist".format(self.LABEL))
|
|
22
|
+
|
|
23
|
+
def is_installed(self):
|
|
24
|
+
return os.path.exists(self._get_plist_path())
|
|
25
|
+
|
|
26
|
+
def get_status(self):
|
|
27
|
+
# Read plist content once and use it to determine installation status
|
|
28
|
+
content = read_file_safely(self._get_plist_path())
|
|
29
|
+
status = {"scheduler": "launchd", "installed": bool(content)}
|
|
30
|
+
if not content:
|
|
31
|
+
return status
|
|
32
|
+
|
|
33
|
+
# For launchd, check if service is actually loaded/enabled
|
|
34
|
+
result = try_run(["launchctl", "list"], logger=self.logger)
|
|
35
|
+
status["enabled"] = bool(result) and self.LABEL in result
|
|
36
|
+
|
|
37
|
+
# Get interval
|
|
38
|
+
interval_match = re.search(r"<key>StartInterval</key>\s*<integer>(\d+)</integer>", content)
|
|
39
|
+
status["interval"] = int(interval_match.group(1)) // 60 if interval_match else None
|
|
40
|
+
|
|
41
|
+
# Get command
|
|
42
|
+
program_match = re.search(r"<key>Program</key>\s*<string>([^<]+)</string>", content)
|
|
43
|
+
if program_match:
|
|
44
|
+
status["command"] = program_match.group(1)
|
|
45
|
+
else:
|
|
46
|
+
args_section = re.search(r"<key>ProgramArguments</key>\s*<array>(.*?)</array>", content, re.DOTALL)
|
|
47
|
+
if args_section:
|
|
48
|
+
strings = re.findall(r"<string>([^<]+)</string>", args_section.group(1))
|
|
49
|
+
if strings:
|
|
50
|
+
status["command"] = " ".join(strings)
|
|
51
|
+
|
|
52
|
+
# Get comments
|
|
53
|
+
desc_match = re.search(r"<key>Description</key>\s*<string>([^<]+)</string>", content)
|
|
54
|
+
status["description"] = desc_match.group(1) if desc_match else None
|
|
55
|
+
|
|
56
|
+
return status
|
|
57
|
+
|
|
58
|
+
def install(self, interval, ddns_args=None):
|
|
59
|
+
plist_path = self._get_plist_path()
|
|
60
|
+
program_args = self._build_ddns_command(ddns_args)
|
|
61
|
+
|
|
62
|
+
# Create comment with version and install date (consistent with Windows)
|
|
63
|
+
comment = self._get_description()
|
|
64
|
+
|
|
65
|
+
# Generate plist XML using template string for proper macOS plist format
|
|
66
|
+
program_args_xml = "".join(" <string>{}</string>\n".format(arg) for arg in program_args)
|
|
67
|
+
|
|
68
|
+
plist_content = """<?xml version="1.0" encoding="UTF-8"?>
|
|
69
|
+
<plist version="1.0">
|
|
70
|
+
<dict>
|
|
71
|
+
<key>Label</key>
|
|
72
|
+
<string>{label}</string>
|
|
73
|
+
<key>Description</key>
|
|
74
|
+
<string>{description}</string>
|
|
75
|
+
<key>ProgramArguments</key>
|
|
76
|
+
<array>{program}</array>
|
|
77
|
+
<key>StartInterval</key>
|
|
78
|
+
<integer>{interval}</integer>
|
|
79
|
+
<key>RunAtLoad</key>
|
|
80
|
+
<true/>
|
|
81
|
+
<key>WorkingDirectory</key>
|
|
82
|
+
<string>{dir}</string>
|
|
83
|
+
</dict>
|
|
84
|
+
</plist>""".format(
|
|
85
|
+
label=self.LABEL, description=comment, program=program_args_xml, interval=interval * 60, dir=os.getcwd()
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
write_file(plist_path, plist_content)
|
|
89
|
+
result = try_run(["launchctl", "load", plist_path], logger=self.logger)
|
|
90
|
+
if result is not None:
|
|
91
|
+
self.logger.info("DDNS launchd service installed successfully")
|
|
92
|
+
return True
|
|
93
|
+
else:
|
|
94
|
+
self.logger.error("Failed to load launchd service")
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
def uninstall(self):
|
|
98
|
+
plist_path = self._get_plist_path()
|
|
99
|
+
try_run(["launchctl", "unload", plist_path], logger=self.logger) # Ignore errors
|
|
100
|
+
try:
|
|
101
|
+
os.remove(plist_path)
|
|
102
|
+
except OSError:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
self.logger.info("DDNS launchd service uninstalled successfully")
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
def enable(self):
|
|
109
|
+
plist_path = self._get_plist_path()
|
|
110
|
+
if not os.path.exists(plist_path):
|
|
111
|
+
self.logger.error("DDNS launchd service not found")
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
result = try_run(["launchctl", "load", plist_path], logger=self.logger)
|
|
115
|
+
if result is not None:
|
|
116
|
+
self.logger.info("DDNS launchd service enabled successfully")
|
|
117
|
+
return True
|
|
118
|
+
else:
|
|
119
|
+
self.logger.error("Failed to enable launchd service")
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
def disable(self):
|
|
123
|
+
plist_path = self._get_plist_path()
|
|
124
|
+
result = try_run(["launchctl", "unload", plist_path], logger=self.logger)
|
|
125
|
+
if result is not None:
|
|
126
|
+
self.logger.info("DDNS launchd service disabled successfully")
|
|
127
|
+
return True
|
|
128
|
+
else:
|
|
129
|
+
self.logger.error("Failed to disable launchd service")
|
|
130
|
+
return False
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
schtasks-based task scheduler
|
|
4
|
+
@author: NewFuture
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import tempfile
|
|
10
|
+
|
|
11
|
+
from ..util.try_run import try_run
|
|
12
|
+
from ._base import BaseScheduler
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SchtasksScheduler(BaseScheduler):
|
|
16
|
+
"""schtasks-based task scheduler"""
|
|
17
|
+
|
|
18
|
+
NAME = "DDNS"
|
|
19
|
+
|
|
20
|
+
def _schtasks(self, *args):
|
|
21
|
+
"""Helper to run schtasks commands with consistent error handling"""
|
|
22
|
+
result = try_run(["schtasks"] + list(args), logger=self.logger)
|
|
23
|
+
return result is not None
|
|
24
|
+
|
|
25
|
+
def _extract_xml(self, xml_text, tag_name):
|
|
26
|
+
"""Extract XML tag content using regex for better performance and flexibility"""
|
|
27
|
+
pattern = r"<{0}[^>]*>(.*?)</{0}>".format(re.escape(tag_name))
|
|
28
|
+
match = re.search(pattern, xml_text, re.DOTALL)
|
|
29
|
+
return match.group(1).strip() if match else None
|
|
30
|
+
|
|
31
|
+
def is_installed(self):
|
|
32
|
+
result = try_run(["schtasks", "/query", "/tn", self.NAME], logger=self.logger) or ""
|
|
33
|
+
return self.NAME in result
|
|
34
|
+
|
|
35
|
+
def get_status(self):
|
|
36
|
+
# Use XML format for language-independent parsing
|
|
37
|
+
task_xml = try_run(["schtasks", "/query", "/tn", self.NAME, "/xml"], logger=self.logger)
|
|
38
|
+
status = {"scheduler": "schtasks", "installed": bool(task_xml)}
|
|
39
|
+
|
|
40
|
+
if not task_xml:
|
|
41
|
+
return status # Task not installed, return minimal status
|
|
42
|
+
|
|
43
|
+
status["enabled"] = self._extract_xml(task_xml, "Enabled") != "false"
|
|
44
|
+
command = self._extract_xml(task_xml, "Command")
|
|
45
|
+
arguments = self._extract_xml(task_xml, "Arguments")
|
|
46
|
+
status["command"] = "{} {}".format(command, arguments) if command and arguments else command
|
|
47
|
+
|
|
48
|
+
# Parse interval: PT10M -> 10, fallback to original string
|
|
49
|
+
interval_str = self._extract_xml(task_xml, "Interval")
|
|
50
|
+
interval_match = re.search(r"PT(\d+)M", interval_str) if interval_str else None
|
|
51
|
+
status["interval"] = int(interval_match.group(1)) if interval_match else interval_str
|
|
52
|
+
|
|
53
|
+
# Show description if exists, otherwise show installation date
|
|
54
|
+
description = self._extract_xml(task_xml, "Description") or self._extract_xml(task_xml, "Date")
|
|
55
|
+
if description:
|
|
56
|
+
status["description"] = description
|
|
57
|
+
return status
|
|
58
|
+
|
|
59
|
+
def install(self, interval, ddns_args=None):
|
|
60
|
+
# Build command line as array: prefer pythonw for script mode, or compiled exe directly
|
|
61
|
+
cmd_array = self._build_ddns_command(ddns_args)
|
|
62
|
+
workdir = os.getcwd()
|
|
63
|
+
|
|
64
|
+
# Split array into executable and arguments for schtasks XML format
|
|
65
|
+
# For Windows scheduler, prefer pythonw.exe to avoid console window
|
|
66
|
+
executable = cmd_array[0].replace("python.exe", "pythonw.exe")
|
|
67
|
+
arguments = self._quote_command_array(cmd_array[1:]) if len(cmd_array) > 1 else ""
|
|
68
|
+
|
|
69
|
+
# Create XML task definition with working directory support
|
|
70
|
+
description = self._get_description()
|
|
71
|
+
|
|
72
|
+
# Use template string to generate Windows Task Scheduler XML
|
|
73
|
+
xml_content = """<?xml version="1.0" encoding="UTF-16"?>
|
|
74
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
75
|
+
<RegistrationInfo>
|
|
76
|
+
<Description>{description}</Description>
|
|
77
|
+
</RegistrationInfo>
|
|
78
|
+
<Triggers>
|
|
79
|
+
<TimeTrigger>
|
|
80
|
+
<StartBoundary>1900-01-01T00:00:00</StartBoundary>
|
|
81
|
+
<Repetition>
|
|
82
|
+
<Interval>PT{interval}M</Interval>
|
|
83
|
+
</Repetition>
|
|
84
|
+
<Enabled>true</Enabled>
|
|
85
|
+
</TimeTrigger>
|
|
86
|
+
</Triggers>
|
|
87
|
+
<Actions>
|
|
88
|
+
<Exec>
|
|
89
|
+
<Command>{exe}</Command>
|
|
90
|
+
<Arguments>{arguments}</Arguments>
|
|
91
|
+
<WorkingDirectory>{dir}</WorkingDirectory>
|
|
92
|
+
</Exec>
|
|
93
|
+
</Actions>
|
|
94
|
+
<Settings>
|
|
95
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
96
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
97
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
98
|
+
</Settings>
|
|
99
|
+
</Task>""".format(description=description, interval=interval, exe=executable, arguments=arguments, dir=workdir)
|
|
100
|
+
|
|
101
|
+
# Write XML to temp file and use it to create task
|
|
102
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".xml", delete=False) as f:
|
|
103
|
+
f.write(xml_content)
|
|
104
|
+
xml_file = f.name
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
success = self._schtasks("/Create", "/XML", xml_file, "/TN", self.NAME, "/F")
|
|
108
|
+
return success
|
|
109
|
+
finally:
|
|
110
|
+
os.unlink(xml_file)
|
|
111
|
+
|
|
112
|
+
def uninstall(self):
|
|
113
|
+
success = self._schtasks("/Delete", "/TN", self.NAME, "/F")
|
|
114
|
+
return success
|
|
115
|
+
|
|
116
|
+
def enable(self):
|
|
117
|
+
return self._schtasks("/Change", "/TN", self.NAME, "/Enable")
|
|
118
|
+
|
|
119
|
+
def disable(self):
|
|
120
|
+
return self._schtasks("/Change", "/TN", self.NAME, "/Disable")
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Systemd timer-based task scheduler for Linux
|
|
4
|
+
@author: NewFuture
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from ..util.fileio import read_file_safely, write_file
|
|
11
|
+
from ..util.try_run import try_run
|
|
12
|
+
from ._base import BaseScheduler
|
|
13
|
+
|
|
14
|
+
try: # python 3
|
|
15
|
+
PermissionError # type: ignore
|
|
16
|
+
except NameError: # python 2 doesn't have PermissionError, use OSError instead
|
|
17
|
+
PermissionError = IOError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SystemdScheduler(BaseScheduler):
|
|
21
|
+
"""Systemd timer-based task scheduler for Linux"""
|
|
22
|
+
|
|
23
|
+
SERVICE_NAME = "ddns.service"
|
|
24
|
+
TIMER_NAME = "ddns.timer"
|
|
25
|
+
SERVICE_PATH = "/etc/systemd/system/ddns.service"
|
|
26
|
+
TIMER_PATH = "/etc/systemd/system/ddns.timer"
|
|
27
|
+
|
|
28
|
+
def _systemctl(self, *args):
|
|
29
|
+
"""Run systemctl command and return success status"""
|
|
30
|
+
result = try_run(["systemctl"] + list(args), logger=self.logger)
|
|
31
|
+
return result is not None
|
|
32
|
+
|
|
33
|
+
def is_installed(self):
|
|
34
|
+
"""Check if systemd timer files exist"""
|
|
35
|
+
return os.path.exists(self.SERVICE_PATH) and os.path.exists(self.TIMER_PATH)
|
|
36
|
+
|
|
37
|
+
def get_status(self):
|
|
38
|
+
"""Get comprehensive status information"""
|
|
39
|
+
installed = self.is_installed()
|
|
40
|
+
status = {"scheduler": "systemd", "installed": installed}
|
|
41
|
+
if not installed:
|
|
42
|
+
return status
|
|
43
|
+
|
|
44
|
+
# Check if timer is enabled
|
|
45
|
+
result = try_run(["systemctl", "is-enabled", self.TIMER_NAME], logger=self.logger)
|
|
46
|
+
status["enabled"] = bool(result and result.strip() == "enabled")
|
|
47
|
+
|
|
48
|
+
# Extract interval from timer file
|
|
49
|
+
timer_content = read_file_safely(self.TIMER_PATH) or ""
|
|
50
|
+
match = re.search(r"OnUnitActiveSec=(\d+)m", timer_content)
|
|
51
|
+
status["interval"] = int(match.group(1)) if match else None
|
|
52
|
+
|
|
53
|
+
# Extract command and description from service file
|
|
54
|
+
service_content = read_file_safely(self.SERVICE_PATH) or ""
|
|
55
|
+
match = re.search(r"ExecStart=(.+)", service_content)
|
|
56
|
+
status["command"] = match.group(1).strip() if match else None
|
|
57
|
+
desc_match = re.search(r"Description=(.+)", service_content)
|
|
58
|
+
status["description"] = desc_match.group(1).strip() if desc_match else None
|
|
59
|
+
|
|
60
|
+
return status
|
|
61
|
+
|
|
62
|
+
def install(self, interval, ddns_args=None):
|
|
63
|
+
"""Install systemd timer with specified interval"""
|
|
64
|
+
ddns_commands = self._build_ddns_command(ddns_args)
|
|
65
|
+
# Convert array to properly quoted command string for ExecStart
|
|
66
|
+
ddns_command = self._quote_command_array(ddns_commands)
|
|
67
|
+
work_dir = os.getcwd()
|
|
68
|
+
description = self._get_description()
|
|
69
|
+
|
|
70
|
+
# Create service file content
|
|
71
|
+
service_content = u"""[Unit]
|
|
72
|
+
Description={}
|
|
73
|
+
After=network.target
|
|
74
|
+
|
|
75
|
+
[Service]
|
|
76
|
+
Type=oneshot
|
|
77
|
+
WorkingDirectory={}
|
|
78
|
+
ExecStart={}
|
|
79
|
+
""".format(description, work_dir, ddns_command) # fmt: skip
|
|
80
|
+
|
|
81
|
+
# Create timer file content
|
|
82
|
+
timer_content = u"""[Unit]
|
|
83
|
+
Description=DDNS automatic IP update timer
|
|
84
|
+
Requires={}
|
|
85
|
+
|
|
86
|
+
[Timer]
|
|
87
|
+
OnUnitActiveSec={}m
|
|
88
|
+
Unit={}
|
|
89
|
+
|
|
90
|
+
[Install]
|
|
91
|
+
WantedBy=multi-user.target
|
|
92
|
+
""".format(self.SERVICE_NAME, interval, self.SERVICE_NAME) # fmt: skip
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
# Write service and timer files
|
|
96
|
+
write_file(self.SERVICE_PATH, service_content)
|
|
97
|
+
write_file(self.TIMER_PATH, timer_content)
|
|
98
|
+
except PermissionError as e:
|
|
99
|
+
self.logger.debug("Permission denied when writing systemd files: %s", e)
|
|
100
|
+
print("Permission denied. Please run as root or use sudo.")
|
|
101
|
+
print("or use cron scheduler (with --scheduler=cron) instead.")
|
|
102
|
+
return False
|
|
103
|
+
except Exception as e:
|
|
104
|
+
self.logger.error("Failed to write systemd files: %s", e)
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
if self._systemctl("daemon-reload") and self._systemctl("enable", self.TIMER_NAME):
|
|
108
|
+
self._systemctl("start", self.TIMER_NAME)
|
|
109
|
+
return True
|
|
110
|
+
else:
|
|
111
|
+
self.logger.error("Failed to enable/start systemd timer")
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
def uninstall(self):
|
|
115
|
+
"""Uninstall systemd timer and service"""
|
|
116
|
+
self.disable() # Stop and disable timer
|
|
117
|
+
# Remove systemd files
|
|
118
|
+
try:
|
|
119
|
+
os.remove(self.SERVICE_PATH)
|
|
120
|
+
os.remove(self.TIMER_PATH)
|
|
121
|
+
self._systemctl("daemon-reload") # Reload systemd configuration
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
except PermissionError as e:
|
|
125
|
+
self.logger.debug("Permission denied when removing systemd files: %s", e)
|
|
126
|
+
print("Permission denied. Please run as root or use sudo.")
|
|
127
|
+
return False
|
|
128
|
+
except Exception as e:
|
|
129
|
+
self.logger.error("Failed to remove systemd files: %s", e)
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
def enable(self):
|
|
133
|
+
"""Enable and start systemd timer"""
|
|
134
|
+
return self._systemctl("enable", self.TIMER_NAME) and self._systemctl("start", self.TIMER_NAME)
|
|
135
|
+
|
|
136
|
+
def disable(self):
|
|
137
|
+
"""Disable and stop systemd timer"""
|
|
138
|
+
self._systemctl("stop", self.TIMER_NAME)
|
|
139
|
+
return self._systemctl("disable", self.TIMER_NAME)
|
ddns/util/fileio.py
CHANGED
|
@@ -23,7 +23,7 @@ def _ensure_directory_exists(file_path): # type: (str) -> None
|
|
|
23
23
|
os.makedirs(directory)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def read_file_safely(file_path, encoding="utf-8", default=None): # type: (str, str, str|None) -> str
|
|
26
|
+
def read_file_safely(file_path, encoding="utf-8", default=None): # type: (str, str, str|None) -> str
|
|
27
27
|
"""
|
|
28
28
|
Safely read file content with UTF-8 encoding, return None if file doesn't exist or can't be read
|
|
29
29
|
|
|
@@ -37,7 +37,7 @@ def read_file_safely(file_path, encoding="utf-8", default=None): # type: (str,
|
|
|
37
37
|
try:
|
|
38
38
|
return read_file(file_path, encoding)
|
|
39
39
|
except Exception:
|
|
40
|
-
return default
|
|
40
|
+
return default # type: ignore
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def write_file_safely(file_path, content, encoding="utf-8"): # type: (str, str, str) -> bool
|
ddns/util/try_run.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Utility: Safe command execution wrapper used across the project.
|
|
4
|
+
Provides a single try_run function with consistent behavior.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def try_run(command, logger=None, **kwargs):
|
|
12
|
+
# type: (list, object, **object) -> str | None
|
|
13
|
+
"""Safely run a subprocess command and return decoded output or None on failure.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
command (list): Command array to execute
|
|
17
|
+
logger (object, optional): Logger instance for debug output
|
|
18
|
+
**kwargs: Additional arguments passed to subprocess.check_output
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
str or None: Command output as string, or None if command failed
|
|
22
|
+
|
|
23
|
+
- Adds a default timeout=60s on Python 3 to avoid hangs
|
|
24
|
+
- Decodes output as text via universal_newlines=True
|
|
25
|
+
- Logs at debug level when logger is provided
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
if sys.version_info[0] >= 3 and "timeout" not in kwargs:
|
|
29
|
+
kwargs["timeout"] = 60
|
|
30
|
+
return subprocess.check_output(command, universal_newlines=True, **kwargs) # type: ignore
|
|
31
|
+
except Exception as e: # noqa: BLE001 - broad for subprocess safety
|
|
32
|
+
if logger is not None:
|
|
33
|
+
try:
|
|
34
|
+
logger.debug("Command failed: %s", e) # type: ignore
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
return None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ddns
|
|
3
|
-
Version: 4.1.
|
|
3
|
+
Version: 4.1.1b1
|
|
4
4
|
Summary: Dynamic DNS client for multiple providers, supporting IPv4 and IPv6.
|
|
5
5
|
Author-email: NewFuture <python@newfuture.cc>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -138,7 +138,7 @@ Dynamic: license-file
|
|
|
138
138
|
也可使用一键安装脚本自动下载并安装对应平台的二进制:
|
|
139
139
|
|
|
140
140
|
```bash
|
|
141
|
-
curl
|
|
141
|
+
curl -#fSL https://ddns.newfuture.cc/install.sh | sh
|
|
142
142
|
```
|
|
143
143
|
提示:安装到系统目录(如 /usr/local/bin)可能需要 root 或 sudo 权限;若权限不足,可改为 `sudo sh` 运行。
|
|
144
144
|
|