Skip to content

Commit

Permalink
Merge pull request #102 from atb00ker/allow-changing-data-directory
Browse files Browse the repository at this point in the history
Allow changing data directory
  • Loading branch information
iFargle authored May 8, 2023
2 parents 0ff6de4 + 4eea529 commit a7c0c9b
Show file tree
Hide file tree
Showing 7 changed files with 113 additions and 58 deletions.
26 changes: 26 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# General variables
TZ="UTC"
COLOR="blue-grey"
HS_SERVER=http://localhost:8080
KEY="GenerateYourOwnRandomKey"
SCRIPT_NAME=/admin
DATA_DIRECTORY=/data
DOMAIN_NAME=http://localhost:8080
AUTH_TYPE="Basic"
LOG_LEVEL="Debug"

# BasicAuth variables
BASIC_AUTH_USER="admin"
BASIC_AUTH_PASS="admin"

# Flask OIDC Variables
OIDC_AUTH_URL=https://localhost:8080
OIDC_CLIENT_ID=Headscale-WebUI
OIDC_CLIENT_SECRET=secret

# About section on the Settings page
GIT_COMMIT=""
GIT_BRANCH=""
APP_VERSION=""
BUILD_DATE=""
HS_VERSION=""
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
__pycache__
.venv
poetry.lock
.env
poetry.lock
26 changes: 24 additions & 2 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,39 @@
* Containers are published to [GHCR](https://github.com/users/iFargle/packages/container/package/headscale-webui) and [Docker Hub](https://hub.docker.com/r/ifargle/headscale-webui)

# Contents
* [Bare Metal](#bare-metal)
* [Docker Compose](#docker-compose)
* [Reverse Proxies](#reverse-proxies)
* [Authentication](#authentication)

---
# Bare Metal

1. Install dependencies:

```bash
# Debian/Ubuntu
apt install gcc python3-poetry --yes
poetry install --only main
```

2. Configurations: rename `.env.sample` -> `.env` and edit `.env` as per your requirements.

3. Run server

```bash
poetry run gunicorn -b 0.0.0.0:5000 server:app
```

That's it. Cheers.

# Docker Compose
## Environment Settings
* `TZ` - Set this to your current timezone. Example: `Asia/Tokyo`
* `COLOR` Set this to your preferred color scheme. See the [MaterializeCSS docs](https://materializecss.github.io/materialize/color.html#palette) for examples. Only set the "base" color -- ie, instead of `blue-gray darken-1`, just use `blue-gray`.
* `HS_SERVER` is the URL for your Headscale control server.
* `SCRIPT_NAME` is your "Base Path" for hosting. For example, if you want to host on http://localhost/admin, set this to `/admin`, otherwise remove this variable entirely.
* `DATA_DIRECTORY` is your "Data Path". This is there the application will create and store data. Only applicable for bare metal installations.
* `KEY` is your encryption key. Set this to a random value generated from `openssl rand -base64 32`
* `AUTH_TYPE` can be set to `Basic` or `OIDC`. See the [Authentication](#Authentication) section below for more information.
* `LOG_LEVEL` can be one of `Debug`, `Info`, `Warning`, `Error`, or `Critical` for decreasing verbosity. Default is `Info` if removed from your Environment.
Expand Down Expand Up @@ -81,7 +103,7 @@ https://[DOMAIN] {
reverse_proxy * [HS_SERVER]
}
```
* Example:
* Example:
```
https://example.com {
reverse_proxy /admin* http://headscale-webui:5000
Expand All @@ -90,7 +112,7 @@ https://example.com {
}
```

---
---
# Authentication
*If your OIDC provider isn't listed or doesn't work, please open up a [new issue](https://github.com/iFargle/headscale-webui/issues/new) and it will be worked on.*

Expand Down
9 changes: 6 additions & 3 deletions headscale.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
from datetime import timedelta, date
from dateutil import parser
from flask import Flask
from dotenv import load_dotenv

load_dotenv()
LOG_LEVEL = os.environ["LOG_LEVEL"].replace('"', '').upper()
DATA_DIRECTORY = os.environ["DATA_DIRECTORY"].replace('"', '') if os.environ["DATA_DIRECTORY"] else "/data"
# Initiate the Flask application and logging:
app = Flask(__name__, static_url_path="/static")
match LOG_LEVEL:
Expand Down Expand Up @@ -39,7 +42,7 @@ def set_api_key(api_key):
# User-set encryption key
encryption_key = os.environ['KEY']
# Key file on the filesystem for persistent storage
key_file = open("/data/key.txt", "wb+")
key_file = open(os.path.join(DATA_DIRECTORY, "key.txt"), "wb+")
# Preparing the Fernet class with the key
fernet = Fernet(encryption_key)
# Encrypting the key
Expand All @@ -48,11 +51,11 @@ def set_api_key(api_key):
return True if key_file.write(encrypted_key) else False

def get_api_key():
if not os.path.exists("/data/key.txt"): return False
if not os.path.exists(os.path.join(DATA_DIRECTORY, "key.txt")): return False
# User-set encryption key
encryption_key = os.environ['KEY']
# Key file on the filesystem for persistent storage
key_file = open("/data/key.txt", "rb+")
key_file = open(os.path.join(DATA_DIRECTORY, "key.txt"), "rb+")
# The encrypted key read from the file
enc_api_key = key_file.read()
if enc_api_key == b'': return "NULL"
Expand Down
101 changes: 51 additions & 50 deletions helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from flask import Flask

LOG_LEVEL = os.environ["LOG_LEVEL"].replace('"', '').upper()
DATA_DIRECTORY = os.environ["DATA_DIRECTORY"].replace('"', '') if os.environ["DATA_DIRECTORY"] else "/data"
# Initiate the Flask application and logging:
app = Flask(__name__, static_url_path="/static")
match LOG_LEVEL:
Expand Down Expand Up @@ -45,7 +46,7 @@ def text_color_duration(duration):
if days > 5: return "deep-orange-text text-lighten-1"
if days > 1: return "deep-orange-text text-lighten-1"
if hours > 12: return "orange-text "
if hours > 1: return "orange-text text-lighten-2"
if hours > 1: return "orange-text text-lighten-2"
if hours == 1: return "yellow-text "
if mins > 15: return "yellow-text text-lighten-2"
if mins > 5: return "green-text text-lighten-3"
Expand All @@ -57,11 +58,11 @@ def key_check():
api_key = headscale.get_api_key()
url = headscale.get_url()

# Test the API key. If the test fails, return a failure.
# Test the API key. If the test fails, return a failure.
# AKA, if headscale returns Unauthorized, fail:
app.logger.info("Testing API key validity.")
status = headscale.test_api_key(url, api_key)
if status != 200:
if status != 200:
app.logger.info("Got a non-200 response from Headscale. Test failed (Response: %i)", status)
return False
else:
Expand Down Expand Up @@ -128,7 +129,7 @@ def format_message(error_type, title, message):
<ul class="collection">
<li class="collection-item avatar">
"""

match error_type.lower():
case "warning":
icon = """<i class="material-icons circle yellow">priority_high</i>"""
Expand All @@ -143,7 +144,7 @@ def format_message(error_type, title, message):
icon = """<i class="material-icons circle grey">help</i>"""
title = """<span class="title">Information - """+title+"""</span>"""

content = content+icon+title+message
content = content+icon+title+message
content = content+"""
</li>
</ul>
Expand All @@ -158,12 +159,12 @@ def access_checks():
# Return an error message if things fail.
# Return a formatted error message for EACH fail.
checks_passed = True # Default to true. Set to false when any checks fail.
data_readable = False # Checks R permissions of /data
data_writable = False # Checks W permissions of /data
data_readable = False # Checks R permissions of DATA_DIRECTORY
data_writable = False # Checks W permissions of DATA_DIRECTORY
data_executable = False # Execute on directories allows file access
file_readable = False # Checks R permissions of /data/key.txt
file_writable = False # Checks W permissions of /data/key.txt
file_exists = False # Checks if /data/key.txt exists
file_readable = False # Checks R permissions of DATA_DIRECTORY/key.txt
file_writable = False # Checks W permissions of DATA_DIRECTORY/key.txt
file_exists = False # Checks if DATA_DIRECTORY/key.txt exists
config_readable = False # Checks if the headscale configuration file is readable


Expand All @@ -176,32 +177,32 @@ def access_checks():
checks_passed = False
app.logger.critical("Headscale URL: Response 200: FAILED")

# Check: /data is rwx for 1000:1000:
if os.access('/data/', os.R_OK): data_readable = True
# Check: DATA_DIRECTORY is rwx for 1000:1000:
if os.access(DATA_DIRECTORY, os.R_OK): data_readable = True
else:
app.logger.critical("/data READ: FAILED")
app.logger.critical(f"{DATA_DIRECTORY} READ: FAILED")
checks_passed = False
if os.access('/data/', os.W_OK): data_writable = True
if os.access(DATA_DIRECTORY, os.W_OK): data_writable = True
else:
app.logger.critical("/data WRITE: FAILED")
app.logger.critical(f"{DATA_DIRECTORY} WRITE: FAILED")
checks_passed = False
if os.access('/data/', os.X_OK): data_executable = True
if os.access(DATA_DIRECTORY, os.X_OK): data_executable = True
else:
app.logger.critical("/data EXEC: FAILED")
app.logger.critical(f"{DATA_DIRECTORY} EXEC: FAILED")
checks_passed = False

# Check: /data/key.txt exists and is rw:
if os.access('/data/key.txt', os.F_OK):
# Check: DATA_DIRECTORY/key.txt exists and is rw:
if os.access(os.path.join(DATA_DIRECTORY, "key.txt"), os.F_OK):
file_exists = True
if os.access('/data/key.txt', os.R_OK): file_readable = True
if os.access(os.path.join(DATA_DIRECTORY, "key.txt"), os.R_OK): file_readable = True
else:
app.logger.critical("/data/key.txt READ: FAILED")
app.logger.critical(f"{os.path.join(DATA_DIRECTORY, 'key.txt')} READ: FAILED")
checks_passed = False
if os.access('/data/key.txt', os.W_OK): file_writable = True
if os.access(os.path.join(DATA_DIRECTORY, "key.txt"), os.W_OK): file_writable = True
else:
app.logger.critical("/data/key.txt WRITE: FAILED")
app.logger.critical(f"{os.path.join(DATA_DIRECTORY, 'key.txt')} WRITE: FAILED")
checks_passed = False
else: app.logger.error("/data/key.txt EXIST: FAILED - NO ERROR")
else: app.logger.error(f"{os.path.join(DATA_DIRECTORY, 'key.txt')} EXIST: FAILED - NO ERROR")

# Check: /etc/headscale/config.yaml is readable:
if os.access('/etc/headscale/config.yaml', os.R_OK): config_readable = True
Expand All @@ -219,7 +220,7 @@ def access_checks():
if not server_reachable:
app.logger.critical("Server is unreachable")
message = """
<p>Your headscale server is either unreachable or not properly configured.
<p>Your headscale server is either unreachable or not properly configured.
Please ensure your configuration is correct (Check for 200 status on
"""+url+"""/api/v1 failed. Response: """+str(response.status_code)+""".)</p>
"""
Expand All @@ -237,58 +238,58 @@ def access_checks():
message_html += format_message("Error", "/etc/headscale/config.yaml not readable", message)

if not data_writable:
app.logger.critical("/data folder is not writable")
message = """
<p>/data is not writable. Please ensure your
permissions are correct. /data mount should be writable
app.logger.critical(f"{DATA_DIRECTORY} folder is not writable")
message = f"""
<p>{DATA_DIRECTORY} is not writable. Please ensure your
permissions are correct. {DATA_DIRECTORY} mount should be writable
by UID/GID 1000:1000.</p>
"""

message_html += format_message("Error", "/data not writable", message)
message_html += format_message("Error", f"{DATA_DIRECTORY} not writable", message)

if not data_readable:
app.logger.critical("/data folder is not readable")
message = """
<p>/data is not readable. Please ensure your
permissions are correct. /data mount should be readable
app.logger.critical(f"{DATA_DIRECTORY} folder is not readable")
message = f"""
<p>{DATA_DIRECTORY} is not readable. Please ensure your
permissions are correct. {DATA_DIRECTORY} mount should be readable
by UID/GID 1000:1000.</p>
"""

message_html += format_message("Error", "/data not readable", message)
message_html += format_message("Error", f"{DATA_DIRECTORY} not readable", message)

if not data_executable:
app.logger.critical("/data folder is not readable")
message = """
<p>/data is not executable. Please ensure your
permissions are correct. /data mount should be readable
app.logger.critical(f"{DATA_DIRECTORY} folder is not readable")
message = f"""
<p>{DATA_DIRECTORY} is not executable. Please ensure your
permissions are correct. {DATA_DIRECTORY} mount should be readable
by UID/GID 1000:1000. (chown 1000:1000 /path/to/data && chmod -R 755 /path/to/data)</p>
"""

message_html += format_message("Error", "/data not executable", message)
message_html += format_message("Error", f"{DATA_DIRECTORY} not executable", message)


if file_exists:
# If it doesn't exist, we assume the user hasn't created it yet.
# Just redirect to the settings page to enter an API Key
if not file_writable:
app.logger.critical("/data/key.txt is not writable")
message = """
<p>/data/key.txt is not writable. Please ensure your
permissions are correct. /data mount should be writable
app.logger.critical(f"{os.path.join(DATA_DIRECTORY, 'key.txt')} is not writable")
message = f"""
<p>{os.path.join(DATA_DIRECTORY, 'key.txt')} is not writable. Please ensure your
permissions are correct. {DATA_DIRECTORY} mount should be writable
by UID/GID 1000:1000.</p>
"""

message_html += format_message("Error", "/data/key.txt not writable", message)
message_html += format_message("Error", f"{os.path.join(DATA_DIRECTORY, 'key.txt')} not writable", message)

if not file_readable:
app.logger.critical("/data/key.txt is not readable")
message = """
<p>/data/key.txt is not readable. Please ensure your
permissions are correct. /data mount should be readable
app.logger.critical(f"{os.path.join(DATA_DIRECTORY, 'key.txt')} is not readable")
message = f"""
<p>{os.path.join(DATA_DIRECTORY, 'key.txt')} is not readable. Please ensure your
permissions are correct. {DATA_DIRECTORY} mount should be readable
by UID/GID 1000:1000.</p>
"""

message_html += format_message("Error", "/data/key.txt not readable", message)
message_html += format_message("Error", f"{os.path.join(DATA_DIRECTORY, 'key.txt')} not readable", message)

return message_html

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pyuwsgi = "^2.0.21"
gunicorn = "^20.1.0"
flask-basicauth = "^0.2.0"
flask-providers-oidc = "^1.2.1"
python-dotenv = "^1.0.0"

[tool.poetry.dev-dependencies]

Expand Down
5 changes: 3 additions & 2 deletions templates/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<i class="material-icons prefix">vpn_key</i>
<input id="api_key" type="password">
<label for="api_key">API Key</label>
</div>
</div>
</div>
<div class="card-action">
<a href="#test_modal" class="modal-trigger" onclick="save_key()">Save</a>
Expand Down Expand Up @@ -57,6 +57,7 @@ <h4>Instructions</h4>
<li>To generate your API key, run the command <a class="{{ COLOR_BTN }} white-text">headscale apikeys create</a> on your control server. Once you generate your first key, this UI will automatically renew the key near expiration.</li>
<li>The Headscale server is configured via the <a class="{{ COLOR_BTN }} white-text">HS_SERVER</a> environment variable in Docker. Current server: <a class="{{ COLOR_BTN }} white-text"> {{url}} </a></li>
<li>You must configure an encryption key via the <a class="{{ COLOR_BTN }} white-text">KEY</a> environment variable in Docker. One can be generated with the command <a class="{{ COLOR_BTN }} white-text">openssl rand -base64 32</a></li>
<li>Enter the API key generated by headscale, press "Save" then "Test". Saving before using the "Test" button is important.</li>
</ul>
</div>
<div class="modal-footer">
Expand All @@ -81,4 +82,4 @@ <h4>Web UI Theme Settings</h4>
<a href="#!" class="modal-close btn-flat">Close</a>
</div>
</div>
{% endblock %}
{% endblock %}

0 comments on commit a7c0c9b

Please sign in to comment.