first commit
This commit is contained in:
18
.venv/lib/python3.12/site-packages/hetzner/__init__.py
Normal file
18
.venv/lib/python3.12/site-packages/hetzner/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
class RobotError(Exception):
|
||||
def __init__(self, message, status=None):
|
||||
if status is not None:
|
||||
message = "{0} ({1})".format(message, status)
|
||||
super(RobotError, self).__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
class ManualReboot(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ConnectError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class WebRobotError(RobotError):
|
||||
pass
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
60
.venv/lib/python3.12/site-packages/hetzner/failover.py
Normal file
60
.venv/lib/python3.12/site-packages/hetzner/failover.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from hetzner import RobotError
|
||||
|
||||
__all__ = ['Failover', 'FailoverManager']
|
||||
|
||||
|
||||
class Failover(object):
|
||||
ip = None
|
||||
server_ip = None
|
||||
server_number = None
|
||||
active_server_ip = None
|
||||
|
||||
def __repr__(self):
|
||||
return "%s (destination: %s, booked on %s (%s))" % (
|
||||
self.ip, self.active_server_ip, self.server_number, self.server_ip)
|
||||
|
||||
def __init__(self, data):
|
||||
for attr, value in data.items():
|
||||
if hasattr(self, attr):
|
||||
setattr(self, attr, value)
|
||||
|
||||
|
||||
class FailoverManager(object):
|
||||
def __init__(self, conn, servers):
|
||||
self.conn = conn
|
||||
self.servers = servers
|
||||
|
||||
def list(self):
|
||||
failovers = {}
|
||||
try:
|
||||
ips = self.conn.get('/failover')
|
||||
except RobotError as err:
|
||||
if err.status == 404:
|
||||
return failovers
|
||||
else:
|
||||
raise
|
||||
for ip in ips:
|
||||
failover = Failover(ip.get('failover'))
|
||||
failovers[failover.ip] = failover
|
||||
return failovers
|
||||
|
||||
def set(self, ip, new_destination):
|
||||
failovers = self.list()
|
||||
if ip not in failovers.keys():
|
||||
raise RobotError(
|
||||
"Invalid IP address '%s'. Failover IP addresses are %s"
|
||||
% (ip, failovers.keys()))
|
||||
failover = failovers.get(ip)
|
||||
if new_destination == failover.active_server_ip:
|
||||
raise RobotError(
|
||||
"%s is already the active destination of failover IP %s"
|
||||
% (new_destination, ip))
|
||||
available_dests = [s.ip for s in list(self.servers)]
|
||||
if new_destination not in available_dests:
|
||||
raise RobotError(
|
||||
"Invalid destination '%s'. "
|
||||
"The destination is not in your server list: %s"
|
||||
% (new_destination, available_dests))
|
||||
result = self.conn.post('/failover/%s'
|
||||
% ip, {'active_server_ip': new_destination})
|
||||
return Failover(result.get('failover'))
|
||||
65
.venv/lib/python3.12/site-packages/hetzner/rdns.py
Normal file
65
.venv/lib/python3.12/site-packages/hetzner/rdns.py
Normal file
@@ -0,0 +1,65 @@
|
||||
try:
|
||||
from urllib import urlencode
|
||||
except ImportError:
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from hetzner import RobotError
|
||||
|
||||
__all__ = ['ReverseDNS', 'ReverseDNSManager']
|
||||
|
||||
|
||||
class ReverseDNS(object):
|
||||
def __init__(self, conn, ip=None, result=None):
|
||||
self.conn = conn
|
||||
self.ip = ip
|
||||
self.update_info(result)
|
||||
|
||||
def update_info(self, result=None):
|
||||
if result is None:
|
||||
try:
|
||||
result = self.conn.get('/rdns/{0}'.format(self.ip))
|
||||
except RobotError as err:
|
||||
if err.status == 404:
|
||||
result = None
|
||||
else:
|
||||
raise
|
||||
|
||||
if result is not None:
|
||||
data = result['rdns']
|
||||
self.ip = data['ip']
|
||||
self.ptr = data['ptr']
|
||||
else:
|
||||
self.ptr = None
|
||||
|
||||
def set(self, value):
|
||||
self.conn.post('/rdns/{0}'.format(self.ip), {'ptr': value})
|
||||
|
||||
def remove(self):
|
||||
self.conn.delete('/rdns/{0}'.format(self.ip))
|
||||
|
||||
def __repr__(self):
|
||||
return "<ReverseDNS PTR: {0}>".format(self.ptr)
|
||||
|
||||
|
||||
class ReverseDNSManager(object):
|
||||
def __init__(self, conn, main_ip=None):
|
||||
self.conn = conn
|
||||
self.main_ip = main_ip
|
||||
|
||||
def get(self, ip):
|
||||
return ReverseDNS(self.conn, ip)
|
||||
|
||||
def __iter__(self):
|
||||
if self.main_ip is None:
|
||||
url = '/rdns'
|
||||
else:
|
||||
data = urlencode({'server_ip': self.main_ip})
|
||||
url = '/rdns?{0}'.format(data)
|
||||
try:
|
||||
result = self.conn.get(url)
|
||||
except RobotError as err:
|
||||
if err.status == 404:
|
||||
result = []
|
||||
else:
|
||||
raise
|
||||
return iter([ReverseDNS(self.conn, result=rdns) for rdns in result])
|
||||
110
.venv/lib/python3.12/site-packages/hetzner/reset.py
Normal file
110
.venv/lib/python3.12/site-packages/hetzner/reset.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import socket
|
||||
import time
|
||||
|
||||
from hetzner import ConnectError, ManualReboot
|
||||
|
||||
|
||||
class Reset(object):
|
||||
def __init__(self, server):
|
||||
self.server = server
|
||||
self.conn = server.conn
|
||||
|
||||
def check_ssh(self, port=22, timeout=5):
|
||||
"""
|
||||
Check if the current server has an open SSH port. Return True if port
|
||||
is reachable, otherwise false. Time out after 'timeout' seconds.
|
||||
"""
|
||||
success = True
|
||||
old_timeout = socket.getdefaulttimeout()
|
||||
socket.setdefaulttimeout(5)
|
||||
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.connect((self.server.ip, port))
|
||||
s.close()
|
||||
except socket.error:
|
||||
success = False
|
||||
|
||||
socket.setdefaulttimeout(old_timeout)
|
||||
return success
|
||||
|
||||
def observed_reboot(self, patience=300, tries=None, manual=False):
|
||||
"""
|
||||
Reboot and wait patience seconds until the system comes back.
|
||||
If not, retry with the next step in tries and wait another patience
|
||||
seconds. Repeat until there are no more tries left.
|
||||
|
||||
If manual is true, do a manual reboot in case the server doesn't come
|
||||
up again. Raises a ManualReboot exception if that is the case.
|
||||
|
||||
Return True on success and False if the system didn't come up.
|
||||
"""
|
||||
is_down = False
|
||||
|
||||
if tries is None:
|
||||
if self.server.is_vserver:
|
||||
tries = ['hard']
|
||||
else:
|
||||
tries = ['soft', 'hard']
|
||||
|
||||
for mode in tries:
|
||||
self.server.logger.info("Tring to reboot using the %r method.",
|
||||
mode)
|
||||
self.reboot(mode)
|
||||
|
||||
start_time = time.time()
|
||||
self.server.logger.info("Waiting for machine to become available.")
|
||||
while True:
|
||||
current_time = time.time()
|
||||
if current_time > start_time + patience:
|
||||
self.server.logger.info(
|
||||
"Machine didn't come up after %d seconds.",
|
||||
patience
|
||||
)
|
||||
break
|
||||
|
||||
is_up = self.check_ssh()
|
||||
time.sleep(1)
|
||||
|
||||
if is_up and is_down:
|
||||
self.server.logger.info("Machine just became available.")
|
||||
return
|
||||
elif not is_down:
|
||||
is_down = not is_up
|
||||
if manual:
|
||||
self.reboot('manual')
|
||||
raise ManualReboot("Issued a manual reboot because the server"
|
||||
" did not come back to life.")
|
||||
else:
|
||||
raise ConnectError("Server keeps playing dead after reboot :-(")
|
||||
|
||||
def reboot(self, mode='soft'):
|
||||
"""
|
||||
Reboot the server, modes are "soft" for reboot by triggering Ctrl-Alt-
|
||||
Del, "hard" for triggering a hardware reset and "manual" for requesting
|
||||
a poor devil from the data center to go to your server and press the
|
||||
power button.
|
||||
|
||||
On a vServer, rebooting with mode="soft" is a no-op, any other value
|
||||
results in a hard reset.
|
||||
"""
|
||||
if self.server.is_vserver:
|
||||
if mode == 'soft':
|
||||
return
|
||||
|
||||
self.conn.scraper.login(force=True)
|
||||
baseurl = '/server/vserverCommand/id/{0}/command/reset'
|
||||
url = baseurl.format(self.server.number)
|
||||
response = self.conn.scraper.request(url, method='POST')
|
||||
assert "msgbox_success" in response.read().decode('utf-8')
|
||||
return response
|
||||
|
||||
modes = {
|
||||
'manual': 'man',
|
||||
'hard': 'hw',
|
||||
'soft': 'sw',
|
||||
}
|
||||
|
||||
modekey = modes.get(mode, modes['soft'])
|
||||
return self.conn.post('/reset/{0}'.format(self.server.ip),
|
||||
{'type': modekey})
|
||||
399
.venv/lib/python3.12/site-packages/hetzner/robot.py
Normal file
399
.venv/lib/python3.12/site-packages/hetzner/robot.py
Normal file
@@ -0,0 +1,399 @@
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
|
||||
from base64 import b64encode
|
||||
|
||||
try:
|
||||
from httplib import BadStatusLine, ResponseNotReady
|
||||
except ImportError:
|
||||
from http.client import BadStatusLine, ResponseNotReady
|
||||
|
||||
try:
|
||||
from urllib import urlencode
|
||||
except ImportError:
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from hetzner import WebRobotError, RobotError
|
||||
from hetzner.server import Server
|
||||
from hetzner.rdns import ReverseDNSManager
|
||||
from hetzner.failover import FailoverManager
|
||||
from hetzner.util.http import ValidatedHTTPSConnection
|
||||
|
||||
ROBOT_HOST = "robot-ws.your-server.de"
|
||||
ROBOT_WEBHOST = "robot.your-server.de"
|
||||
ROBOT_LOGINHOST = "accounts.hetzner.com"
|
||||
|
||||
RE_CSRF_TOKEN = re.compile(
|
||||
r'<input[^>]*?name="_csrf_token"[^>]*?value="([^">]+)"'
|
||||
)
|
||||
|
||||
__all__ = ['Robot', 'RobotConnection', 'RobotWebInterface', 'ServerManager']
|
||||
|
||||
|
||||
class RobotWebInterface(object):
|
||||
"""
|
||||
This is for scraping the web interface and can be used to implement
|
||||
features that are not yet available in the official API.
|
||||
"""
|
||||
def __init__(self, user=None, passwd=None):
|
||||
self.conn = None
|
||||
self.session_cookie = None
|
||||
self.user = user
|
||||
self.passwd = passwd
|
||||
self.logged_in = False
|
||||
self.logger = logging.getLogger("Robot scraper for {0}".format(user))
|
||||
|
||||
def _parse_cookies(self, response):
|
||||
"""
|
||||
Return a dictionary consisting of the cookies from the given response.
|
||||
"""
|
||||
result = {}
|
||||
cookies = response.getheader('set-cookie')
|
||||
if cookies is None:
|
||||
return result
|
||||
|
||||
# Not very accurate but sufficent enough for our use case.
|
||||
for cookieval in cookies.split(','):
|
||||
cookieattrs = cookieval.strip().split(';')
|
||||
if len(cookieattrs) <= 1:
|
||||
continue
|
||||
cookie = cookieattrs[0].strip().split('=', 1)
|
||||
if len(cookie) != 2:
|
||||
continue
|
||||
result[cookie[0]] = cookie[1]
|
||||
|
||||
return result
|
||||
|
||||
def update_session(self, response):
|
||||
"""
|
||||
Parses the session cookie from the given response instance and updates
|
||||
self.session_cookie accordingly if a session cookie was recognized.
|
||||
"""
|
||||
session = self._parse_cookies(response).get('robot')
|
||||
if session is not None:
|
||||
self.session_cookie = "robot=" + session
|
||||
|
||||
def connect(self, force=False):
|
||||
"""
|
||||
Establish a connection to the robot web interface if we're not yet
|
||||
connected. If 'force' is set to True, throw away the old connection and
|
||||
establish a new one, regardless of whether we are connected or not.
|
||||
"""
|
||||
if force and self.conn is not None:
|
||||
self.conn.close()
|
||||
self.conn = None
|
||||
if self.conn is None:
|
||||
self.conn = ValidatedHTTPSConnection(ROBOT_WEBHOST)
|
||||
|
||||
def login(self, user=None, passwd=None, force=False):
|
||||
"""
|
||||
Log into the robot web interface using self.user and self.passwd. If
|
||||
user/passwd is provided as arguments, those are used instead and
|
||||
self.user/self.passwd are updated accordingly.
|
||||
"""
|
||||
if self.logged_in and not force:
|
||||
return
|
||||
|
||||
self.connect(force=force)
|
||||
|
||||
# Update self.user and self.passwd in case we need to re-establish the
|
||||
# connection.
|
||||
if user is not None:
|
||||
self.user = user
|
||||
if passwd is not None:
|
||||
self.passwd = passwd
|
||||
|
||||
if self.user is None or self.passwd is None:
|
||||
raise WebRobotError("Login credentials for the web user interface "
|
||||
"are missing.")
|
||||
|
||||
if self.user.startswith("#ws+"):
|
||||
raise WebRobotError("The user {0} is a dedicated web service user "
|
||||
"and cannot be used for scraping the web user "
|
||||
"interface.".format(self.user))
|
||||
|
||||
# We need to first visit the Robot so that we later get an OAuth token
|
||||
# for the Robot from the authentication site.
|
||||
self.logger.debug("Visiting Robot web frontend for the first time.")
|
||||
auth_url = self.request('/', xhr=False).getheader('location')
|
||||
|
||||
if not auth_url.startswith('https://' + ROBOT_LOGINHOST + '/'):
|
||||
msg = "https://{0}/ does not redirect to https://{1}/ " \
|
||||
"but instead redirects to: {2}"
|
||||
raise WebRobotError(msg.format(ROBOT_WEBHOST, ROBOT_LOGINHOST,
|
||||
auth_url))
|
||||
|
||||
self.logger.debug("Following authentication redirect to %r.", auth_url)
|
||||
|
||||
# This is primarily for getting a first session cookie.
|
||||
login_conn = ValidatedHTTPSConnection(ROBOT_LOGINHOST)
|
||||
login_conn.request('GET', auth_url[len(ROBOT_LOGINHOST) + 8:], None)
|
||||
|
||||
response = login_conn.getresponse()
|
||||
if response.status != 302:
|
||||
raise WebRobotError("Invalid status code {0} while visiting auth"
|
||||
" URL".format(response.status))
|
||||
|
||||
cookies = self._parse_cookies(response)
|
||||
if "PHPSESSID" not in cookies:
|
||||
msg = "Auth site didn't respond with a session cookie."
|
||||
raise WebRobotError(msg)
|
||||
|
||||
self.logger.debug("Session ID for auth site is %r.",
|
||||
cookies['PHPSESSID'])
|
||||
|
||||
# Make sure that we always send the auth site's session ID in
|
||||
# subsequent requests.
|
||||
cookieval = '; '.join([k + '=' + v for k, v in cookies.items()])
|
||||
headers = {'Cookie': cookieval}
|
||||
|
||||
self.logger.debug("Visiting login page at https://%s/login.",
|
||||
ROBOT_LOGINHOST)
|
||||
|
||||
# Note that the auth site doesn't seem to support keep-alives, so we
|
||||
# need to reconnect here.
|
||||
login_conn = ValidatedHTTPSConnection(ROBOT_LOGINHOST)
|
||||
login_conn.request('GET', "/login", None, headers)
|
||||
|
||||
response = login_conn.getresponse()
|
||||
if response.status != 200:
|
||||
raise WebRobotError("Invalid status code {0} while visiting login"
|
||||
" page".format(response.status))
|
||||
|
||||
# Find the CSRF token
|
||||
haystack = response.read()
|
||||
token = RE_CSRF_TOKEN.search(str(haystack))
|
||||
if token is None:
|
||||
raise WebRobotError("Unable to find CSRF token for login form")
|
||||
|
||||
data = urlencode({'_username': self.user, '_password': self.passwd,
|
||||
'_csrf_token': token.group(1)})
|
||||
self.logger.debug("Logging in to auth site with user %s.", self.user)
|
||||
|
||||
# Again, we need to reconnect here.
|
||||
login_conn = ValidatedHTTPSConnection(ROBOT_LOGINHOST)
|
||||
post_headers = headers.copy()
|
||||
post_headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
login_conn.request('POST', '/login_check', data, post_headers)
|
||||
response = login_conn.getresponse()
|
||||
|
||||
# Here, if the authentication is successful another session is started
|
||||
# and we get a new session ID.
|
||||
cookies = self._parse_cookies(response)
|
||||
if "PHPSESSID" not in cookies:
|
||||
raise WebRobotError("Login to robot web interface failed.")
|
||||
self.logger.debug("New session ID for auth site after login is %r.",
|
||||
cookies['PHPSESSID'])
|
||||
cookieval = '; '.join([k + '=' + v for k, v in cookies.items()])
|
||||
headers['Cookie'] = cookieval
|
||||
|
||||
# This should be the actual OAuth authorization URL.
|
||||
location = response.getheader('Location')
|
||||
|
||||
if response.status != 302 or location is None:
|
||||
raise WebRobotError("Unable to get OAuth authorization URL.")
|
||||
|
||||
if not location.startswith('https://' + ROBOT_LOGINHOST + '/'):
|
||||
msg = "https://{0}/ does not redirect to https://{1}/ " \
|
||||
"but instead redirects to: {2}"
|
||||
raise WebRobotError(msg.format(ROBOT_LOGINHOST, ROBOT_LOGINHOST,
|
||||
location))
|
||||
|
||||
self.logger.debug("Got redirected, visiting %r.", location)
|
||||
|
||||
login_conn = ValidatedHTTPSConnection(ROBOT_LOGINHOST)
|
||||
login_conn.request('GET', location[len(ROBOT_LOGINHOST) + 8:], None,
|
||||
headers)
|
||||
response = login_conn.getresponse()
|
||||
|
||||
# We now should get an URL back to the Robot web interface.
|
||||
location = response.getheader('Location')
|
||||
if response.status != 302 or location is None:
|
||||
raise WebRobotError("Failed to get OAuth URL for Robot.")
|
||||
if not location.startswith('https://' + ROBOT_WEBHOST + '/'):
|
||||
msg = "https://{0}/ does not redirect to https://{1}/ " \
|
||||
"but instead redirects to: {2}"
|
||||
raise WebRobotError(msg.format(ROBOT_LOGINHOST, ROBOT_WEBHOST,
|
||||
auth_url))
|
||||
|
||||
self.logger.debug("Going back to Robot web interface via %r.",
|
||||
location)
|
||||
|
||||
# Reconnect to Robot with the OAuth token.
|
||||
self.connect(force=True)
|
||||
response = self.request(location[len(ROBOT_WEBHOST) + 8:], xhr=False)
|
||||
|
||||
if response.status != 302:
|
||||
raise WebRobotError("Status after providing OAuth token should be"
|
||||
" 302 and not {0}".format(response.status))
|
||||
|
||||
if response.getheader('location') != 'https://' + ROBOT_WEBHOST + '/':
|
||||
raise WebRobotError("Robot login with OAuth token has failed.")
|
||||
|
||||
self.logged_in = True
|
||||
|
||||
def request(self, path, data=None, xhr=True, method=None, log=True):
|
||||
"""
|
||||
Send a request to the web interface, using 'data' for urlencoded POST
|
||||
data. If 'data' is None (which it is by default), a GET request is sent
|
||||
instead. A httplib.HTTPResponse is returned on success.
|
||||
|
||||
By default this method uses headers for XMLHttpRequests, so if the
|
||||
request should be an ordinary HTTP request, set 'xhr' to False.
|
||||
|
||||
If 'log' is set to False, don't log anything containing data. This is
|
||||
useful to prevent logging sensible information such as passwords.
|
||||
"""
|
||||
self.connect()
|
||||
|
||||
headers = {'Connection': 'keep-alive'}
|
||||
if self.session_cookie is not None:
|
||||
headers['Cookie'] = self.session_cookie
|
||||
if xhr:
|
||||
headers['X-Requested-With'] = 'XMLHttpRequest'
|
||||
|
||||
if data is None:
|
||||
if method is None:
|
||||
method = 'GET'
|
||||
encoded = None
|
||||
else:
|
||||
if method is None:
|
||||
method = 'POST'
|
||||
encoded = urlencode(data)
|
||||
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
|
||||
if log:
|
||||
self.logger.debug("Sending %s request to Robot web frontend "
|
||||
"at %s with data %r.",
|
||||
("XHR " if xhr else "") + method, path, encoded)
|
||||
self.conn.request(method, path, encoded, headers)
|
||||
|
||||
try:
|
||||
response = self.conn.getresponse()
|
||||
except ResponseNotReady:
|
||||
self.logger.debug("Connection closed by Robot web frontend,"
|
||||
" retrying.")
|
||||
# Connection closed, so we need to reconnect.
|
||||
# FIXME: Try to avoid endless loops here!
|
||||
self.connect(force=True)
|
||||
return self.request(path, data=data, xhr=xhr, log=log)
|
||||
|
||||
if log:
|
||||
self.logger.debug("Got response from web frontend with status %d.",
|
||||
response.status)
|
||||
|
||||
self.update_session(response)
|
||||
return response
|
||||
|
||||
|
||||
class RobotConnection(object):
|
||||
def __init__(self, user, passwd):
|
||||
self.user = user
|
||||
self.passwd = passwd
|
||||
self.conn = ValidatedHTTPSConnection(ROBOT_HOST)
|
||||
self.logger = logging.getLogger("Robot of {0}".format(user))
|
||||
|
||||
# Provide this as a way to easily add unsupported API features.
|
||||
self.scraper = RobotWebInterface(user, passwd)
|
||||
|
||||
def _request(self, method, path, data, headers, retry=1):
|
||||
self.conn.request(method.upper(), path, data, headers)
|
||||
try:
|
||||
return self.conn.getresponse()
|
||||
except BadStatusLine:
|
||||
# XXX: Sometimes, the API server seems to have a problem with
|
||||
# keepalives.
|
||||
if retry <= 0:
|
||||
raise
|
||||
|
||||
self.conn.close()
|
||||
self.conn.connect()
|
||||
return self._request(method, path, data, headers, retry - 1)
|
||||
|
||||
def request(self, method, path, data=None, allow_empty=False):
|
||||
if data is not None:
|
||||
data = urlencode(data)
|
||||
|
||||
auth = 'Basic {0}'.format(b64encode(
|
||||
"{0}:{1}".format(self.user, self.passwd).encode('ascii')
|
||||
).decode('ascii'))
|
||||
|
||||
headers = {'Authorization': auth}
|
||||
|
||||
if data is not None:
|
||||
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
|
||||
self.logger.debug("Sending %s request to Robot at %s with data %r.",
|
||||
method, path, data)
|
||||
response = self._request(method, path, data, headers)
|
||||
raw_data = response.read().decode('utf-8')
|
||||
if len(raw_data) == 0 and not allow_empty:
|
||||
msg = "Empty response, status {0}."
|
||||
raise RobotError(msg.format(response.status), response.status)
|
||||
elif not allow_empty:
|
||||
try:
|
||||
data = json.loads(raw_data)
|
||||
except ValueError:
|
||||
msg = "Response is not JSON (status {0}): {1}"
|
||||
raise RobotError(msg.format(response.status, repr(raw_data)))
|
||||
else:
|
||||
data = None
|
||||
self.logger.debug(
|
||||
"Got response from Robot with status %d and data %r.",
|
||||
response.status, data
|
||||
)
|
||||
|
||||
if 200 <= response.status < 300:
|
||||
return data
|
||||
else:
|
||||
error = data.get('error', None)
|
||||
if error is None:
|
||||
raise RobotError("Unknown error: {0}".format(data),
|
||||
response.status)
|
||||
else:
|
||||
err = "{0} - {1}".format(error['status'], error['message'])
|
||||
missing = error.get('missing', [])
|
||||
invalid = error.get('invalid', [])
|
||||
fields = []
|
||||
if missing is not None:
|
||||
fields += missing
|
||||
if invalid is not None:
|
||||
fields += invalid
|
||||
if len(fields) > 0:
|
||||
err += ", fields: {0}".format(', '.join(fields))
|
||||
raise RobotError(err, response.status)
|
||||
|
||||
def get(self, path):
|
||||
return self.request('GET', path)
|
||||
|
||||
def post(self, path, data):
|
||||
return self.request('POST', path, data)
|
||||
|
||||
def put(self, path, data):
|
||||
return self.request('PUT', path, data)
|
||||
|
||||
def delete(self, path, data=None):
|
||||
return self.request('DELETE', path, data, allow_empty=True)
|
||||
|
||||
|
||||
class ServerManager(object):
|
||||
def __init__(self, conn):
|
||||
self.conn = conn
|
||||
|
||||
def get(self, ip):
|
||||
"""
|
||||
Get server by providing its main IP address.
|
||||
"""
|
||||
return Server(self.conn, self.conn.get('/server/{0}'.format(ip)))
|
||||
|
||||
def __iter__(self):
|
||||
return iter([Server(self.conn, s) for s in self.conn.get('/server')])
|
||||
|
||||
|
||||
class Robot(object):
|
||||
def __init__(self, user, passwd):
|
||||
self.conn = RobotConnection(user, passwd)
|
||||
self.servers = ServerManager(self.conn)
|
||||
self.rdns = ReverseDNSManager(self.conn)
|
||||
self.failover = FailoverManager(self.conn, self.servers)
|
||||
467
.venv/lib/python3.12/site-packages/hetzner/server.py
Normal file
467
.venv/lib/python3.12/site-packages/hetzner/server.py
Normal file
@@ -0,0 +1,467 @@
|
||||
import os
|
||||
import re
|
||||
import random
|
||||
import string
|
||||
import subprocess
|
||||
import warnings
|
||||
import logging
|
||||
|
||||
from tempfile import mkdtemp
|
||||
from datetime import datetime
|
||||
from functools import reduce
|
||||
|
||||
try:
|
||||
from urllib import urlencode
|
||||
except ImportError:
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from hetzner import RobotError, WebRobotError
|
||||
from hetzner.rdns import ReverseDNS, ReverseDNSManager
|
||||
from hetzner.reset import Reset
|
||||
from hetzner.util import addr, scraping
|
||||
|
||||
__all__ = ['AdminAccount', 'IpAddress', 'RescueSystem', 'Server', 'Subnet',
|
||||
'IpManager', 'SubnetManager']
|
||||
|
||||
|
||||
class SSHAskPassHelper(object):
|
||||
"""
|
||||
This creates a temporary SSH askpass helper script, which just passes the
|
||||
provided password.
|
||||
"""
|
||||
def __init__(self, passwd):
|
||||
self.passwd = passwd
|
||||
self.tempdir = None
|
||||
self.script = None
|
||||
|
||||
def __enter__(self):
|
||||
self.tempdir = mkdtemp()
|
||||
script = os.path.join(self.tempdir, "askpass")
|
||||
fd = os.open(script, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o700)
|
||||
self.script = script
|
||||
esc_passwd = self.passwd.replace("'", r"'\''")
|
||||
askpass = "#!/bin/sh\necho -n '{0}'".format(esc_passwd).encode('ascii')
|
||||
os.write(fd, askpass)
|
||||
os.close(fd)
|
||||
return script
|
||||
|
||||
def __exit__(self, type, value, traceback):
|
||||
if self.script is not None:
|
||||
os.unlink(self.script)
|
||||
if self.tempdir is not None:
|
||||
os.rmdir(self.tempdir)
|
||||
|
||||
|
||||
class RescueSystem(object):
|
||||
def __init__(self, server):
|
||||
self.server = server
|
||||
self.conn = server.conn
|
||||
|
||||
self._active = None
|
||||
self._password = None
|
||||
|
||||
def _fetch_status(self):
|
||||
reply = self.conn.get('/boot/{0}/rescue'.format(self.server.ip))
|
||||
data = reply['rescue']
|
||||
self._active = data['active']
|
||||
self._password = data['password']
|
||||
|
||||
@property
|
||||
def active(self):
|
||||
if self._active is not None:
|
||||
return self._active
|
||||
self._fetch_status()
|
||||
return self._active
|
||||
|
||||
@property
|
||||
def password(self):
|
||||
if self._password is not None:
|
||||
return self._password
|
||||
self._fetch_status()
|
||||
return self._password
|
||||
|
||||
def _rescue_action(self, method, opts=None):
|
||||
reply = self.conn.request(
|
||||
method,
|
||||
'/boot/{0}/rescue'.format(self.server.ip),
|
||||
opts
|
||||
)
|
||||
|
||||
data = reply['rescue']
|
||||
self._active = data['active']
|
||||
self._password = data['password']
|
||||
|
||||
def activate(self, bits=64, os='linux'):
|
||||
"""
|
||||
Activate the rescue system if necessary.
|
||||
"""
|
||||
if not self.active:
|
||||
opts = {'os': os, 'arch': bits}
|
||||
return self._rescue_action('post', opts)
|
||||
|
||||
def deactivate(self):
|
||||
"""
|
||||
Deactivate the rescue system if necessary.
|
||||
"""
|
||||
if self.active:
|
||||
return self._rescue_action('delete')
|
||||
|
||||
def observed_activate(self, *args, **kwargs):
|
||||
"""
|
||||
Activate the rescue system and reboot into it.
|
||||
Look at Server.observed_reboot() for options.
|
||||
"""
|
||||
self.activate()
|
||||
self.server.observed_reboot(*args, **kwargs)
|
||||
|
||||
def observed_deactivate(self, *args, **kwargs):
|
||||
"""
|
||||
Deactivate the rescue system and reboot into normal system.
|
||||
Look at Server.observed_reboot() for options.
|
||||
"""
|
||||
self.deactivate()
|
||||
self.server.observed_reboot(*args, **kwargs)
|
||||
|
||||
def shell(self, *args, **kwargs):
|
||||
"""
|
||||
Reboot into rescue system, spawn a shell and after the shell is
|
||||
closed, reboot back into the normal system.
|
||||
|
||||
Look at Server.observed_reboot() for further options.
|
||||
"""
|
||||
msg = ("The RescueSystem.shell() method will be removed from the API"
|
||||
" in version 1.0.0, please do not use it! See"
|
||||
" https://github.com/aszlig/hetzner/issues/13"
|
||||
" for details.")
|
||||
warnings.warn(msg, FutureWarning)
|
||||
self.observed_activate(*args, **kwargs)
|
||||
|
||||
with SSHAskPassHelper(self.password) as askpass:
|
||||
ssh_options = [
|
||||
'CheckHostIP=no',
|
||||
'GlobalKnownHostsFile=/dev/null',
|
||||
'UserKnownHostsFile=/dev/null',
|
||||
'StrictHostKeyChecking=no',
|
||||
'LogLevel=quiet',
|
||||
]
|
||||
ssh_args = reduce(lambda acc, opt: acc + ['-o', opt],
|
||||
ssh_options, [])
|
||||
cmd = ['ssh'] + ssh_args + ["root@{0}".format(self.server.ip)]
|
||||
env = dict(os.environ)
|
||||
env['DISPLAY'] = ":666"
|
||||
env['SSH_ASKPASS'] = askpass
|
||||
subprocess.check_call(cmd, env=env, preexec_fn=os.setsid)
|
||||
|
||||
self.observed_deactivate(*args, **kwargs)
|
||||
|
||||
|
||||
class AdminAccount(object):
|
||||
def __init__(self, server):
|
||||
# XXX: This is preliminary, because we don't have such functionality in
|
||||
# the official API yet.
|
||||
self._scraper = server.conn.scraper
|
||||
self._serverid = server.number
|
||||
self.exists = False
|
||||
self.login = None
|
||||
self.passwd = None
|
||||
self.update_info()
|
||||
|
||||
def update_info(self):
|
||||
"""
|
||||
Get information about currently active admin login.
|
||||
"""
|
||||
self._scraper.login()
|
||||
login_re = re.compile(r'"label_req">Login.*?"element">([^<]+)',
|
||||
re.DOTALL)
|
||||
|
||||
path = '/server/admin/id/{0}'.format(self._serverid)
|
||||
response = self._scraper.request(path)
|
||||
assert response.status == 200
|
||||
match = login_re.search(response.read().decode('utf-8'))
|
||||
if match is None:
|
||||
self.exists = False
|
||||
else:
|
||||
self.exists = True
|
||||
self.login = match.group(1)
|
||||
|
||||
def _genpasswd(self):
|
||||
random.seed(os.urandom(512))
|
||||
chars = string.ascii_letters + string.digits + "/()-=+_,;.^~#*@"
|
||||
length = random.randint(20, 40)
|
||||
return ''.join(random.choice(chars) for i in range(length))
|
||||
|
||||
def create(self, passwd=None):
|
||||
"""
|
||||
Create a new admin account if missing. If passwd is supplied, use it
|
||||
instead of generating a random one.
|
||||
"""
|
||||
if passwd is None:
|
||||
passwd = self._genpasswd()
|
||||
|
||||
form_path = '/server/admin/id/{0}'.format(self._serverid)
|
||||
form_response = self._scraper.request(form_path, method='POST')
|
||||
|
||||
parser = scraping.CSRFParser('password[_csrf_token]')
|
||||
parser.feed(form_response.read().decode('utf-8'))
|
||||
assert parser.csrf_token is not None
|
||||
|
||||
data = {
|
||||
'password[new_password]': passwd,
|
||||
'password[new_password_repeat]': passwd,
|
||||
'password[_csrf_token]': parser.csrf_token,
|
||||
}
|
||||
|
||||
if not self.exists:
|
||||
failmsg = "Unable to create admin account"
|
||||
path = '/server/adminCreate/id/{0}'.format(self._serverid)
|
||||
else:
|
||||
failmsg = "Unable to update admin account password"
|
||||
path = '/server/adminUpdate'
|
||||
data['id'] = self._serverid
|
||||
|
||||
response = self._scraper.request(path, data)
|
||||
data = response.read().decode('utf-8')
|
||||
if "msgbox_success" not in data:
|
||||
ul_re = re.compile(r'<ul\s+class="error_list">(.*?)</ul>',
|
||||
re.DOTALL)
|
||||
li_re = re.compile(r'<li>\s*([^<]*?)\s*</li>')
|
||||
ul_match = ul_re.search(data)
|
||||
if ul_match is not None:
|
||||
errors = [error.group(1)
|
||||
for error in li_re.finditer(ul_match.group(0))]
|
||||
msg = failmsg + ': ' + ', '.join(errors)
|
||||
raise WebRobotError(msg)
|
||||
raise WebRobotError(failmsg)
|
||||
self.update_info()
|
||||
self.passwd = passwd
|
||||
return self.login, self.passwd
|
||||
|
||||
def delete(self):
|
||||
"""
|
||||
Remove the admin account.
|
||||
"""
|
||||
if not self.exists:
|
||||
return
|
||||
path = '/server/adminDelete/id/{0}'.format(self._serverid)
|
||||
assert "msgbox_success" in \
|
||||
self._scraper.request(path).read().decode('utf-8')
|
||||
self.update_info()
|
||||
|
||||
def __repr__(self):
|
||||
if self.exists:
|
||||
return "<AdminAccount login: {0}>".format(self.login)
|
||||
else:
|
||||
return "<AdminAccount missing>"
|
||||
|
||||
|
||||
class IpAddress(object):
|
||||
def __init__(self, conn, result, subnet_ip=None):
|
||||
self.conn = conn
|
||||
self.subnet_ip = subnet_ip
|
||||
self.update_info(result)
|
||||
self._rdns = None
|
||||
|
||||
@property
|
||||
def rdns(self):
|
||||
"""
|
||||
Get or set reverse DNS PTRs.
|
||||
"""
|
||||
if self._rdns is None:
|
||||
self._rdns = ReverseDNS(self.conn, self.ip)
|
||||
return self._rdns
|
||||
|
||||
def update_info(self, result=None):
|
||||
"""
|
||||
Update the information of the current IP address and all related
|
||||
information such as traffic warnings. If result is omitted, a new
|
||||
request is sent to the robot to gather the information.
|
||||
"""
|
||||
if self.subnet_ip is not None:
|
||||
if result is None:
|
||||
result = self.conn.get('/subnet/{0}'.format(self._subnet_addr))
|
||||
data = result['subnet']
|
||||
self._subnet_addr = data['ip']
|
||||
data['ip'] = self.subnet_ip
|
||||
# Does not exist in subnets
|
||||
data['separate_mac'] = None
|
||||
else:
|
||||
if result is None:
|
||||
result = self.conn.get('/ip/{0}'.format(self.ip))
|
||||
data = result['ip']
|
||||
|
||||
self.ip = data['ip']
|
||||
self.server_ip = data['server_ip']
|
||||
self.locked = data['locked']
|
||||
self.separate_mac = data['separate_mac']
|
||||
self.traffic_warnings = data['traffic_warnings']
|
||||
self.traffic_hourly = data['traffic_hourly']
|
||||
self.traffic_daily = data['traffic_daily']
|
||||
self.traffic_monthly = data['traffic_monthly']
|
||||
|
||||
def __repr__(self):
|
||||
return "<IpAddress {0}>".format(self.ip)
|
||||
|
||||
|
||||
class IpManager(object):
|
||||
def __init__(self, conn, main_ip):
|
||||
self.conn = conn
|
||||
self.main_ip = main_ip
|
||||
|
||||
def get(self, ip):
|
||||
"""
|
||||
Get a specific IP address of a server.
|
||||
"""
|
||||
return IpAddress(self.conn, self.conn.get('/ip/{0}'.format(ip)))
|
||||
|
||||
def __iter__(self):
|
||||
data = urlencode({'server_ip': self.main_ip})
|
||||
result = self.conn.get('/ip?{0}'.format(data))
|
||||
return iter([IpAddress(self.conn, ip) for ip in result])
|
||||
|
||||
|
||||
class Subnet(object):
|
||||
def __init__(self, conn, result):
|
||||
self.conn = conn
|
||||
self.update_info(result)
|
||||
|
||||
def update_info(self, result=None):
|
||||
"""
|
||||
Update the information of the subnet. If result is omitted, a new
|
||||
request is sent to the robot to gather the information.
|
||||
"""
|
||||
if result is None:
|
||||
result = self.conn.get('/subnet/{0}'.format(self.net_ip))
|
||||
|
||||
data = result['subnet']
|
||||
|
||||
self.net_ip = data['ip']
|
||||
self.mask = data['mask']
|
||||
self.gateway = data['gateway']
|
||||
self.server_ip = data['server_ip']
|
||||
self.failover = data['failover']
|
||||
self.locked = data['locked']
|
||||
self.traffic_warnings = data['traffic_warnings']
|
||||
self.traffic_hourly = data['traffic_hourly']
|
||||
self.traffic_daily = data['traffic_daily']
|
||||
self.traffic_monthly = data['traffic_monthly']
|
||||
|
||||
self.is_ipv6, self.numeric_net_ip = addr.parse_ipaddr(self.net_ip)
|
||||
self.numeric_gateway = addr.parse_ipaddr(self.gateway, self.is_ipv6)
|
||||
getrange = addr.get_ipv6_range if self.is_ipv6 else addr.get_ipv4_range
|
||||
self.numeric_range = getrange(self.numeric_net_ip, self.mask)
|
||||
|
||||
def get_ip_range(self):
|
||||
"""
|
||||
Return the smallest and biggest possible IP address of the current
|
||||
subnet.
|
||||
"""
|
||||
convert = addr.ipv6_bin2addr if self.is_ipv6 else addr.ipv4_bin2addr
|
||||
return convert(self.numeric_range[0]), convert(self.numeric_range[1])
|
||||
|
||||
def __contains__(self, addr):
|
||||
"""
|
||||
Check whether a specific IP address is within the current subnet.
|
||||
"""
|
||||
numeric_addr = addr.parse_ipaddr(addr, self.is_ipv6)
|
||||
return self.numeric_range[0] <= numeric_addr <= self.numeric_range[1]
|
||||
|
||||
def get_ip(self, addr):
|
||||
"""
|
||||
Return an IpAddress object for the specified IPv4 or IPv6 address or
|
||||
None if the IP address doesn't exist in the current subnet.
|
||||
"""
|
||||
if addr in self:
|
||||
result = self.conn.get('/subnet/{0}'.format(self.net_ip))
|
||||
return IpAddress(self.conn, result, addr)
|
||||
else:
|
||||
return None
|
||||
|
||||
def __repr__(self):
|
||||
return "<Subnet {0}/{1} (Gateway: {2})>".format(self.net_ip, self.mask,
|
||||
self.gateway)
|
||||
|
||||
|
||||
class SubnetManager(object):
|
||||
def __init__(self, conn, main_ip):
|
||||
self.conn = conn
|
||||
self.main_ip = main_ip
|
||||
|
||||
def get(self, net_ip):
|
||||
"""
|
||||
Get a specific subnet of a server.
|
||||
"""
|
||||
return Subnet(self.conn, self.conn.get('/subnet/{0}'.format(net_ip)))
|
||||
|
||||
def __iter__(self):
|
||||
data = urlencode({'server_ip': self.main_ip})
|
||||
try:
|
||||
result = self.conn.get('/subnet?{0}'.format(data))
|
||||
except RobotError as err:
|
||||
# If there are no subnets a 404 is returned rather than just an
|
||||
# empty list.
|
||||
if err.status == 404:
|
||||
result = []
|
||||
return iter([Subnet(self.conn, net) for net in result])
|
||||
|
||||
|
||||
class Server(object):
|
||||
def __init__(self, conn, result):
|
||||
self.conn = conn
|
||||
self.update_info(result)
|
||||
self.rescue = RescueSystem(self)
|
||||
self.reset = Reset(self)
|
||||
self.ips = IpManager(self.conn, self.ip)
|
||||
self.subnets = SubnetManager(self.conn, self.ip)
|
||||
self.rdns = ReverseDNSManager(self.conn, self.ip)
|
||||
self._admin_account = None
|
||||
self.logger = logging.getLogger("Server #{0}".format(self.number))
|
||||
|
||||
@property
|
||||
def admin(self):
|
||||
"""
|
||||
Update, create and delete admin accounts.
|
||||
"""
|
||||
if self._admin_account is None:
|
||||
self._admin_account = AdminAccount(self)
|
||||
return self._admin_account
|
||||
|
||||
def update_info(self, result=None):
|
||||
"""
|
||||
Updates the information of the current Server instance either by
|
||||
sending a new GET request or by parsing the response given by result.
|
||||
"""
|
||||
if result is None:
|
||||
result = self.conn.get('/server/{0}'.format(self.ip))
|
||||
|
||||
data = result['server']
|
||||
|
||||
self.ip = data['server_ip']
|
||||
self.number = data['server_number']
|
||||
self.name = data['server_name']
|
||||
self.product = data['product']
|
||||
self.datacenter = data['dc']
|
||||
self.traffic = data['traffic']
|
||||
self.status = data['status']
|
||||
self.cancelled = data['cancelled']
|
||||
self.paid_until = datetime.strptime(data['paid_until'], '%Y-%m-%d')
|
||||
self.is_vserver = self.product.startswith('VQ')
|
||||
|
||||
def observed_reboot(self, *args, **kwargs):
|
||||
msg = ("Server.observed_reboot() is deprecated. Please use"
|
||||
" Server.reset.observed_reboot() instead.")
|
||||
warnings.warn(msg, DeprecationWarning)
|
||||
return self.reset.observed_reboot(*args, **kwargs)
|
||||
|
||||
def reboot(self, *args, **kwargs):
|
||||
msg = ("Server.reboot() is deprecated. Please use"
|
||||
" Server.reset.reboot() instead.")
|
||||
warnings.warn(msg, DeprecationWarning)
|
||||
return self.reset.reboot(*args, **kwargs)
|
||||
|
||||
def set_name(self, name):
|
||||
result = self.conn.post('/server/{0}'.format(self.ip),
|
||||
{'server_name': name})
|
||||
self.update_info(result)
|
||||
|
||||
def __repr__(self):
|
||||
return "<{0} (#{1} {2})>".format(self.ip, self.number, self.product)
|
||||
@@ -0,0 +1 @@
|
||||
from hetzner.tests.test_util_addr import * # NOQA
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,85 @@
|
||||
import unittest
|
||||
import struct
|
||||
import socket
|
||||
|
||||
from hetzner.util.addr import (parse_ipv4, parse_ipv6, parse_ipaddr,
|
||||
get_ipv4_range, get_ipv6_range,
|
||||
ipv4_bin2addr, ipv6_bin2addr)
|
||||
|
||||
|
||||
class UtilAddrTestCase(unittest.TestCase):
|
||||
def test_parse_ipv4(self):
|
||||
self.assertEqual(parse_ipv4('174.26.72.88'), 2920958040)
|
||||
self.assertEqual(parse_ipv4('0.0.0.0'), 0)
|
||||
self.assertEqual(parse_ipv4('255.255.255.255'), 4294967295)
|
||||
self.assertRaises(socket.error, parse_ipv4, '999.999.999.999')
|
||||
self.assertRaises(socket.error, parse_ipv4, '::ffff:192.168.0.1')
|
||||
|
||||
def test_parse_ipv6(self):
|
||||
self.assertEqual(parse_ipv6('::ffff:192.168.0.1'), 281473913978881)
|
||||
self.assertEqual(parse_ipv6('fe80::fbd6:7860'),
|
||||
338288524927261089654018896845572831328)
|
||||
self.assertEqual(parse_ipv6('ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff'),
|
||||
340282366920938463463374607431768211455)
|
||||
self.assertEqual(parse_ipv6('::'), 0)
|
||||
self.assertRaises(socket.error, parse_ipv6, '174.26.72.88')
|
||||
|
||||
def test_parse_ipaddr(self):
|
||||
self.assertEqual(parse_ipaddr('1.2.3.4'), (False, 16909060))
|
||||
self.assertEqual(parse_ipaddr('255.255.0.0', False), 4294901760)
|
||||
self.assertEqual(parse_ipaddr('dead::beef'),
|
||||
(True, 295986882420777848964380943247191621359))
|
||||
self.assertEqual(parse_ipaddr('ffff::ffff', True),
|
||||
340277174624079928635746076935439056895)
|
||||
self.assertRaises(socket.error, parse_ipaddr, '1.2.3.4', True)
|
||||
self.assertRaises(socket.error, parse_ipaddr, 'dead::beef', False)
|
||||
self.assertRaises(socket.error, parse_ipaddr, 'invalid')
|
||||
|
||||
def test_get_ipv4_range(self):
|
||||
self.assertEqual(get_ipv4_range(0xac100000, 12),
|
||||
(2886729728, 2887778303))
|
||||
self.assertEqual(get_ipv4_range(0xa1b2c3d4, 16),
|
||||
(2712797184, 2712862719))
|
||||
self.assertEqual(get_ipv4_range(0xa1b2c3d4, 32),
|
||||
(2712847316, 2712847316))
|
||||
self.assertEqual(get_ipv4_range(0xa1b2c3d4, 0),
|
||||
(0, 4294967295))
|
||||
self.assertRaises(ValueError, get_ipv4_range, 0x01, 64)
|
||||
|
||||
def test_get_ipv6_range(self):
|
||||
self.assertEqual(
|
||||
get_ipv6_range(0x00010203ff05060708091a1b1c1d1e1f, 36),
|
||||
(5233173638632030885207665411096576,
|
||||
5233178590392188026728765007593471)
|
||||
)
|
||||
self.assertEqual(
|
||||
get_ipv6_range(0x000102030405060708091a1b1c1d1e1f, 64),
|
||||
(5233100606242805471950326074441728,
|
||||
5233100606242823918694399783993343)
|
||||
)
|
||||
self.assertEqual(
|
||||
get_ipv6_range(0x000102030405060708091a1b1c1d1e1f, 128),
|
||||
(5233100606242806050973056906370591,
|
||||
5233100606242806050973056906370591)
|
||||
)
|
||||
self.assertEqual(
|
||||
get_ipv6_range(0x000102030405060708091a1b1c1d1e1f, 0),
|
||||
(0, 340282366920938463463374607431768211455)
|
||||
)
|
||||
self.assertRaises(ValueError, get_ipv6_range, 0x01, 256)
|
||||
|
||||
def test_ipv4_bin2addr(self):
|
||||
self.assertEqual(ipv4_bin2addr(0x01020304), '1.2.3.4')
|
||||
self.assertEqual(ipv4_bin2addr(0x0000ffff), '0.0.255.255')
|
||||
self.assertEqual(ipv4_bin2addr(0xffff0000), '255.255.0.0')
|
||||
self.assertRaises(struct.error, ipv4_bin2addr, 0xa1ffff0000)
|
||||
|
||||
def test_ipv6_bin2addr(self):
|
||||
self.assertEqual(ipv6_bin2addr(0x01020304050607080910111213141516),
|
||||
'102:304:506:708:910:1112:1314:1516')
|
||||
self.assertEqual(ipv6_bin2addr(0xffff000000000dead00000beef000000),
|
||||
'ffff::dea:d000:be:ef00:0')
|
||||
self.assertEqual(ipv6_bin2addr(0x123400000000000000000000000000ff),
|
||||
'1234::ff')
|
||||
self.assertRaises(struct.error, ipv6_bin2addr,
|
||||
0xa1ffff0000000000000000000000000000)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
84
.venv/lib/python3.12/site-packages/hetzner/util/addr.py
Normal file
84
.venv/lib/python3.12/site-packages/hetzner/util/addr.py
Normal file
@@ -0,0 +1,84 @@
|
||||
import socket
|
||||
import struct
|
||||
|
||||
|
||||
def parse_ipv4(addr):
|
||||
"""
|
||||
Return a numeric representation of the given IPv4 address.
|
||||
"""
|
||||
binary_ip = socket.inet_pton(socket.AF_INET, addr)
|
||||
return struct.unpack('!L', binary_ip)[0]
|
||||
|
||||
|
||||
def parse_ipv6(addr):
|
||||
"""
|
||||
Return a numeric representation of the given IPv6 address.
|
||||
"""
|
||||
binary_ip = socket.inet_pton(socket.AF_INET6, addr)
|
||||
high, low = struct.unpack('!QQ', binary_ip)
|
||||
return high << 64 | low
|
||||
|
||||
|
||||
def parse_ipaddr(addr, is_ipv6=None):
|
||||
"""
|
||||
Parse IP address and return a tuple consisting of a boolean indicating
|
||||
whether the given address is an IPv6 address and the numeric representation
|
||||
of the address.
|
||||
|
||||
If is_ipv6 is either True or False, the specific address type is enforced
|
||||
and only the parsed address is returned instead of a tuple.
|
||||
"""
|
||||
if is_ipv6 is None:
|
||||
try:
|
||||
return False, parse_ipv4(addr)
|
||||
except socket.error:
|
||||
return True, parse_ipv6(addr)
|
||||
elif is_ipv6:
|
||||
return parse_ipv6(addr)
|
||||
else:
|
||||
return parse_ipv4(addr)
|
||||
|
||||
|
||||
def get_ipv4_range(numeric_netaddr, prefix_len):
|
||||
"""
|
||||
Return the smallest and biggest possible IPv4 address of the specified
|
||||
network address (in numeric representation) and prefix length.
|
||||
"""
|
||||
mask_inverted = 32 - prefix_len
|
||||
mask_bin = 0xffffffff >> mask_inverted << mask_inverted
|
||||
range_start = numeric_netaddr & mask_bin
|
||||
range_end = range_start | (1 << mask_inverted) - 1
|
||||
return range_start, range_end
|
||||
|
||||
|
||||
def get_ipv6_range(numeric_netaddr, prefix_len):
|
||||
"""
|
||||
Return the smallest and biggest possible IPv6 address of the specified
|
||||
network address (in numeric representation) and prefix length.
|
||||
"""
|
||||
mask_bin_full = 0xffffffffffffffffffffffffffffffff
|
||||
mask_inverted = 128 - prefix_len
|
||||
mask_bin = mask_bin_full >> mask_inverted << mask_inverted
|
||||
range_start = numeric_netaddr & mask_bin
|
||||
range_end = range_start | (1 << mask_inverted) - 1
|
||||
return range_start, range_end
|
||||
|
||||
|
||||
def ipv4_bin2addr(numeric_addr):
|
||||
"""
|
||||
Convert a numeric representation of the given IPv4 address into quad-dotted
|
||||
notation.
|
||||
"""
|
||||
packed = struct.pack('!L', numeric_addr)
|
||||
return socket.inet_ntop(socket.AF_INET, packed)
|
||||
|
||||
|
||||
def ipv6_bin2addr(numeric_addr):
|
||||
"""
|
||||
Convert a numeric representation of the given IPv6 address into a shortened
|
||||
hexadecimal notiation separated by colons.
|
||||
"""
|
||||
high = numeric_addr >> 64
|
||||
low = numeric_addr & 0xffffffffffffffff
|
||||
packed = struct.pack('!QQ', high, low)
|
||||
return socket.inet_ntop(socket.AF_INET6, packed)
|
||||
70
.venv/lib/python3.12/site-packages/hetzner/util/http.py
Normal file
70
.venv/lib/python3.12/site-packages/hetzner/util/http.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import os
|
||||
import ssl
|
||||
import socket
|
||||
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
try:
|
||||
from httplib import HTTPSConnection
|
||||
except ImportError:
|
||||
from http.client import HTTPSConnection
|
||||
|
||||
|
||||
class ValidatedHTTPSConnection(HTTPSConnection):
|
||||
CA_ROOT_CERT_FALLBACK = '''
|
||||
DigiCert Global Root G2
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh
|
||||
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
|
||||
d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH
|
||||
MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT
|
||||
MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
|
||||
b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG
|
||||
9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI
|
||||
2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx
|
||||
1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ
|
||||
q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz
|
||||
tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ
|
||||
vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP
|
||||
BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV
|
||||
5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY
|
||||
1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4
|
||||
NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG
|
||||
Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91
|
||||
8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe
|
||||
pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl
|
||||
MrY=
|
||||
-----END CERTIFICATE-----
|
||||
'''
|
||||
|
||||
def get_ca_cert_bundle(self):
|
||||
via_env = os.getenv('SSL_CERT_FILE')
|
||||
if via_env is not None and os.path.exists(via_env):
|
||||
return via_env
|
||||
probe_paths = [
|
||||
"/etc/ssl/certs/ca-certificates.crt",
|
||||
"/etc/ssl/certs/ca-bundle.crt",
|
||||
"/etc/pki/tls/certs/ca-bundle.crt",
|
||||
]
|
||||
for path in probe_paths:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
def connect(self):
|
||||
sock = socket.create_connection((self.host, self.port),
|
||||
self.timeout,
|
||||
self.source_address)
|
||||
bundle = cafile = self.get_ca_cert_bundle()
|
||||
if bundle is None:
|
||||
ca_certs = NamedTemporaryFile()
|
||||
ca_certs.write('\n'.join(
|
||||
map(str.strip, self.CA_ROOT_CERT_FALLBACK.splitlines())
|
||||
).encode('ascii'))
|
||||
ca_certs.flush()
|
||||
cafile = ca_certs.name
|
||||
self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file,
|
||||
cert_reqs=ssl.CERT_REQUIRED,
|
||||
ca_certs=cafile)
|
||||
if bundle is None:
|
||||
ca_certs.close()
|
||||
19
.venv/lib/python3.12/site-packages/hetzner/util/scraping.py
Normal file
19
.venv/lib/python3.12/site-packages/hetzner/util/scraping.py
Normal file
@@ -0,0 +1,19 @@
|
||||
try:
|
||||
from HTMLParser import HTMLParser
|
||||
except ImportError:
|
||||
from html.parser import HTMLParser
|
||||
|
||||
|
||||
class CSRFParser(HTMLParser):
|
||||
def __init__(self, field_name):
|
||||
HTMLParser.__init__(self)
|
||||
self.field_name = field_name
|
||||
self.csrf_token = None
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag != 'input':
|
||||
return
|
||||
attrdict = dict(attrs)
|
||||
if attrdict.get('name', '') == self.field_name:
|
||||
self.csrf_token = attrdict.get('value', None)
|
||||
handle_startendtag = handle_starttag
|
||||
Reference in New Issue
Block a user