From 581725dc98dd2a7c196b19a1c86298b4c129e7fc Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Sat, 16 Feb 2019 22:07:47 +0100 Subject: [PATCH 01/17] Add ALLOWED_DOMAINS option, fixes #143 --- README.md | 2 ++ configurator.py | 21 ++++++++++++++++++++- settings.conf | 1 + 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c2f020f..7d2d194 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ Set the password that should be used for authentication. Only if `USERNAME` __an The credentials in the form of `"username:password"` are now deprecated and should be removed from you configuration. Replace it by specifying `USERNAME` and `PASSWORD`. It will still work though to ensure backwards compatibility. #### ALLOWED_NETWORKS (list) Limit access to the configurator by adding allowed IP addresses / networks to the list, e.g `ALLOWED_NETWORKS = ["192.168.0.0/24", "172.16.47.23"]`. If you are using the [hass.io addon](https://www.home-assistant.io/addons/configurator/) of the configurator, add the docker-network `172.30.0.0/16` to this list. +#### ALLOWED_DOMAINS +ALlow access to the configurator to client IP addesses which match the result of DNS lookups for the specified domains. #### BANNED_IPS (list) List of statically banned IP addresses, e.g. `BANNED_IPS = ["1.1.1.1", "2.2.2.2"]` #### BANLIMIT (integer) diff --git a/configurator.py b/configurator.py index f5520b4..6034421 100755 --- a/configurator.py +++ b/configurator.py @@ -57,6 +57,9 @@ # Limit access to the configurator by adding allowed IP addresses / networks to # the list, e.g ALLOWED_NETWORKS = ["192.168.0.0/24", "172.16.47.23"] ALLOWED_NETWORKS = [] +# Allow access to the configurator to client IP addesses which match the result +# of DNS lookups for the specified domains. +ALLOWED_DOMAINS = [] # List of statically banned IP addresses, e.g. ["1.1.1.1", "2.2.2.2"] BANNED_IPS = [] # Ban IPs after n failed login attempts. Restart service to reset banning. @@ -3491,7 +3494,7 @@ def load_settings(settingsfile): HASS_API_PASSWORD, CREDENTIALS, ALLOWED_NETWORKS, BANNED_IPS, BANLIMIT, \ DEV, IGNORE_PATTERN, DIRSFIRST, SESAME, VERIFY_HOSTNAME, ENFORCE_BASEPATH, \ ENV_PREFIX, NOTIFY_SERVICE, USERNAME, PASSWORD, SESAME_TOTP_SECRET, TOTP, \ - GIT, REPO, PORT, IGNORE_SSL, HASS_WS_API + GIT, REPO, PORT, IGNORE_SSL, HASS_WS_API, ALLOWED_DOMAINS settings = {} if settingsfile: try: @@ -3551,6 +3554,10 @@ def load_settings(settingsfile): except Exception: LOG.warning("Invalid network in ALLOWED_NETWORKS: %s", net) ALLOWED_NETWORKS.remove(net) + ALLOWED_DOMAINS = settings.get("ALLOWED_DOMAINS", ALLOWED_DOMAINS) + if ALLOWED_DOMAINS and not all(ALLOWED_DOMAINS): + LOG.warning("Invalid value for ALLOWED_DOMAINS. Using empty list.") + ALLOWED_DOMAINS = [] BANNED_IPS = settings.get("BANNED_IPS", BANNED_IPS) if BANNED_IPS and not all(BANNED_IPS): LOG.warning("Invalid value for BANNED_IPS. Using empty list.") @@ -3727,6 +3734,18 @@ def check_access(clientip): if ipobject in ipaddress.ip_network(net): return True LOG.warning("Client IP not within allowed networks.") + if ALLOWED_DOMAINS: + for domain in ALLOWED_DOMAINS: + try: + domain_data = socket.getaddrinfo(domain, None) + except Exception as err: + LOG.warning("Unable to lookup domain data: %s", err) + continue + for res in domain_data: + if res[0] in [socket.AF_INET, socket.AF_INET6]: + if res[4][0] == clientip: + return True + LOG.warning("Client IP not within allowed domains.") BANNED_IPS.append(clientip) return False diff --git a/settings.conf b/settings.conf index 65134cb..f6d903d 100644 --- a/settings.conf +++ b/settings.conf @@ -13,6 +13,7 @@ "USERNAME": null, "PASSWORD": null, "ALLOWED_NETWORKS": [], + "ALLOWED_DOMAINS": [], "BANNED_IPS": [], "BANLIMIT": 0, "IGNORE_PATTERN": [], From d14d4a492f32aeee24ec6ecf04966b4d92ef7bcf Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Sat, 23 Feb 2019 20:25:49 +0100 Subject: [PATCH 02/17] Bump version --- configurator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configurator.py b/configurator.py index 6034421..924fbfd 100755 --- a/configurator.py +++ b/configurator.py @@ -108,7 +108,7 @@ logging.Formatter('%(levelname)s:%(asctime)s:%(name)s:%(message)s')) LOG.addHandler(SO) RELEASEURL = "https://api.github.com/repos/danielperna84/hass-configurator/releases/latest" -VERSION = "0.3.4" +VERSION = "0.3.5" BASEDIR = "." DEV = False LISTENPORT = None From 8de375e3fffa0213f76f2578a722c7aa176beacf Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Sat, 23 Feb 2019 20:25:56 +0100 Subject: [PATCH 03/17] Update changelog --- changelog.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/changelog.txt b/changelog.txt index ad4c9b0..b1f0a9b 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,6 @@ +Version 0.3.5 (2019-) +- Add `ALLOWED_DOMAINS` option (Issue #143) + Version 0.3.4 (2019-01-27) - Spelling fix @nelsonblaha - Added env_var tag exception (Issue #138) From bb8306836cfbb10bfa9a100909311fd0f5c4163b Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Sat, 23 Feb 2019 20:37:02 +0100 Subject: [PATCH 04/17] Update dependencies --- changelog.txt | 1 + configurator.py | 5 +---- dev.html | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/changelog.txt b/changelog.txt index b1f0a9b..650824f 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,6 @@ Version 0.3.5 (2019-) - Add `ALLOWED_DOMAINS` option (Issue #143) +- Update dependencies Version 0.3.4 (2019-01-27) - Spelling fix @nelsonblaha diff --git a/configurator.py b/configurator.py index 924fbfd..9f46186 100755 --- a/configurator.py +++ b/configurator.py @@ -124,7 +124,7 @@ HASS Configurator - + + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ +
+ +
+ +
+ +
+
    +
  • Editor Settings
  • +
    +

    Keyboard Shortcuts

    +

    + + +

    +

    + + +

    +

    + + +

    +

    + + +

    +

    + + +

    +

    + + +

    +
    + + +
    +

    + + +

    +

    + + +

    +

    + + +

    +

    + + +

    +
    + + +
    +
    + + +
    +
    + + +
    +

    + + +

    +

    + + +

    +
    + + +
    +

    + + +

    +

    + + +

    +

    + + +

    +

    + + +

    +

    + + +

    +
    + + +
    +
    + + +
    +

    + + +

    +

    + + +

    +

    + + +

    +

    + + +

    +

    + + +

    +
    + + +
    Save Settings Locally +

    Ace Editor 1.4.2

    +
    +
+
+
+ + + + + + + + + + + + +""") + +# pylint: disable=unused-argument +def signal_handler(sig, frame): + """Handle signal to shut down server.""" + global HTTPD + LOG.info("Got signal: %s. Shutting down server", str(sig)) + HTTPD.server_close() + sys.exit(0) + +def load_settings(args): + """Load settings from file and environment.""" + global LISTENIP, LISTENPORT, BASEPATH, SSL_CERTIFICATE, SSL_KEY, HASS_API, \ + HASS_API_PASSWORD, CREDENTIALS, ALLOWED_NETWORKS, BANNED_IPS, BANLIMIT, \ + DEV, IGNORE_PATTERN, DIRSFIRST, SESAME, VERIFY_HOSTNAME, ENFORCE_BASEPATH, \ + ENV_PREFIX, NOTIFY_SERVICE, USERNAME, PASSWORD, SESAME_TOTP_SECRET, TOTP, \ + GIT, REPO, PORT, IGNORE_SSL, HASS_WS_API, ALLOWED_DOMAINS + settings = {} + settingsfile = args.settings + if settingsfile: + try: + if os.path.isfile(settingsfile): + with open(settingsfile) as fptr: + settings = json.loads(fptr.read()) + LOG.debug("Settings from file:") + LOG.debug(settings) + else: + LOG.warning("File not found: %s", settingsfile) + except Exception as err: + LOG.warning(err) + LOG.warning("Not loading settings from file") + ENV_PREFIX = settings.get('ENV_PREFIX', ENV_PREFIX) + for key, value in os.environ.items(): + if key.startswith(ENV_PREFIX): + # Convert booleans + if value in ['true', 'false', 'True', 'False']: + value = value in ['true', 'True'] + # Convert None / null + elif value in ['none', 'None', 'null']: + value = None + # Convert plain numbers + elif value.isnumeric(): + value = int(value) + # Make lists out of comma separated values for list-settings + elif key[len(ENV_PREFIX):] in ["ALLOWED_NETWORKS", "BANNED_IPS", "IGNORE_PATTERN"]: + value = value.split(',') + settings[key[len(ENV_PREFIX):]] = value + LOG.debug("Settings after looking at environment:") + LOG.debug(settings) + if args.git: + GIT = args.git + else: + GIT = settings.get("GIT", GIT) + if GIT: + try: + # pylint: disable=redefined-outer-name + from git import Repo as REPO + except ImportError: + LOG.warning("Unable to import Git module") + if args.listen: + LISTENIP = args.listen + else: + LISTENIP = settings.get("LISTENIP", LISTENIP) + if args.port: + PORT = args.port + else: + LISTENPORT = settings.get("LISTENPORT", None) + PORT = settings.get("PORT", PORT) + if LISTENPORT is not None: + PORT = LISTENPORT + if args.basepath: + BASEPATH = args.basepath + else: + BASEPATH = settings.get("BASEPATH", BASEPATH) + if args.enforce: + ENFORCE_BASEPATH = True + else: + ENFORCE_BASEPATH = settings.get("ENFORCE_BASEPATH", ENFORCE_BASEPATH) + SSL_CERTIFICATE = settings.get("SSL_CERTIFICATE", SSL_CERTIFICATE) + SSL_KEY = settings.get("SSL_KEY", SSL_KEY) + if args.standalone: + HASS_API = None + else: + HASS_API = settings.get("HASS_API", HASS_API) + HASS_WS_API = settings.get("HASS_WS_API", HASS_WS_API) + HASS_API_PASSWORD = settings.get("HASS_API_PASSWORD", HASS_API_PASSWORD) + CREDENTIALS = settings.get("CREDENTIALS", CREDENTIALS) + ALLOWED_NETWORKS = settings.get("ALLOWED_NETWORKS", ALLOWED_NETWORKS) + if ALLOWED_NETWORKS and not all(ALLOWED_NETWORKS): + LOG.warning("Invalid value for ALLOWED_NETWORKS. Using empty list.") + ALLOWED_NETWORKS = [] + for net in ALLOWED_NETWORKS: + try: + ipaddress.ip_network(net) + except Exception: + LOG.warning("Invalid network in ALLOWED_NETWORKS: %s", net) + ALLOWED_NETWORKS.remove(net) + ALLOWED_DOMAINS = settings.get("ALLOWED_DOMAINS", ALLOWED_DOMAINS) + if ALLOWED_DOMAINS and not all(ALLOWED_DOMAINS): + LOG.warning("Invalid value for ALLOWED_DOMAINS. Using empty list.") + ALLOWED_DOMAINS = [] + BANNED_IPS = settings.get("BANNED_IPS", BANNED_IPS) + if BANNED_IPS and not all(BANNED_IPS): + LOG.warning("Invalid value for BANNED_IPS. Using empty list.") + BANNED_IPS = [] + for banned_ip in BANNED_IPS: + try: + ipaddress.ip_address(banned_ip) + except Exception: + LOG.warning("Invalid IP address in BANNED_IPS: %s", banned_ip) + BANNED_IPS.remove(banned_ip) + BANLIMIT = settings.get("BANLIMIT", BANLIMIT) + DEV = settings.get("DEV", DEV) + IGNORE_PATTERN = settings.get("IGNORE_PATTERN", IGNORE_PATTERN) + if IGNORE_PATTERN and not all(IGNORE_PATTERN): + LOG.warning("Invalid value for IGNORE_PATTERN. Using empty list.") + IGNORE_PATTERN = [] + if args.dirsfirst: + DIRSFIRST = args.dirsfirst + else: + DIRSFIRST = settings.get("DIRSFIRST", DIRSFIRST) + SESAME = settings.get("SESAME", SESAME) + SESAME_TOTP_SECRET = settings.get("SESAME_TOTP_SECRET", SESAME_TOTP_SECRET) + VERIFY_HOSTNAME = settings.get("VERIFY_HOSTNAME", VERIFY_HOSTNAME) + NOTIFY_SERVICE = settings.get("NOTIFY_SERVICE", NOTIFY_SERVICE_DEFAULT) + IGNORE_SSL = settings.get("IGNORE_SSL", IGNORE_SSL) + if IGNORE_SSL: + # pylint: disable=protected-access + ssl._create_default_https_context = ssl._create_unverified_context + if args.username and args.password: + USERNAME = args.username + PASSWORD = args.password + else: + USERNAME = settings.get("USERNAME", USERNAME) + PASSWORD = settings.get("PASSWORD", PASSWORD) + PASSWORD = str(PASSWORD) if PASSWORD else None + if CREDENTIALS and (USERNAME is None or PASSWORD is None): + USERNAME = CREDENTIALS.split(":")[0] + PASSWORD = ":".join(CREDENTIALS.split(":")[1:]) + if PASSWORD and PASSWORD.startswith("{sha256}"): + PASSWORD = PASSWORD.lower() + if SESAME_TOTP_SECRET: + try: + import pyotp + TOTP = pyotp.TOTP(SESAME_TOTP_SECRET) + except ImportError: + LOG.warning("Unable to import pyotp module") + except Exception as err: + LOG.warning("Unable to create TOTP object: %s", err) + +def is_jwt(token): + """Perform basic check if token is a JWT token.""" + return len(token.split('.')) == 3 + +def is_safe_path(basedir, path, follow_symlinks=True): + """Check path for malicious traversal.""" + if basedir is None: + return True + if follow_symlinks: + return os.path.realpath(path).startswith(basedir.encode('utf-8')) + return os.path.abspath(path).startswith(basedir.encode('utf-8')) + +def get_dircontent(path, repo=None): + """Get content of directory.""" + dircontent = [] + if repo: + untracked = [ + "%s%s%s"%(repo.working_dir, os.sep, e) for e in \ + ["%s"%os.sep.join(f.split('/')) for f in repo.untracked_files] + ] + staged = {} + unstaged = {} + try: + for element in repo.index.diff("HEAD"): + staged["%s%s%s" % (repo.working_dir, + os.sep, + "%s"%os.sep.join( + element.b_path.split('/')))] = element.change_type + except Exception as err: + LOG.warning("Exception: %s", str(err)) + for element in repo.index.diff(None): + unstaged["%s%s%s" % (repo.working_dir, + os.sep, + "%s"%os.sep.join( + element.b_path.split('/')))] = element.change_type + else: + untracked = [] + staged = {} + unstaged = {} + + def sorted_file_list(): + """Sort list of files / directories.""" + dirlist = [x for x in os.listdir(path) if os.path.isdir(os.path.join(path, x))] + filelist = [x for x in os.listdir(path) if not os.path.isdir(os.path.join(path, x))] + if DIRSFIRST: + return sorted(dirlist, key=lambda x: x.lower()) + \ + sorted(filelist, key=lambda x: x.lower()) + return sorted(dirlist + filelist, key=lambda x: x.lower()) + + for elem in sorted_file_list(): + edata = {} + edata['name'] = elem + edata['dir'] = path + edata['fullpath'] = os.path.abspath(os.path.join(path, elem)) + edata['type'] = 'dir' if os.path.isdir(edata['fullpath']) else 'file' + try: + stats = os.stat(os.path.join(path, elem)) + edata['size'] = stats.st_size + edata['modified'] = stats.st_mtime + edata['created'] = stats.st_ctime + except Exception: + edata['size'] = 0 + edata['modified'] = 0 + edata['created'] = 0 + edata['changetype'] = None + edata['gitstatus'] = bool(repo) + edata['gittracked'] = 'untracked' if edata['fullpath'] in untracked else 'tracked' + if edata['fullpath'] in unstaged: + edata['gitstatus'] = 'unstaged' + edata['changetype'] = unstaged.get(edata['name'], None) + elif edata['fullpath'] in staged: + edata['gitstatus'] = 'staged' + edata['changetype'] = staged.get(edata['name'], None) + + hidden = False + if IGNORE_PATTERN is not None: + for file_pattern in IGNORE_PATTERN: + if fnmatch.fnmatch(edata['name'], file_pattern): + hidden = True + + if not hidden: + dircontent.append(edata) + + return dircontent + +def get_html(): + """Load the HTML from file in dev-mode, otherwise embedded.""" + if DEV: + try: + with open("dev.html") as fptr: + html = Template(fptr.read()) + return html + except Exception as err: + LOG.warning(err) + LOG.warning("Delivering embedded HTML") + return INDEX + +def password_problems(password, name="UNKNOWN"): + """Rudimentary checks for password strength.""" + problems = 0 + password = str(password) + if password is None: + return problems + if len(password) < 8: + LOG.warning("Password %s is too short", name) + problems += 1 + if password.isalpha(): + LOG.warning("Password %s does not contain digits", name) + problems += 2 + if password.isdigit(): + LOG.warning("Password %s does not contain alphabetic characters", name) + problems += 4 + quota = len(set(password)) / len(password) + exp = len(password) ** len(set(password)) + score = exp / quota / 8 + if score < 65536: + LOG.warning("Password %s does not contain enough unique characters (%i)", + name, len(set(password))) + problems += 8 + return problems + +def check_access(clientip): + """Check if IP is allowed to access the configurator / API.""" + global BANNED_IPS + if clientip in BANNED_IPS: + LOG.warning("Client IP banned.") + return False + if not ALLOWED_NETWORKS: + return True + for net in ALLOWED_NETWORKS: + ipobject = ipaddress.ip_address(clientip) + if ipobject in ipaddress.ip_network(net): + return True + LOG.warning("Client IP not within allowed networks.") + if ALLOWED_DOMAINS: + for domain in ALLOWED_DOMAINS: + try: + domain_data = socket.getaddrinfo(domain, None) + except Exception as err: + LOG.warning("Unable to lookup domain data: %s", err) + continue + for res in domain_data: + if res[0] in [socket.AF_INET, socket.AF_INET6]: + if res[4][0] == clientip: + return True + LOG.warning("Client IP not within allowed domains.") + BANNED_IPS.append(clientip) + return False + +def verify_hostname(request_hostname): + """Verify the provided host header is correct.""" + if VERIFY_HOSTNAME: + if VERIFY_HOSTNAME not in request_hostname: + return False + return True + +class RequestHandler(BaseHTTPRequestHandler): + """Request handler.""" + # pylint: disable=redefined-builtin + def log_message(self, format, *args): + LOG.info("%s - %s", self.client_address[0], format % args) + + # pylint: disable=invalid-name + def do_BLOCK(self, status=420, reason="Policy not fulfilled"): + """Customized do_BLOCK method.""" + self.send_response(status) + self.end_headers() + self.wfile.write(bytes(reason, "utf8")) + + # pylint: disable=invalid-name + def do_GET(self): + """Customized do_GET method.""" + if not verify_hostname(self.headers.get('Host', '')): + self.do_BLOCK(403, "Forbidden") + return + req = urlparse(self.path) + if SESAME or TOTP: + chunk = req.path.split("/")[-1] + if SESAME and chunk == SESAME: + if self.client_address[0] not in ALLOWED_NETWORKS: + ALLOWED_NETWORKS.append(self.client_address[0]) + if self.client_address[0] in BANNED_IPS: + BANNED_IPS.remove(self.client_address[0]) + url = req.path[:req.path.rfind(chunk)] + self.send_response(302) + self.send_header('Location', url) + self.end_headers() + data = { + "title": "HASS Configurator - SESAME access", + "message": "Your SESAME token has been used to whitelist " \ + "the IP address %s." % self.client_address[0] + } + notify(**data) + return + if TOTP and TOTP.verify(chunk): + if self.client_address[0] not in ALLOWED_NETWORKS: + ALLOWED_NETWORKS.append(self.client_address[0]) + if self.client_address[0] in BANNED_IPS: + BANNED_IPS.remove(self.client_address[0]) + url = req.path[:req.path.rfind(chunk)] + self.send_response(302) + self.send_header('Location', url) + self.end_headers() + data = { + "title": "HASS Configurator - SESAME access", + "message": "Your SESAME token has been used to whitelist " \ + "the IP address %s." % self.client_address[0] + } + notify(**data) + return + if not check_access(self.client_address[0]): + self.do_BLOCK() + return + query = parse_qs(req.query) + self.send_response(200) + # pylint: disable=no-else-return + if req.path.endswith('/api/file'): + content = "" + filename = query.get('filename', None) + try: + if filename: + is_raw = False + filename = unquote(filename[0]).encode('utf-8') + if ENFORCE_BASEPATH and not is_safe_path(BASEPATH, filename): + raise OSError('Access denied.') + filepath = os.path.join(BASEDIR.encode('utf-8'), filename) + if os.path.isfile(filepath): + mimetype = mimetypes.guess_type(filepath.decode('utf-8')) + if mimetype[0] is not None: + if mimetype[0].split('/')[0] == 'image': + is_raw = True + if is_raw: + with open(filepath, 'rb') as fptr: + content = fptr.read() + self.send_header('Content-type', mimetype[0]) + else: + with open(filepath, 'rb') as fptr: + content += fptr.read().decode('utf-8') + self.send_header('Content-type', 'text/text') + else: + self.send_header('Content-type', 'text/text') + content = "File not found" + except Exception as err: + LOG.warning(err) + self.send_header('Content-type', 'text/text') + content = str(err) + self.end_headers() + if is_raw: + self.wfile.write(content) + else: + self.wfile.write(bytes(content, "utf8")) + return + elif req.path.endswith('/api/download'): + content = "" + filename = query.get('filename', None) + try: + if filename: + filename = unquote(filename[0]).encode('utf-8') + if ENFORCE_BASEPATH and not is_safe_path(BASEPATH, filename): + raise OSError('Access denied.') + LOG.info(filename) + if os.path.isfile(os.path.join(BASEDIR.encode('utf-8'), filename)): + with open(os.path.join(BASEDIR.encode('utf-8'), filename), 'rb') as fptr: + filecontent = fptr.read() + self.send_header( + 'Content-Disposition', + 'attachment; filename=%s' % filename.decode('utf-8').split(os.sep)[-1]) + self.end_headers() + self.wfile.write(filecontent) + return + content = "File not found" + except Exception as err: + LOG.warning(err) + content = str(err) + self.send_header('Content-type', 'text/text') + self.wfile.write(bytes(content, "utf8")) + return + elif req.path.endswith('/api/listdir'): + content = {'error': None} + self.send_header('Content-type', 'text/json') + self.end_headers() + dirpath = query.get('path', None) + try: + if dirpath: + dirpath = unquote(dirpath[0]).encode('utf-8') + if os.path.isdir(dirpath): + if ENFORCE_BASEPATH and not is_safe_path(BASEPATH, + dirpath): + raise OSError('Access denied.') + repo = None + activebranch = None + dirty = False + branches = [] + if REPO: + try: + # pylint: disable=not-callable + repo = REPO(dirpath.decode('utf-8'), + search_parent_directories=True) + activebranch = repo.active_branch.name + dirty = repo.is_dirty() + for branch in repo.branches: + branches.append(branch.name) + except Exception as err: + LOG.debug("Exception (no repo): %s", str(err)) + dircontent = get_dircontent(dirpath.decode('utf-8'), repo) + filedata = { + 'content': dircontent, + 'abspath': os.path.abspath(dirpath).decode('utf-8'), + 'parent': os.path.dirname(os.path.abspath(dirpath)).decode('utf-8'), + 'branches': branches, + 'activebranch': activebranch, + 'dirty': dirty, + 'error': None + } + self.wfile.write(bytes(json.dumps(filedata), "utf8")) + except Exception as err: + LOG.warning(err) + content['error'] = str(err) + self.wfile.write(bytes(json.dumps(content), "utf8")) + return + elif req.path.endswith('/api/abspath'): + content = "" + self.send_header('Content-type', 'text/text') + self.end_headers() + dirpath = query.get('path', None) + if dirpath: + dirpath = unquote(dirpath[0]).encode('utf-8') + LOG.debug(dirpath) + absp = os.path.abspath(dirpath) + LOG.debug(absp) + if os.path.isdir(dirpath): + self.wfile.write(os.path.abspath(dirpath)) + return + elif req.path.endswith('/api/parent'): + content = "" + self.send_header('Content-type', 'text/text') + self.end_headers() + dirpath = query.get('path', None) + if dirpath: + dirpath = unquote(dirpath[0]).encode('utf-8') + LOG.debug(dirpath) + absp = os.path.abspath(dirpath) + LOG.debug(absp) + if os.path.isdir(dirpath): + self.wfile.write(os.path.abspath(os.path.dirname(dirpath))) + return + elif req.path.endswith('/api/netstat'): + content = "" + self.send_header('Content-type', 'text/json') + self.end_headers() + res = { + "allowed_networks": ALLOWED_NETWORKS, + "banned_ips": BANNED_IPS + } + self.wfile.write(bytes(json.dumps(res), "utf8")) + return + elif req.path.endswith('/api/restart'): + LOG.info("/api/restart") + self.send_header('Content-type', 'text/json') + self.end_headers() + res = {"restart": False} + try: + headers = { + "Content-Type": "application/json" + } + if HASS_API_PASSWORD: + if is_jwt(HASS_API_PASSWORD): + headers["Authorization"] = "Bearer %s" % HASS_API_PASSWORD + else: + headers["x-ha-access"] = HASS_API_PASSWORD + req = urllib.request.Request( + "%sservices/homeassistant/restart" % HASS_API, + headers=headers, method='POST') + with urllib.request.urlopen(req) as response: + res = json.loads(response.read().decode('utf-8')) + LOG.debug(res) + except Exception as err: + LOG.warning(err) + res['restart'] = str(err) + self.wfile.write(bytes(json.dumps(res), "utf8")) + return + elif req.path.endswith('/api/check_config'): + LOG.info("/api/check_config") + self.send_header('Content-type', 'text/json') + self.end_headers() + res = {"check_config": False} + try: + headers = { + "Content-Type": "application/json" + } + if HASS_API_PASSWORD: + if is_jwt(HASS_API_PASSWORD): + headers["Authorization"] = "Bearer %s" % HASS_API_PASSWORD + else: + headers["x-ha-access"] = HASS_API_PASSWORD + req = urllib.request.Request( + "%sservices/homeassistant/check_config" % HASS_API, + headers=headers, method='POST') + except Exception as err: + LOG.warning(err) + res['restart'] = str(err) + self.wfile.write(bytes(json.dumps(res), "utf8")) + return + elif req.path.endswith('/api/reload_automations'): + LOG.info("/api/reload_automations") + self.send_header('Content-type', 'text/json') + self.end_headers() + res = {"reload_automations": False} + try: + headers = { + "Content-Type": "application/json" + } + if HASS_API_PASSWORD: + if is_jwt(HASS_API_PASSWORD): + headers["Authorization"] = "Bearer %s" % HASS_API_PASSWORD + else: + headers["x-ha-access"] = HASS_API_PASSWORD + req = urllib.request.Request( + "%sservices/automation/reload" % HASS_API, + headers=headers, method='POST') + with urllib.request.urlopen(req) as response: + LOG.debug(json.loads(response.read().decode('utf-8'))) + res['service'] = "called successfully" + except Exception as err: + LOG.warning(err) + res['restart'] = str(err) + self.wfile.write(bytes(json.dumps(res), "utf8")) + return + elif req.path.endswith('/api/reload_scripts'): + LOG.info("/api/reload_scripts") + self.send_header('Content-type', 'text/json') + self.end_headers() + res = {"reload_scripts": False} + try: + headers = { + "Content-Type": "application/json" + } + if HASS_API_PASSWORD: + if is_jwt(HASS_API_PASSWORD): + headers["Authorization"] = "Bearer %s" % HASS_API_PASSWORD + else: + headers["x-ha-access"] = HASS_API_PASSWORD + req = urllib.request.Request( + "%sservices/script/reload" % HASS_API, + headers=headers, method='POST') + with urllib.request.urlopen(req) as response: + LOG.debug(json.loads(response.read().decode('utf-8'))) + res['service'] = "called successfully" + except Exception as err: + LOG.warning(err) + res['restart'] = str(err) + self.wfile.write(bytes(json.dumps(res), "utf8")) + return + elif req.path.endswith('/api/reload_groups'): + LOG.info("/api/reload_groups") + self.send_header('Content-type', 'text/json') + self.end_headers() + res = {"reload_groups": False} + try: + headers = { + "Content-Type": "application/json" + } + if HASS_API_PASSWORD: + if is_jwt(HASS_API_PASSWORD): + headers["Authorization"] = "Bearer %s" % HASS_API_PASSWORD + else: + headers["x-ha-access"] = HASS_API_PASSWORD + req = urllib.request.Request( + "%sservices/group/reload" % HASS_API, + headers=headers, method='POST') + with urllib.request.urlopen(req) as response: + LOG.debug(json.loads(response.read().decode('utf-8'))) + res['service'] = "called successfully" + except Exception as err: + LOG.warning(err) + res['restart'] = str(err) + self.wfile.write(bytes(json.dumps(res), "utf8")) + return + elif req.path.endswith('/api/reload_core'): + LOG.info("/api/reload_core") + self.send_header('Content-type', 'text/json') + self.end_headers() + res = {"reload_core": False} + try: + headers = { + "Content-Type": "application/json" + } + if HASS_API_PASSWORD: + if is_jwt(HASS_API_PASSWORD): + headers["Authorization"] = "Bearer %s" % HASS_API_PASSWORD + else: + headers["x-ha-access"] = HASS_API_PASSWORD + req = urllib.request.Request( + "%sservices/homeassistant/reload_core_config" % HASS_API, + headers=headers, method='POST') + with urllib.request.urlopen(req) as response: + LOG.debug(json.loads(response.read().decode('utf-8'))) + res['service'] = "called successfully" + except Exception as err: + LOG.warning(err) + res['restart'] = str(err) + self.wfile.write(bytes(json.dumps(res), "utf8")) + return + elif req.path.endswith('/'): + self.send_header('Content-type', 'text/html') + self.end_headers() + + loadfile = query.get('loadfile', [None])[0] + if loadfile is None: + loadfile = 'null' + else: + loadfile = "'%s'" % loadfile + services = "[]" + events = "[]" + states = "[]" + try: + if HASS_API: + headers = { + "Content-Type": "application/json" + } + if HASS_API_PASSWORD: + if is_jwt(HASS_API_PASSWORD): + headers["Authorization"] = "Bearer %s" % HASS_API_PASSWORD + else: + headers["x-ha-access"] = HASS_API_PASSWORD + + req = urllib.request.Request("%sservices" % HASS_API, + headers=headers, method='GET') + with urllib.request.urlopen(req) as response: + services = response.read().decode('utf-8') + + req = urllib.request.Request("%sevents" % HASS_API, + headers=headers, method='GET') + with urllib.request.urlopen(req) as response: + events = response.read().decode('utf-8') + + req = urllib.request.Request("%sstates" % HASS_API, + headers=headers, method='GET') + with urllib.request.urlopen(req) as response: + states = response.read().decode('utf-8') + + except Exception as err: + LOG.warning("Exception getting bootstrap") + LOG.warning(err) + + color = "" + try: + response = urllib.request.urlopen(RELEASEURL) + latest = json.loads(response.read().decode('utf-8'))['tag_name'] + if VERSION != latest: + color = "red-text" + except Exception as err: + LOG.warning("Exception getting release") + LOG.warning(err) + ws_api = "" + if HASS_API: + protocol, uri = HASS_API.split("//") + ws_api = "%s://%swebsocket" % ( + "wss" if protocol == 'https' else 'ws', uri + ) + if HASS_WS_API: + ws_api = HASS_WS_API + html = get_html().safe_substitute( + services=services, + events=events, + states=states, + loadfile=loadfile, + current=VERSION, + versionclass=color, + githidden="" if GIT else "hiddendiv", + # pylint: disable=anomalous-backslash-in-string + separator="\%s" % os.sep if os.sep == "\\" else os.sep, + your_address=self.client_address[0], + listening_address="%s://%s:%i" % ( + 'https' if SSL_CERTIFICATE else 'http', LISTENIP, PORT), + hass_api_address="%s" % (HASS_API, ), + hass_ws_address=ws_api, + api_password=HASS_API_PASSWORD if HASS_API_PASSWORD else "") + self.wfile.write(bytes(html, "utf8")) + return + else: + self.send_response(404) + self.end_headers() + self.wfile.write(bytes("File not found", "utf8")) + + # pylint: disable=invalid-name + def do_POST(self): + """Customized do_POST method.""" + global ALLOWED_NETWORKS, BANNED_IPS + if not verify_hostname(self.headers.get('Host', '')): + self.do_BLOCK(403, "Forbidden") + return + if not check_access(self.client_address[0]): + self.do_BLOCK() + return + req = urlparse(self.path) + + response = { + "error": True, + "message": "Generic failure" + } + + length = int(self.headers['content-length']) + if req.path.endswith('/api/save'): + try: + postvars = parse_qs(self.rfile.read(length).decode('utf-8'), + keep_blank_values=1) + except Exception as err: + LOG.warning(err) + response['message'] = "%s" % (str(err)) + postvars = {} + if 'filename' in postvars.keys() and 'text' in postvars.keys(): + if postvars['filename'] and postvars['text']: + try: + filename = unquote(postvars['filename'][0]) + response['file'] = filename + with open(filename, 'wb') as fptr: + fptr.write(bytes(postvars['text'][0], "utf-8")) + self.send_response(200) + self.send_header('Content-type', 'text/json') + self.end_headers() + response['error'] = False + response['message'] = "File saved successfully" + self.wfile.write(bytes(json.dumps(response), "utf8")) + return + except Exception as err: + response['message'] = "%s" % (str(err)) + LOG.warning(err) + else: + response['message'] = "Missing filename or text" + elif req.path.endswith('/api/upload'): + if length > 104857600: #100 MB for now + read = 0 + while read < length: + read += len(self.rfile.read(min(66556, length - read))) + self.send_response(200) + self.send_header('Content-type', 'text/json') + self.end_headers() + response['error'] = True + response['message'] = "File too big: %i" % read + self.wfile.write(bytes(json.dumps(response), "utf8")) + return + form = cgi.FieldStorage( + fp=self.rfile, + headers=self.headers, + environ={ + 'REQUEST_METHOD': 'POST', + 'CONTENT_TYPE': self.headers['Content-Type'], + }) + filename = form['file'].filename + filepath = form['path'].file.read() + data = form['file'].file.read() + open("%s%s%s" % (filepath, os.sep, filename), "wb").write(data) + self.send_response(200) + self.send_header('Content-type', 'text/json') + self.end_headers() + response['error'] = False + response['message'] = "Upload successful" + self.wfile.write(bytes(json.dumps(response), "utf8")) + return + elif req.path.endswith('/api/delete'): + try: + postvars = parse_qs(self.rfile.read(length).decode('utf-8'), + keep_blank_values=1) + except Exception as err: + LOG.warning(err) + response['message'] = "%s" % (str(err)) + postvars = {} + if 'path' in postvars.keys(): + if postvars['path']: + try: + delpath = unquote(postvars['path'][0]) + response['path'] = delpath + try: + if os.path.isdir(delpath): + os.rmdir(delpath) + else: + os.unlink(delpath) + self.send_response(200) + self.send_header('Content-type', 'text/json') + self.end_headers() + response['error'] = False + response['message'] = "Deletion successful" + self.wfile.write(bytes(json.dumps(response), "utf8")) + return + except Exception as err: + LOG.warning(err) + response['error'] = True + response['message'] = str(err) + + except Exception as err: + response['message'] = "%s" % (str(err)) + LOG.warning(err) + else: + response['message'] = "Missing filename or text" + elif req.path.endswith('/api/exec_command'): + try: + postvars = parse_qs(self.rfile.read(length).decode('utf-8'), + keep_blank_values=1) + except Exception as err: + LOG.warning(err) + response['message'] = "%s" % (str(err)) + postvars = {} + if 'command' in postvars.keys(): + if postvars['command']: + try: + command = shlex.split(postvars['command'][0]) + timeout = 15 + if 'timeout' in postvars.keys(): + if postvars['timeout']: + timeout = int(postvars['timeout'][0]) + try: + proc = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + stdout, stderr = proc.communicate(timeout=timeout) + self.send_response(200) + self.send_header('Content-type', 'text/json') + self.end_headers() + response['error'] = False + response['message'] = "Command executed: %s" % postvars['command'][0] + response['returncode'] = proc.returncode + try: + response['stdout'] = stdout.decode(sys.getdefaultencoding()) + except Exception as err: + LOG.warning(err) + response['stdout'] = stdout.decode("utf-8", errors="replace") + try: + response['stderr'] = stderr.decode(sys.getdefaultencoding()) + except Exception as err: + LOG.warning(err) + response['stderr'] = stderr.decode("utf-8", errors="replace") + self.wfile.write(bytes(json.dumps(response), "utf8")) + return + except Exception as err: + LOG.warning(err) + response['error'] = True + response['message'] = str(err) + + except Exception as err: + response['message'] = "%s" % (str(err)) + LOG.warning(err) + else: + response['message'] = "Missing command" + elif req.path.endswith('/api/gitadd'): + try: + postvars = parse_qs(self.rfile.read(length).decode('utf-8'), + keep_blank_values=1) + except Exception as err: + LOG.warning(err) + response['message'] = "%s" % (str(err)) + postvars = {} + if 'path' in postvars.keys(): + if postvars['path']: + try: + addpath = unquote(postvars['path'][0]) + # pylint: disable=not-callable + repo = REPO(addpath, + search_parent_directories=True) + filepath = "/".join( + addpath.split(os.sep)[len(repo.working_dir.split(os.sep)):]) + response['path'] = filepath + try: + repo.index.add([filepath]) + response['error'] = False + response['message'] = "Added file to index" + self.send_response(200) + self.send_header('Content-type', 'text/json') + self.end_headers() + self.wfile.write(bytes(json.dumps(response), "utf8")) + return + except Exception as err: + LOG.warning(err) + response['error'] = True + response['message'] = str(err) + + except Exception as err: + response['message'] = "%s" % (str(err)) + LOG.warning(err) + else: + response['message'] = "Missing filename" + elif req.path.endswith('/api/gitdiff'): + try: + postvars = parse_qs(self.rfile.read(length).decode('utf-8'), + keep_blank_values=1) + except Exception as err: + LOG.warning(err) + response['message'] = "%s" % (str(err)) + postvars = {} + if 'path' in postvars.keys(): + if postvars['path']: + try: + diffpath = unquote(postvars['path'][0]) + # pylint: disable=not-callable + repo = REPO(diffpath, + search_parent_directories=True) + filepath = "/".join( + diffpath.split(os.sep)[len(repo.working_dir.split(os.sep)):]) + response['path'] = filepath + try: + diff = repo.index.diff(None, + create_patch=True, + paths=filepath)[0].diff.decode("utf-8") + response['error'] = False + response['message'] = diff + self.send_response(200) + self.send_header('Content-type', 'text/json') + self.end_headers() + self.wfile.write(bytes(json.dumps(response), "utf8")) + return + except Exception as err: + LOG.warning(err) + response['error'] = True + response['message'] = "Unable to load diff: %s" % str(err) + + except Exception as err: + response['message'] = "%s" % (str(err)) + LOG.warning(err) + else: + response['message'] = "Missing filename" + elif req.path.endswith('/api/commit'): + try: + postvars = parse_qs(self.rfile.read(length).decode('utf-8'), + keep_blank_values=1) + except Exception as err: + LOG.warning(err) + response['message'] = "%s" % (str(err)) + postvars = {} + if 'path' in postvars.keys() and 'message' in postvars.keys(): + if postvars['path'] and postvars['message']: + try: + commitpath = unquote(postvars['path'][0]) + response['path'] = commitpath + message = unquote(postvars['message'][0]) + # pylint: disable=not-callable + repo = REPO(commitpath, + search_parent_directories=True) + try: + repo.index.commit(message) + response['error'] = False + response['message'] = "Changes commited" + self.send_response(200) + self.send_header('Content-type', 'text/json') + self.end_headers() + self.wfile.write(bytes(json.dumps(response), "utf8")) + return + except Exception as err: + response['error'] = True + response['message'] = str(err) + LOG.debug(response) + + except Exception as err: + response['message'] = "Not a git repository: %s" % (str(err)) + LOG.warning("Exception (no repo): %s", str(err)) + else: + response['message'] = "Missing path" + elif req.path.endswith('/api/checkout'): + try: + postvars = parse_qs(self.rfile.read(length).decode('utf-8'), + keep_blank_values=1) + except Exception as err: + LOG.warning(err) + response['message'] = "%s" % (str(err)) + postvars = {} + if 'path' in postvars.keys() and 'branch' in postvars.keys(): + if postvars['path'] and postvars['branch']: + try: + branchpath = unquote(postvars['path'][0]) + response['path'] = branchpath + branch = unquote(postvars['branch'][0]) + # pylint: disable=not-callable + repo = REPO(branchpath, + search_parent_directories=True) + try: + head = [h for h in repo.heads if h.name == branch][0] + head.checkout() + response['error'] = False + response['message'] = "Checked out %s" % branch + self.send_response(200) + self.send_header('Content-type', 'text/json') + self.end_headers() + self.wfile.write(bytes(json.dumps(response), "utf8")) + return + except Exception as err: + response['error'] = True + response['message'] = str(err) + LOG.warning(response) + + except Exception as err: + response['message'] = "Not a git repository: %s" % (str(err)) + LOG.warning("Exception (no repo): %s", str(err)) + else: + response['message'] = "Missing path or branch" + elif req.path.endswith('/api/newbranch'): + try: + postvars = parse_qs(self.rfile.read(length).decode('utf-8'), + keep_blank_values=1) + except Exception as err: + LOG.warning(err) + response['message'] = "%s" % (str(err)) + postvars = {} + if 'path' in postvars.keys() and 'branch' in postvars.keys(): + if postvars['path'] and postvars['branch']: + try: + branchpath = unquote(postvars['path'][0]) + response['path'] = branchpath + branch = unquote(postvars['branch'][0]) + # pylint: disable=not-callable + repo = REPO(branchpath, + search_parent_directories=True) + try: + repo.git.checkout("HEAD", b=branch) + response['error'] = False + response['message'] = "Created and checked out %s" % branch + self.send_response(200) + self.send_header('Content-type', 'text/json') + self.end_headers() + self.wfile.write(bytes(json.dumps(response), "utf8")) + return + except Exception as err: + response['error'] = True + response['message'] = str(err) + LOG.warning(response) + + except Exception as err: + response['message'] = "Not a git repository: %s" % (str(err)) + LOG.warning("Exception (no repo): %s", str(err)) + else: + response['message'] = "Missing path or branch" + elif req.path.endswith('/api/init'): + try: + postvars = parse_qs(self.rfile.read(length).decode('utf-8'), + keep_blank_values=1) + except Exception as err: + LOG.warning(err) + response['message'] = "%s" % (str(err)) + postvars = {} + if 'path' in postvars.keys(): + if postvars['path']: + try: + repopath = unquote(postvars['path'][0]) + response['path'] = repopath + try: + repo = REPO.init(repopath) + response['error'] = False + response['message'] = "Initialized repository in %s" % repopath + self.send_response(200) + self.send_header('Content-type', 'text/json') + self.end_headers() + self.wfile.write(bytes(json.dumps(response), "utf8")) + return + except Exception as err: + response['error'] = True + response['message'] = str(err) + LOG.warning(response) + + except Exception as err: + response['message'] = "Not a git repository: %s" % (str(err)) + LOG.warning("Exception (no repo): %s", str(err)) + else: + response['message'] = "Missing path or branch" + elif req.path.endswith('/api/push'): + try: + postvars = parse_qs(self.rfile.read(length).decode('utf-8'), + keep_blank_values=1) + except Exception as err: + LOG.warning(err) + response['message'] = "%s" % (str(err)) + postvars = {} + if 'path' in postvars.keys(): + if postvars['path']: + try: + repopath = unquote(postvars['path'][0]) + response['path'] = repopath + try: + # pylint: disable=not-callable + repo = REPO(repopath) + urls = [] + if repo.remotes: + for url in repo.remotes.origin.urls: + urls.append(url) + if not urls: + response['error'] = True + response['message'] = "No remotes configured for %s" % repopath + else: + repo.remotes.origin.push() + response['error'] = False + response['message'] = "Pushed to %s" % urls[0] + self.send_response(200) + self.send_header('Content-type', 'text/json') + self.end_headers() + self.wfile.write(bytes(json.dumps(response), "utf8")) + return + except Exception as err: + response['error'] = True + response['message'] = str(err) + LOG.warning(response) + + except Exception as err: + response['message'] = "Not a git repository: %s" % (str(err)) + LOG.warning("Exception (no repo): %s", str(err)) + else: + response['message'] = "Missing path or branch" + elif req.path.endswith('/api/stash'): + try: + postvars = parse_qs(self.rfile.read(length).decode('utf-8'), + keep_blank_values=1) + except Exception as err: + LOG.warning(err) + response['message'] = "%s" % (str(err)) + postvars = {} + if 'path' in postvars.keys(): + if postvars['path']: + try: + repopath = unquote(postvars['path'][0]) + response['path'] = repopath + try: + # pylint: disable=not-callable + repo = REPO(repopath) + returnvalue = repo.git.stash() + response['error'] = False + response['message'] = "%s\n%s" % (returnvalue, repopath) + self.send_response(200) + self.send_header('Content-type', 'text/json') + self.end_headers() + self.wfile.write(bytes(json.dumps(response), "utf8")) + return + except Exception as err: + response['error'] = True + response['message'] = str(err) + LOG.warning(response) + + except Exception as err: + response['message'] = "Not a git repository: %s" % (str(err)) + LOG.warning("Exception (no repo): %s", str(err)) + else: + response['message'] = "Missing path or branch" + elif req.path.endswith('/api/newfolder'): + try: + postvars = parse_qs(self.rfile.read(length).decode('utf-8'), + keep_blank_values=1) + except Exception as err: + LOG.warning(err) + response['message'] = "%s" % (str(err)) + postvars = {} + if 'path' in postvars.keys() and 'name' in postvars.keys(): + if postvars['path'] and postvars['name']: + try: + basepath = unquote(postvars['path'][0]) + name = unquote(postvars['name'][0]) + response['path'] = os.path.join(basepath, name) + try: + os.makedirs(response['path']) + self.send_response(200) + self.send_header('Content-type', 'text/json') + self.end_headers() + response['error'] = False + response['message'] = "Folder created" + self.wfile.write(bytes(json.dumps(response), "utf8")) + return + except Exception as err: + LOG.warning(err) + response['error'] = True + response['message'] = str(err) + except Exception as err: + response['message'] = "%s" % (str(err)) + LOG.warning(err) + elif req.path.endswith('/api/newfile'): + try: + postvars = parse_qs(self.rfile.read(length).decode('utf-8'), + keep_blank_values=1) + except Exception as err: + LOG.warning(err) + response['message'] = "%s" % (str(err)) + postvars = {} + if 'path' in postvars.keys() and 'name' in postvars.keys(): + if postvars['path'] and postvars['name']: + try: + basepath = unquote(postvars['path'][0]) + name = unquote(postvars['name'][0]) + response['path'] = os.path.join(basepath, name) + try: + with open(response['path'], 'w') as fptr: + fptr.write("") + self.send_response(200) + self.send_header('Content-type', 'text/json') + self.end_headers() + response['error'] = False + response['message'] = "File created" + self.wfile.write(bytes(json.dumps(response), "utf8")) + return + except Exception as err: + LOG.warning(err) + response['error'] = True + response['message'] = str(err) + except Exception as err: + response['message'] = "%s" % (str(err)) + LOG.warning(err) + else: + response['message'] = "Missing filename or text" + elif req.path.endswith('/api/allowed_networks'): + try: + postvars = parse_qs(self.rfile.read(length).decode('utf-8'), + keep_blank_values=1) + except Exception as err: + LOG.warning(err) + response['message'] = "%s" % (str(err)) + postvars = {} + if 'network' in postvars.keys() and 'method' in postvars.keys(): + if postvars['network'] and postvars['method']: + try: + network = unquote(postvars['network'][0]) + method = unquote(postvars['method'][0]) + if method == 'remove': + if network in ALLOWED_NETWORKS: + ALLOWED_NETWORKS.remove(network) + if not ALLOWED_NETWORKS: + ALLOWED_NETWORKS.append("0.0.0.0/0") + response['error'] = False + elif method == 'add': + ipaddress.ip_network(network) + ALLOWED_NETWORKS.append(network) + response['error'] = False + else: + response['error'] = True + self.send_response(200) + self.send_header('Content-type', 'text/json') + self.end_headers() + response['error'] = False + response['message'] = "ALLOWED_NETWORKS (%s): %s" % (method, network) + self.wfile.write(bytes(json.dumps(response), "utf8")) + return + except Exception as err: + response['error'] = True + response['message'] = "%s" % (str(err)) + LOG.warning(err) + else: + response['message'] = "Missing network" + elif req.path.endswith('/api/banned_ips'): + try: + postvars = parse_qs(self.rfile.read(length).decode('utf-8'), + keep_blank_values=1) + except Exception as err: + LOG.warning(err) + response['message'] = "%s" % (str(err)) + postvars = {} + if 'ip' in postvars.keys() and 'method' in postvars.keys(): + if postvars['ip'] and postvars['method']: + try: + ip_address = unquote(postvars['ip'][0]) + method = unquote(postvars['method'][0]) + if method == 'unban': + if ip_address in BANNED_IPS: + BANNED_IPS.remove(ip_address) + response['error'] = False + elif method == 'ban': + ipaddress.ip_network(ip_address) + BANNED_IPS.append(ip_address) + else: + response['error'] = True + self.send_response(200) + self.send_header('Content-type', 'text/json') + self.end_headers() + response['message'] = "BANNED_IPS (%s): %s" % (method, ip_address) + self.wfile.write(bytes(json.dumps(response), "utf8")) + return + except Exception as err: + response['error'] = True + response['message'] = "%s" % (str(err)) + LOG.warning(err) + else: + response['message'] = "Missing IP" + else: + response['message'] = "Invalid method" + self.send_response(200) + self.send_header('Content-type', 'text/json') + self.end_headers() + self.wfile.write(bytes(json.dumps(response), "utf8")) + return + +class AuthHandler(RequestHandler): + """Handler to verify auth header.""" + def do_BLOCK(self, status=420, reason="Policy not fulfilled"): + self.send_response(status) + self.end_headers() + self.wfile.write(bytes(reason, "utf8")) + + # pylint: disable=invalid-name + def do_AUTHHEAD(self): + """Request authorization.""" + LOG.info("Requesting authorization") + self.send_response(401) + self.send_header('WWW-Authenticate', 'Basic realm=\"HASS-Configurator\"') + self.send_header('Content-type', 'text/html') + self.end_headers() + + def do_GET(self): + if not verify_hostname(self.headers.get('Host', '')): + self.do_BLOCK(403, "Forbidden") + return + header = self.headers.get('Authorization', None) + if header is None: + self.do_AUTHHEAD() + self.wfile.write(bytes('no auth header received', 'utf-8')) + else: + authorization = header.split() + if len(authorization) == 2 and authorization[0] == "Basic": + plain = base64.b64decode(authorization[1]).decode("utf-8") + parts = plain.split(':') + username = parts[0] + password = ":".join(parts[1:]) + if PASSWORD.startswith("{sha256}"): + password = "{sha256}%s" % hashlib.sha256(password.encode("utf-8")).hexdigest() + if username == USERNAME and password == PASSWORD: + if BANLIMIT: + FAIL2BAN_IPS.pop(self.client_address[0], None) + super().do_GET() + return + if BANLIMIT: + bancounter = FAIL2BAN_IPS.get(self.client_address[0], 1) + if bancounter >= BANLIMIT: + LOG.warning("Blocking access from %s", self.client_address[0]) + self.do_BLOCK() + return + FAIL2BAN_IPS[self.client_address[0]] = bancounter + 1 + self.do_AUTHHEAD() + self.wfile.write(bytes('Authentication required', 'utf-8')) + + def do_POST(self): + if not verify_hostname(self.headers.get('Host', '')): + self.do_BLOCK(403, "Forbidden") + return + header = self.headers.get('Authorization', None) + if header is None: + self.do_AUTHHEAD() + self.wfile.write(bytes('no auth header received', 'utf-8')) + else: + authorization = header.split() + if len(authorization) == 2 and authorization[0] == "Basic": + plain = base64.b64decode(authorization[1]).decode("utf-8") + parts = plain.split(':') + username = parts[0] + password = ":".join(parts[1:]) + if PASSWORD.startswith("{sha256}"): + password = "{sha256}%s" % hashlib.sha256(password.encode("utf-8")).hexdigest() + if username == USERNAME and password == PASSWORD: + if BANLIMIT: + FAIL2BAN_IPS.pop(self.client_address[0], None) + super().do_POST() + return + if BANLIMIT: + bancounter = FAIL2BAN_IPS.get(self.client_address[0], 1) + if bancounter >= BANLIMIT: + LOG.warning("Blocking access from %s", self.client_address[0]) + self.do_BLOCK() + return + FAIL2BAN_IPS[self.client_address[0]] = bancounter + 1 + self.do_AUTHHEAD() + self.wfile.write(bytes('Authentication required', 'utf-8')) + +class SimpleServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + """Server class.""" + daemon_threads = True + allow_reuse_address = True + + def __init__(self, server_address, RequestHandlerClass): + socketserver.TCPServer.__init__(self, server_address, RequestHandlerClass) + +def notify(title="HASS Configurator", + message="Notification by HASS Configurator", + notification_id=None): + """Helper function to send notifications via HASS.""" + if not HASS_API or not NOTIFY_SERVICE: + return + headers = { + "Content-Type": "application/json" + } + data = { + "title": title, + "message": message + } + if notification_id and NOTIFY_SERVICE == NOTIFY_SERVICE_DEFAULT: + data["notification_id"] = notification_id + if HASS_API_PASSWORD: + if is_jwt(HASS_API_PASSWORD): + headers["Authorization"] = "Bearer %s" % HASS_API_PASSWORD + else: + headers["x-ha-access"] = HASS_API_PASSWORD + req = urllib.request.Request( + "%sservices/%s" % (HASS_API, NOTIFY_SERVICE.replace('.', '/')), + data=bytes(json.dumps(data).encode('utf-8')), + headers=headers, method='POST') + LOG.info("%s", data) + try: + with urllib.request.urlopen(req) as response: + message = response.read().decode('utf-8') + LOG.debug(message) + except Exception as err: + LOG.warning("Exception while creating notification: %s", err) + +def main(): + """Main function, duh!""" + global HTTPD + signal.signal(signal.SIGINT, signal_handler) + parser = argparse.ArgumentParser() + parser.add_argument( + 'settings', nargs='?', help="Path to file with persistent settings.") + parser.add_argument( + '--listen', '-l', nargs='?', + help="The IP address the service is listening on.") + parser.add_argument( + '--port', '-p', nargs='?', type=int, + help="The port the service is listening on.") + parser.add_argument('--basepath', '-b', nargs='?', + help="Path to initially serve files from") + parser.add_argument('--enforce', '-e', action='store_true', + help="Lock the configurator into the basepath.") + parser.add_argument( + '--username', '-U', nargs='?', help="Username required for access.") + parser.add_argument( + '--password', '-P', nargs='?', help="Password required for access.") + parser.add_argument('--standalone', '-s', action='store_true', + help="Don't fetch data from HASS_API.") + parser.add_argument('--dirsfirst', '-d', action='store_true', + help="Display directories first.") + parser.add_argument('--git', '-g', action='store_true', + help="Enable GIT support.") + args = parser.parse_args() + load_settings(args) + LOG.info("Starting server") + + try: + problems = None + if HASS_API_PASSWORD: + problems = password_problems(HASS_API_PASSWORD, "HASS_API_PASSWORD") + if problems: + data = { + "title": "HASS Configurator - Password warning", + "message": "Your HASS API password seems insecure (%i). " \ + "Refer to the HASS configurator logs for further information." % problems, + "notification_id": "HC_HASS_API_PASSWORD" + } + notify(**data) + + problems = None + if SESAME: + problems = password_problems(SESAME, "SESAME") + if problems: + data = { + "title": "HASS Configurator - Password warning", + "message": "Your SESAME seems insecure (%i). " \ + "Refer to the HASS configurator logs for further information." % problems, + "notification_id": "HC_SESAME" + } + notify(**data) + + problems = None + if PASSWORD: + problems = password_problems(PASSWORD, "PASSWORD") + if problems: + data = { + "title": "HASS Configurator - Password warning", + "message": "Your PASSWORD seems insecure (%i). " \ + "Refer to the HASS configurator logs for further information." % problems, + "notification_id": "HC_PASSWORD" + } + notify(**data) + except Exception as err: + LOG.warning("Exception while checking passwords: %s", err) + + custom_server = SimpleServer + if ':' in LISTENIP: + custom_server.address_family = socket.AF_INET6 + server_address = (LISTENIP, PORT) + if USERNAME and PASSWORD: + handler = AuthHandler + else: + handler = RequestHandler + HTTPD = custom_server(server_address, handler) + if SSL_CERTIFICATE: + HTTPD.socket = ssl.wrap_socket(HTTPD.socket, + certfile=SSL_CERTIFICATE, + keyfile=SSL_KEY, + server_side=True) + LOG.info('Listening on: %s://%s:%i', + 'https' if SSL_CERTIFICATE else 'http', LISTENIP, PORT) + if BASEPATH: + os.chdir(BASEPATH) + HTTPD.serve_forever() + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..84e5fa5 --- /dev/null +++ b/setup.py @@ -0,0 +1,29 @@ +# pylint: disable=missing-docstring +from setuptools import setup + +setup(name='hass-configurator', + version='0.3.4', + description='HASS-Configurator', + url='http://github.com/danielperna84/hass-configurator', + project_urls={ + 'Documentation': 'https://github.com/danielperna84/hass-configurator', + 'Tracker': 'https://github.com/danielperna84/hass-configurator/issues' + }, + author='Daniel Perna', + author_email='danielperna84@gmail.com', + license='MIT', + install_requires=['pyotp', 'gitpython'], + packages=['hass_configurator'], + entry_points={ + 'console_scripts': [ + 'hass-configurator = hass_configurator.configurator:main' + ] + }, + keywords='home-assistant', + platforms='any', + python_requires='>=3', + classifiers=[ + 'Programming Language :: Python :: 3', + 'Operating System :: OS Independent', + 'Topic :: Text Editors'], + zip_safe=False) From fcd3aa3951baea0a90ee9f2a3ada2ae3441c70b5 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Sun, 3 Mar 2019 00:38:01 +0100 Subject: [PATCH 07/17] Added standalone mode --- README.md | 3 ++- changelog.txt | 1 + configurator.py | 34 ++++++++++++++++++++++++++++--- dev.html | 28 +++++++++++++++++++++++-- hass_configurator/configurator.py | 34 ++++++++++++++++++++++++++++--- 5 files changed, 91 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7d2d194..4ac8664 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ While the configuration UI of [Home Assistant](https://home-assistant.io/) is st - Execute shell commands - Stage and commit changes in Git repositories, create and switch between branches, push to SSH remotes - Customizable editor settings (saved using [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage)) +- Standalone mode that hides the Home Assistant related panel on the left side (triggers, entities etc.). Set `HASS_API` to `None` or use the commandline flag `-s` / `--standalone` to enable this mode. #### Screenshot HASS Configurator: ![Screenshot](https://github.com/danielperna84/hass-configurator/blob/master/screenshots/main.png) @@ -50,7 +51,7 @@ Set ENFORCE_BASEPATH to `True` to lock the configurator into the basepath and th #### SSL_CERTIFICATE / SSL_KEY (string) If you're using SSL, set the paths to your SSL files here. This is similar to the SSL setup you can do in Home Assistant. #### HASS_API (string) -The configurator fetches some data from your running Home Assistant instance. If the API isn't available through the default URL, modify this variable to fix this. E.g. `http://192.168.1.2:8123/api/` +The configurator fetches some data from your running Home Assistant instance. If the API isn't available through the default URL, modify this variable to fix this. E.g. `http://192.168.1.2:8123/api/`. If you set this to `None`, the configurator will start in _standalone_ mode. #### HASS_WS_API (string) The event observer requires direct access to the websocket API of Home Assistant. Use this option to prefill the URL in event observer dialog with the correct address. E.g. `wss://hass.example.com/api/websocket`. Without this option set the configurator uses the value of `HASS_API`, which might not always be the correct value. #### HASS_API_PASSWORD (string) diff --git a/changelog.txt b/changelog.txt index af11c3c..ce229cf 100644 --- a/changelog.txt +++ b/changelog.txt @@ -4,6 +4,7 @@ Version 0.3.5 (2019-03-) - Include argparse for commandline parameters - Update dependencies - Code cleanup, lint (Issue #50) +- Added standalone mode Version 0.3.4 (2019-01-27) - Spelling fix @nelsonblaha diff --git a/configurator.py b/configurator.py index e8ff5d0..64cdb44 100755 --- a/configurator.py +++ b/configurator.py @@ -1696,7 +1696,7 @@
-
+

@@ -2233,6 +2233,29 @@ var global_current_filepath = null; var global_current_filename = null; + function toggle_hass_panels() { + if (document.getElementById("hass_menu_l").style.display == "none") { + document.getElementById("hass_menu_l").style.display = ""; + document.getElementById("editor").classList.remove("l12"); + document.getElementById("editor").classList.add("l9"); + } + else { + document.getElementById("hass_menu_l").style.display = "none"; + document.getElementById("editor").classList.remove("l9"); + document.getElementById("editor").classList.add("l12"); + } + if (document.getElementById("hass_menu_s").style.display == "none") { + document.getElementById("hass_menu_s").style.display = ""; + document.getElementById("editor").classList.remove("l12"); + document.getElementById("editor").classList.add("l9"); + } + else { + document.getElementById("hass_menu_s").style.display = "none"; + document.getElementById("editor").classList.remove("l9"); + document.getElementById("editor").classList.add("l12"); + } + } + function got_focus_or_visibility() { if (global_current_filename && global_current_filepath) { // The globals are set, set the localStorage to those values @@ -2354,6 +2377,7 @@ }, minLength: 1, }); + $standalone }); + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ +
+ +
+ +
+ +
+
    +
  • Editor Settings
  • +
    +

    Keyboard Shortcuts

    +

    + + +

    +

    + + +

    +

    + + +

    +

    + + +

    +

    + + +

    +

    + + +

    +
    + + +
    +

    + + +

    +

    + + +

    +

    + + +

    +

    + + +

    +
    + + +
    +
    + + +
    +
    + + +
    +

    + + +

    +

    + + +

    +
    + + +
    +

    + + +

    +

    + + +

    +

    + + +

    +

    + + +

    +

    + + +

    +
    + + +
    +
    + + +
    +

    + + +

    +

    + + +

    +

    + + +

    +

    + + +

    +

    + + +

    +
    + + +
    Save Settings Locally +

    Ace Editor 1.4.2

    +
    +
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/setup.py b/setup.py index 84e5fa5..c08aaa8 100644 --- a/setup.py +++ b/setup.py @@ -1,19 +1,33 @@ # pylint: disable=missing-docstring +from os import path from setuptools import setup -setup(name='hass-configurator', - version='0.3.4', +NAME = "hass-configurator" +PACKAGE_NAME = "hass_configurator" +VERSION = "0.3.5" + +# read the contents of your README file +THIS_DIRECTORY = path.abspath(path.dirname(__file__)) +with open(path.join(THIS_DIRECTORY, 'README.md'), encoding='utf-8') as f: + LONG_DESCRIPTION = f.read() + +setup(name=NAME, + version=VERSION, description='HASS-Configurator', + long_description=LONG_DESCRIPTION, + long_description_content_type='text/markdown', url='http://github.com/danielperna84/hass-configurator', project_urls={ 'Documentation': 'https://github.com/danielperna84/hass-configurator', 'Tracker': 'https://github.com/danielperna84/hass-configurator/issues' }, + download_url='https://github.com/danielperna84/hass-configurator/tarball/'+VERSION, author='Daniel Perna', author_email='danielperna84@gmail.com', license='MIT', install_requires=['pyotp', 'gitpython'], - packages=['hass_configurator'], + packages=[PACKAGE_NAME], + include_package_data=True, entry_points={ 'console_scripts': [ 'hass-configurator = hass_configurator.configurator:main' From c885a77583d18273300685c4f8081ea58b7be748 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Mon, 4 Mar 2019 02:22:51 +0100 Subject: [PATCH 13/17] Lint --- configurator.py | 5 ++--- hass_configurator/configurator.py | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/configurator.py b/configurator.py index a00305a..425f04e 100755 --- a/configurator.py +++ b/configurator.py @@ -3753,9 +3753,8 @@ def get_html(): """Load the HTML from file in dev-mode, otherwise embedded.""" if DEV: try: - with open(os.path.join( - os.path.dirname( - os.path.realpath(__file__)), "dev.html")) as fptr: + with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), + "dev.html")) as fptr: html = Template(fptr.read()) return html except Exception as err: diff --git a/hass_configurator/configurator.py b/hass_configurator/configurator.py index a00305a..425f04e 100644 --- a/hass_configurator/configurator.py +++ b/hass_configurator/configurator.py @@ -3753,9 +3753,8 @@ def get_html(): """Load the HTML from file in dev-mode, otherwise embedded.""" if DEV: try: - with open(os.path.join( - os.path.dirname( - os.path.realpath(__file__)), "dev.html")) as fptr: + with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), + "dev.html")) as fptr: html = Template(fptr.read()) return html except Exception as err: From acb2617d66b69eb3bb233adc90a94f345e2c4841 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Fri, 8 Mar 2019 00:23:26 +0100 Subject: [PATCH 14/17] Update readme because of Wiki --- README.md | 115 ++++-------------------------------------------------- 1 file changed, 8 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 3c3a9ad..c13971d 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ [![Build Status](https://travis-ci.org/danielperna84/hass-configurator.svg?branch=master)](https://travis-ci.org/danielperna84/hass-configurator) ### Configuration UI for Home Assistant -While the configuration UI of [Home Assistant](https://home-assistant.io/) is still in development, you can use this small webapp to modify your configuration. It's essentially an embedded [Ace editor](https://ace.c9.io/), which has syntax hightlighting and automatic linting for yaml files (and a ton of other features you can turn on and off). There is also an integrated file browser to select whatever file you want to edit. When you are done with editing the file, click the save-button (or hit CTRL+s/CMD+s) and it will replace the original file. -[JT Martinez](https://github.com/jmart518) has done a wonderful job by implementing [Material Design](http://materializecss.com/). +The HASS-Configurator is a small webapp (you access it via web browser) that provides a filesystem-browser and text-editor to modify files on the machine the configurator is running on. It has been created to allow easy configuration of [Home Assistant](https://home-assistant.io/). It is powered by [Ace editor](https://ace.c9.io/), which supports syntax highlighting for various code/markup languages. [YAML](https://en.wikipedia.org/wiki/YAML) files (the default language for Home Assistant configuration files) will be automatically checked for syntax errors while editing. +__IMPORTANT:__ The configurator fetches JavaScript libraries, CSS and fonts from CDNs. Hence it does __NOT__ work when your client device is offline. ### Feature list: @@ -27,105 +27,17 @@ If there is anything you want to have differently, feel free to fork and enhance _WARNING_: This tool allows you to browse your filesystem and modify files. So be careful which files you edit, or you might break critical parts of your system. ### Installation -There are no dependencies on Python modules that are not part of the standard library. And all the fancy JavaScript libraries are loaded from CDN (which means this does not work when you are offline). -- Copy [configurator.py](https://github.com/danielperna84/hass-configurator/blob/master/configurator.py) to your Home Assistant configuration directory (e.g /home/homeassistant/.homeassistant) -- Make it executable (`sudo chmod 755 configurator.py`) -- (Optional) Set the `GIT` variable in configurator.py to `True` if [GitPython](https://gitpython.readthedocs.io/) is installed on your system -- (Optional) Install [pyotp](https://github.com/pyotp/pyotp) if you want to use the time based `SESAME` feature (see below). -- Execute it (`sudo ./configurator.py`) -- To terminate the process do the usual `CTRL+C`, maybe once or twice +Possible methods to install the configurator are documented in the Wiki: [Installation](https://github.com/danielperna84/hass-configurator/wiki/Installation) ### Configuration -Near the top of the py-file you will find some global variables you can change to customize the configurator a little bit. If you are unfamiliar with Python: when setting variables of the type _string_, you have to write that within quotation marks. The default settings are fine for just checking this out quickly. With more customized setups you will have to change some settings though. -To keep your setting across updates it is also possible to save settings in an external file. In that case copy [settings.conf](https://github.com/danielperna84/hass-configurator/blob/master/settings.conf) whereever you like and append the full path to the file to the command when starting the configurator. E.g. `sudo .configurator.py /home/homeassistant/.homeassistant/mysettings.conf`. This file is in JSON format. So make sure it has a valid syntax (you can set the editor to JSON to get syntax highlighting for the settings). The major difference to the settings in the py-file is, that `None` becomes `null`. -Another way of passing settings is by using [environment variables](https://en.wikipedia.org/wiki/Environment_variable). All settings passed via environment variables will overwrite the settings you have set in the `settings.conf` file. This allows you to provide settings in you systemd service file or the way it is usually done with Docker. The names of the environment variables have to be named exactly like the regular ones, prepended with the prefix `HC_`. You can customize this prefix in the `settings.conf` by setting `ENV_PREFIX` to something you like. `ENV_PREFIX` can not be set via environment variable. For settings that are usually defined as lists (`ALLOWED_NETWORKS` etc.) a comma is used as a separator for each value (e.g. `HC_ALLOWED_NETWORKS="127.0.0.1,192.168.0.0/16"`). - -#### LISTENIP (string) -The IP address the service is listening on. By default it is binding to `0.0.0.0`, which is every IPv4 interface on the system. When using `::`, all available IPv6- and IPv4-addresses will be used. -#### PORT (integer) -The port the service is listening on. By default it is using 3218, but you can change this if you need to. The former setting `LISTENPORT` still works but is deprecated. Please change your settings accordingly. When setting this option to `0` a dynamic port will be used and logged at startup. This probably is only useful in _standalone_ mode and without firewall restrictions. -#### BASEPATH (string) -It is possible to place configurator.py somewhere else. Set the `BASEPATH` to something like `"/home/homeassistant/.homeassistant"`, and no matter where you are running the configurator from, it will start serving files from there. This is needed if you plan on running the configurator with systemd. -#### ENFORCE_BASEPATH (bool) -Set ENFORCE_BASEPATH to `True` to lock the configurator into the basepath and thereby prevent it from opening files outside of the BASEPATH -#### SSL_CERTIFICATE / SSL_KEY (string) -If you're using SSL, set the paths to your SSL files here. This is similar to the SSL setup you can do in Home Assistant. -#### HASS_API (string) -The configurator fetches some data from your running Home Assistant instance. If the API isn't available through the default URL, modify this variable to fix this. E.g. `http://192.168.1.2:8123/api/`. If you set this to `None`, the configurator will start in _standalone_ mode. -#### HASS_WS_API (string) -The event observer requires direct access to the websocket API of Home Assistant. Use this option to prefill the URL in event observer dialog with the correct address. E.g. `wss://hass.example.com/api/websocket`. Without this option set the configurator uses the value of `HASS_API`, which might not always be the correct value. -#### HASS_API_PASSWORD (string) -If you plan on using API functions (reloading stuff, fetching entities and services etc.), you have to set your API password. Calling the API of Home Assistant is prohibited without authentication. Both the old fashioned `api_password` and the _new_ [long-lived access tokens](https://developers.home-assistant.io/docs/en/auth_api.html#long-lived-access-token) (you can create those on your profile page at http://your-hass-address.com/profile) are supported. -#### IGNORE_SSL (bool) -Set IGNORE_SSL to `True` to disable SSL verification when connecting to the Home Assistant API (while fetching entities etc., not in your browser). This is useful if Home Assistant is configured with SSL, but the configurator accesses it via IP, in which case SSL verification will fail. -#### USERNAME (string) -If you want to enable [HTTP basic authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) you can set the desired username here. The `:` character is not allowed. -#### PASSWORD (string) -Set the password that should be used for authentication. Only if `USERNAME` __and__ `PASSWORD` are set authentication will be enabled. You may provide the password as a SHA256-hash with the prefix `{sha256}`. For example `PASSWORD = "test"` is functionally equal to `PASSWORD = "{sha256}9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"`. The hash will be converted to lower case automatically. Using the hash provides extra security by not exposing the actual password in plaintext in your configuration. -#### CREDENTIALS (string) -The credentials in the form of `"username:password"` are now deprecated and should be removed from you configuration. Replace it by specifying `USERNAME` and `PASSWORD`. It will still work though to ensure backwards compatibility. -#### ALLOWED_NETWORKS (list) -Limit access to the configurator by adding allowed IP addresses / networks to the list, e.g `ALLOWED_NETWORKS = ["192.168.0.0/24", "172.16.47.23"]`. If you are using the [hass.io addon](https://www.home-assistant.io/addons/configurator/) of the configurator, add the docker-network `172.30.0.0/16` to this list. -#### ALLOWED_DOMAINS -ALlow access to the configurator to client IP addesses which match the result of DNS lookups for the specified domains. -#### BANNED_IPS (list) -List of statically banned IP addresses, e.g. `BANNED_IPS = ["1.1.1.1", "2.2.2.2"]` -#### BANLIMIT (integer) -Ban IPs after n failed login attempts. Restart service to reset banning. The default of `0` disables this feature. `CREDENTIALS` has to be set for this to work. -#### IGNORE_PATTERN (list) -Files and folders to ignore in the UI, e.g. `IGNORE_PATTERN = [".*", "*.log", "__pycache__"]` -#### GIT (bool) -Set this variable to `True` to enable Git integration. This feature requires [GitPython](https://gitpython.readthedocs.io) - to be installed on the system that is running the configurator. -To push local commits to a remote repository, you have to add the remote manually: `git remote add origin ssh://somehost:/user/repo.git` -Verify, that the user that is running the configurator is allowed to push without any interaction (by using SSH PubKey authentication for example). -#### DIRSFIRST (bool) -If set to `true`, directories will be displayed at the top. -#### SESAME (string) -If set to _somesecretkeynobodycanguess_, you can browse to `https://your.configurator:3218/somesecretkeynobodycanguess` from any IP, and it will be removed from the `BANNED_IPS` list (in case it has been banned before) and added to the `ALLOWED_NETWORKS` list. Once the request has been processed you will automatically be redirected to the configurator. Think of this as dynamically allowing access from untrusted IPs by providing a secret key (_open sesame!_). Keep in mind, that once the IP has been added, you will either have to restart the configurator or manually remove the IP through the _Network status_ to revoke access. -#### SESAME_TOTP_SECRET (string) -Instead of or additionally to the `SESAME` token you may also specify a [Base32](https://en.wikipedia.org/wiki/Base32) encoded string that serves as the token for time based OTP (one time password) IP whitelisting. It works like the regular `SESAME`, but the request path that whitelists your IP changes every 30 seconds. You can add the `SESAME_TOTP_SECRET` to most of the available OTP-Apps (Google Authenticator and alike) and just append the 6-digit number to the URI where your configurator is reachable. For this to work the [pyotp](https://github.com/pyotp/pyotp) module has to be installed. -#### VERIFY_HOSTNAME (string) -HTTP requests include the hostname to which the request has been made. To improve security you can set this parameter to `yourdomain.example.com`. This will check if the hostname within the request matches the one you are expecting. If it does not match, a `403 Forbidden` response will be sent. As a result attackers that scan your IP address won't be able to connect unless they know the correct hostname. Be careful with this option though, because it prohibits you from accessing the configurator directly via IP. -#### ENV_PREFIX (string) -To modify the default prefix for settings passed as environment variables (`HC_`) change this setting to another value that meets your demands. -#### NOTIFY_SERVICE (string) -Define a notification service from your Home Assistant setup that should be used to send notifications, e.g. `notify.mytelegram`. The default is `persistent_notification.create`. Do __NOT__ change the value of the `NOTIFY_SERVICE_DEFAULT` variable! You will be notified if your `HASS_API_PASSWORD`, `SESAME` or `PASSWORD` password seems insecure. Additionally a notification with the accessing IP will be sent every time the `SESAME` token has been used for whitelisting. To disable this feature set the value to `False`. - -__Note regarding `ALLOWED_NETWORKS`, `BANNED_IPS` and `BANLIMIT`__: -The way this is implemented works in the following order: - -1. (Only if `CREDENTIALS` is set) Check credentials - - Failure: Retry `BANLIMIT` times, after that return error 420 (unless you try again without any authentication headers set, e.g. private tab of your browser) - - Success: Continue -2. Check if client IP address is in `BANNED_IPS` - - Yes: Return error 420 - - No: Continue -3. Check if client IP address is in `ALLOWED_NETWORKS` - - No: Return error 420 - - Yes: Continue and display UI of configurator +Available options to customize the behaviour of the configurator are documented in the Wiki: [Configuration](https://github.com/danielperna84/hass-configurator/wiki/Configuration) ### API -Starting at version 0.2.5 you can add / remove IP addresses and networks from and to the `ALLOWED_NETWORKS` and `BANNED_IPS` lists at runtime. Keep in mind though, that these changes are not persistent and will be lost when the service is restarted. The API can be used through the UI in the _Network status_ menu or by sending POST requests. A possible use case could be programmatically allowing access from your dynamic public IP, which can be required for some setups involving SSL. - -#### API targets: - -- `api/allowed_networks` - #### Methods: - - `add` - - `remove` - #### Example: - - `curl -d "method=add&network=1.2.3.4" -X POST http://127.0.0.1:3218/api/allowed_networks` -- `api/banned_ips` - #### Methods: - - `ban` - - `unban` - #### Example: - - Example: `curl -d "method=ban&ip=9.9.9.9" -X POST http://127.0.0.1:3218/api/banned_ips` +There is an API available to programmatically add and remove IP addresses / networks to and from `ALLOWED_NETWORKS` and `BANNED_IPS`. Usage is documented in the Wiki: [API](https://github.com/danielperna84/hass-configurator/wiki/API) ### Embedding into Home Assistant -Home Assistant has the [panel_iframe](https://home-assistant.io/components/panel_iframe/) component. With this it is possible to embed the configurator directly into Home Assistant, allowing you to modify your configuration through the Home Assistant frontend. +Once you have properly set up the configurator, you can use the [panel_iframe](https://home-assistant.io/components/panel_iframe/) component of Home Assistant to embed the configurator directly into the Home Assistant UI. An example configuration would look like this: ```yaml @@ -133,20 +45,9 @@ panel_iframe: configurator: title: Configurator icon: mdi:wrench - url: http://123.123.132.132:3218 + url: http://1.2.3.4:3218 ``` __IMPORTANT__: Be careful when setting up port forwarding to the configurator while embedding into Home Assistant. If you don't restrict access by requiring authentication and / or blocking based on client IP addresses, your configuration will be exposed to the web! ### Keeping the configurator running -Since the configurator script on its own is no service, you'll have to take some extra steps to keep it running. Here are three options (for Linux), but there are more, depending on your usecase. - -1. Simple fork into the background with the command `nohup sudo ./configurator.py &` -2. If your system is using systemd (that's usually what you'll find on a Raspberry PI), there's a [template file](https://github.com/danielperna84/hass-configurator/blob/master/hass-configurator.systemd) you can use and then apply the same process to integrate it as mentioned in the [Home Assistant documentation](https://home-assistant.io/getting-started/autostart-systemd/). If you use this method you have to set the `BASEPATH` variable according to your environment. -3. If you have [supervisor](http://supervisord.org/) running on your system, [hass-poc-configurator.supervisor](https://github.com/danielperna84/hass-configurator/blob/master/hass-configurator.supervisor) would be an example configuration you could use to control the configurator. -4. A tool called [tmux](https://tmux.github.io/), which should be pre-installed with recent AIO installers. -5. A tool called [screen](http://ss64.com/bash/screen.html). If it's not already installed on your system, you can do `sudo apt-get install screen` to get it. When it's installed, start a screen session by executing `screen`. Then navigate to your Home Assistant directory and start the configurator like described above. Put the screen session into the background by pressing `CTRL+A` and then `CTRL+D`. -To resume the screen session, log in to your machine and execute `screen -r`. - -### Docker -If you are using docker to run your homeassistant instance at home you can find corresponding docker images for the configurator on [dockerhub](https://hub.docker.com/r/causticlab/hass-configurator-docker/). -For usage visit the [repository](https://github.com/CausticLab/hass-configurator-docker) +Since the configurator script on its own is no service, you'll have to take some extra steps to keep it running. More information on this topic can be found in the Wiki: [Daemonizing](https://github.com/danielperna84/hass-configurator/wiki/Daemonizing) From 8cfb79371ef0e8c2a2261caa634e61f2a0567ac4 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Fri, 8 Mar 2019 00:25:18 +0100 Subject: [PATCH 15/17] Change order --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c13971d..252afbf 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,9 @@ Possible methods to install the configurator are documented in the Wiki: [Instal ### Configuration Available options to customize the behaviour of the configurator are documented in the Wiki: [Configuration](https://github.com/danielperna84/hass-configurator/wiki/Configuration) +### Keeping the configurator running +Since the configurator script on its own is no service, you'll have to take some extra steps to keep it running. More information on this topic can be found in the Wiki: [Daemonizing](https://github.com/danielperna84/hass-configurator/wiki/Daemonizing) + ### API There is an API available to programmatically add and remove IP addresses / networks to and from `ALLOWED_NETWORKS` and `BANNED_IPS`. Usage is documented in the Wiki: [API](https://github.com/danielperna84/hass-configurator/wiki/API) @@ -48,6 +51,3 @@ panel_iframe: url: http://1.2.3.4:3218 ``` __IMPORTANT__: Be careful when setting up port forwarding to the configurator while embedding into Home Assistant. If you don't restrict access by requiring authentication and / or blocking based on client IP addresses, your configuration will be exposed to the web! - -### Keeping the configurator running -Since the configurator script on its own is no service, you'll have to take some extra steps to keep it running. More information on this topic can be found in the Wiki: [Daemonizing](https://github.com/danielperna84/hass-configurator/wiki/Daemonizing) From 88f91961d4d905f72c67657951d5e0db56c5ec1f Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Fri, 8 Mar 2019 00:39:17 +0100 Subject: [PATCH 16/17] Update Dockerfile to use pip package --- Dockerfile | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6e9c3c5..0b847ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,10 +4,7 @@ LABEL maintainer="Daniel Perna " RUN apk update && \ apk upgrade && \ apk add --no-cache bash git openssh && \ - pip install --no-cache-dir gitpython pyotp - -WORKDIR /app -COPY configurator.py /app/ + pip install --no-cache-dir hass-configurator EXPOSE 3218 VOLUME /config @@ -15,4 +12,4 @@ VOLUME /config ENV HC_GIT true ENV HC_BASEPATH /config -ENTRYPOINT ["python", "/app/configurator.py"] \ No newline at end of file +ENTRYPOINT ["hass-configurator"] \ No newline at end of file From da5b513c45acf4f369903e75a0c84268e52bcd9c Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Fri, 8 Mar 2019 00:42:59 +0100 Subject: [PATCH 17/17] Final changelog --- changelog.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/changelog.txt b/changelog.txt index ce229cf..7c94a3d 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,10 +1,11 @@ -Version 0.3.5 (2019-03-) +Version 0.3.5 (2019-03-08) - Added `ALLOWED_DOMAINS` option (Issue #143) - Refactor for Pypi packaging (Issue #147) - Include argparse for commandline parameters - Update dependencies - Code cleanup, lint (Issue #50) - Added standalone mode +- Add Dockerfile Version 0.3.4 (2019-01-27) - Spelling fix @nelsonblaha