Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add TLS support #2697

Open
wants to merge 22 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion bootstrap/bootstrap/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import docker
import requests
import urllib3
from loguru import logger


Expand Down Expand Up @@ -226,7 +227,7 @@ def is_version_chooser_online(self) -> bool:
bool: True if version chooser is online, False otherwise.
"""
try:
response = requests.get("http://localhost/version-chooser/v1.0/version/current", timeout=10)
response = requests.get("http://localhost/version-chooser/v1.0/version/current", timeout=10, verify=False)
if Bootstrapper.SETTINGS_NAME_CORE in response.json()["repository"]:
return True
except Exception as e:
Expand All @@ -248,6 +249,7 @@ def remove(self, container: str) -> None:
def run(self) -> None:
"""Runs the bootstrapper"""
logger.info(f"Starting bootstrap {self.bootstrap_version()}")
urllib3.disable_warnings()
while True:
time.sleep(5)
for image in self.read_config_file():
Expand Down
17 changes: 17 additions & 0 deletions core/frontend/src/components/wizard/Wizard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
<div class="d-flex flex-column align-center">
<v-text-field v-model="vehicle_name" label="Vehicle Name" />
<v-text-field v-model="mdns_name" label="MDNS Name" />
<v-checkbox v-model="enable_tls" label="Enable TLS" />
</div>
<ScriptLoader
v-model="scripts"
Expand Down Expand Up @@ -346,6 +347,8 @@ export default Vue.extend({
vehicle_name: 'blueos',
vehicle_type: '' as Vehicle | string,
vehicle_image: null as string | null,
// Allow the user to enable TLS on their vehicle
enable_tls: false,
// Allow us to check if the user is stuck in retry
retry_count: 0,
params: undefined as undefined | Dictionary<number>,
Expand Down Expand Up @@ -458,6 +461,15 @@ export default Vue.extend({
skip: false,
started: false,
},
{
title: 'Set TLS',
summary: `Enable TLS for the BlueOS web server: ${this.enable_tls}`,
promise: () => this.setTLS(),
message: undefined,
done: false,
skip: false,
started: false,
},
{
title: 'Set vehicle image',
summary: 'Set image to be used for vehicle thumbnail',
Expand Down Expand Up @@ -599,6 +611,11 @@ export default Vue.extend({
.then(() => undefined)
.catch(() => 'Failed to set custom vehicle name')
},
async setTLS(): Promise<ConfigurationStatus> {
return beacon.setTLS(this.enable_tls)
.then(() => undefined)
.catch(() => 'Failed to change TLS configuration')
},
async disableWifiHotspot(): Promise<ConfigurationStatus> {
return back_axios({
method: 'post',
Expand Down
33 changes: 33 additions & 0 deletions core/frontend/src/store/beacon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ class BeaconStore extends VuexModule {

vehicle_name = ''

use_tls = false

// eslint-disable-next-line
@Mutation
private _setHostname(hostname: string): void {
Expand All @@ -49,6 +51,12 @@ class BeaconStore extends VuexModule {
this.vehicle_name = vehicle_name
}

// eslint-disable-next-line
@Mutation
private _setUseTLS(use_tls: boolean): void {
this.use_tls = use_tls
}

@Mutation
setAvailableDomains(domains: Domain[]): void {
this.available_domains = domains
Expand Down Expand Up @@ -236,6 +244,31 @@ class BeaconStore extends VuexModule {
}
}, 1000)
}

@Action
async setTLS(enable_tls: boolean): Promise<boolean> {
return back_axios({
method: 'post',
url: `${this.API_URL}/use_tls`,
timeout: 5000,
params: {
enable_tls: enable_tls,
},
})
.then(() => {
// eslint-disable-next-line
this._setUseTLS(enable_tls)
return true
})
.catch((error) => {
if (error === backend_offline_error) {
return false
}
const message = `Could not set TLS option: ${error.response?.data ?? error.message}.`
notifier.pushError('BEACON_SET_TLS_FAIL', message, true)
return false
})
}
}

export { BeaconStore }
Expand Down
185 changes: 181 additions & 4 deletions core/services/beacon/main.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
#!/usr/bin/env python3
import argparse
import asyncio
import datetime
import itertools
import logging
import os
import pathlib
import re
import shlex
import shutil
import signal
import socket
import subprocess
from typing import Any, Dict, List, Optional

import psutil
Expand All @@ -19,11 +26,19 @@
from zeroconf import IPVersion
from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf

from settings import ServiceTypes, SettingsV4
from settings import ServiceTypes, SettingsV5
from typedefs import InterfaceType, IpInfo, MdnsEntry

SERVICE_NAME = "beacon"

NGINX_ROOT_PATH = "/etc/blueos/nginx"
NGINX_PID_PATH = "/run/nginx.pid" # this is defined in the nginx config
TLS_CERT_PATH = os.path.join(NGINX_ROOT_PATH, "blueos.crt")
TLS_KEY_PATH = os.path.join(NGINX_ROOT_PATH, "blueos.key")

BLUEOS_TOOLS_PATH = "/home/pi/tools"
BLUEOS_TOOLS_NGINX_PATH = os.path.join(BLUEOS_TOOLS_PATH, "nginx")


class AsyncRunner:
def __init__(self, ip_version: IPVersion, interface: str, interface_name: str) -> None:
Expand Down Expand Up @@ -78,7 +93,7 @@ class Beacon:
def __init__(self) -> None:
self.runners: Dict[str, AsyncRunner] = {}
try:
self.manager = Manager(SERVICE_NAME, SettingsV4)
self.manager = Manager(SERVICE_NAME, SettingsV5)
except Exception as e:
logger.warning(f"failed to load configuration file ({e}), loading defaults")
self.load_default_settings()
Expand All @@ -95,8 +110,8 @@ def load_default_settings(self) -> None:
current_folder = pathlib.Path(__file__).parent.resolve()
default_settings_file = current_folder / "default-settings.json"
logger.debug("loading settings from ", default_settings_file)
self.manager = Manager(SERVICE_NAME, SettingsV4, load=False)
self.manager.settings = self.manager.load_from_file(SettingsV4, default_settings_file)
self.manager = Manager(SERVICE_NAME, SettingsV5, load=False)
self.manager.settings = self.manager.load_from_file(SettingsV5, default_settings_file)
self.manager.save()

def load_service_types(self) -> Dict[str, ServiceTypes]:
Expand All @@ -119,6 +134,12 @@ def set_hostname(self, hostname: str) -> None:
case InterfaceType.HOTSPOT:
interface.domain_names = [f"{hostname}-hotspot"]
self.manager.save()
# if the hostname is changed and we have TLS enabled we need to regenerate the cert
if self.get_enable_tls():
os.unlink(TLS_KEY_PATH)
os.unlink(TLS_CERT_PATH)
self.generate_cert()
self.reload_nginx_config()

def get_hostname(self) -> str:
try:
Expand All @@ -133,6 +154,146 @@ def set_vehicle_name(self, name: str) -> None:
def get_vehicle_name(self) -> str:
return self.manager.settings.vehicle_name or "BlueROV2"

def get_enable_tls(self) -> bool:
# TODO: return what's in settings or assume no...this may change in the future
return self.manager.settings.use_tls or False

def set_enable_tls(self, enable_tls: bool) -> None:
# handle enabling/disabling tls
if not enable_tls and self.get_enable_tls():
# tls is currently enabled and we need to disable
# change nginx config
self.generate_new_nginx_config(use_tls=False)
# validate config
if not self.nginx_config_is_valid():
raise SystemError("Unable to validate staged Nginx config")
# bounce nginx
self.nginx_promote_config(keep_backup=True)
# remove old cert
os.unlink(TLS_CERT_PATH)
os.unlink(TLS_KEY_PATH)
elif enable_tls and not self.get_enable_tls():
# tls is currently disabled and we need to enable
# generate cert
self.generate_cert()
# change nginx config
self.generate_new_nginx_config(use_tls=True)
# validate config
if not self.nginx_config_is_valid():
raise SystemError("Unable to validate staged Nginx config")
# bounce nginx
self.nginx_promote_config(keep_backup=True)
self.manager.settings.use_tls = enable_tls
self.manager.save()

def generate_cert(self) -> None:
"""
Generates the TLS certificate for the current vehicle hostname and stores in persistent storage
"""
# get the hostname
current_hostname = self.get_hostname()
alt_names = []
alt_names.append(f"DNS:{current_hostname}")
alt_names.append(f"DNS:{current_hostname}-wifi")
alt_names.append(f"DNS:{current_hostname}-hotspot")
alt_names.append("IP:192.168.2.2")
matt-bathyscope marked this conversation as resolved.
Show resolved Hide resolved
alt_names.append("IP:192.168.3.1")

# shell out to openssl to get the cert
try:
subprocess.check_call(
[
"openssl",
"req",
"-x509",
"-newkey",
"rsa:4096",
"-sha256",
"-days",
"1825",
"-nodes",
"-keyout",
TLS_KEY_PATH,
"-out",
TLS_CERT_PATH,
"-subj",
shlex.quote(f"/CN={self.DEFAULT_HOSTNAME}"),
"-addext",
shlex.quote(f"subjectAltName={','.join(alt_names)}"),
],
shell=False,
)
except subprocess.CalledProcessError as ex:
raise SystemError("Unable to generate certificates") from ex

def generate_new_nginx_config(
self, config_path: str = os.path.join(NGINX_ROOT_PATH, "nginx.conf.ondeck"), use_tls: bool = False
) -> None:
"""
Generates a new nginx config file at the path specified
"""
# use the templates for simplicity now
# also, the templates are in core's tools directory but the live config lives in /etc/blueos/nginx
# TODO: the user may have changed the config, so we should parse and update as needed
if use_tls:
shutil.copy(
os.path.join(BLUEOS_TOOLS_NGINX_PATH, "nginx_tls.conf.template"), config_path, follow_symlinks=False
)
else:
shutil.copy(
os.path.join(BLUEOS_TOOLS_NGINX_PATH, "nginx.conf.template"), config_path, follow_symlinks=False
)

def nginx_config_is_valid(self, config_path: str = os.path.join(NGINX_ROOT_PATH, "nginx.conf.ondeck")) -> bool:
"""
Returns true if the nginx config file is valid
"""
try:
subprocess.check_call(["nginx", "-t", "-c", config_path], shell=False)
return True
except subprocess.CalledProcessError:
# got a non-zero return code indicating the config was not valid
return False

def nginx_promote_config(
self,
config_path: str = os.path.join(NGINX_ROOT_PATH, "nginx.conf"),
new_config_path: str = os.path.join(NGINX_ROOT_PATH, "nginx.conf.ondeck"),
keep_backup: bool = False,
) -> None:
"""
Moves the file at new_config_path to config_path and bounces nginx, optionally keeping a backup of config_path
"""
# do both files exist
if not os.path.exists(config_path):
raise FileNotFoundError("Old config not found")
if not os.path.isfile(new_config_path):
raise FileNotFoundError("New config not found")

if keep_backup:
shutil.copyfile(
config_path,
f"{config_path}_backup_{datetime.datetime.utcnow().strftime('%Y%m%d_%H%M%S')}",
follow_symlinks=False,
)

# move it
os.unlink(config_path)
os.rename(new_config_path, config_path)

# reload nginx config by getting the PID of the master process and sending a SIGHUP
self.reload_nginx_config()

def reload_nginx_config(self) -> None:
"""
Sends a SIGHUP to the nginx master process to trigger a reload of the running config
"""
if not os.path.exists(NGINX_PID_PATH):
raise SystemError("No nginx master PID found")
with open(NGINX_PID_PATH, "r", encoding="utf-8") as pidf:
nginx_pid = int(pidf.read())
os.kill(nginx_pid, signal.SIGHUP)

def create_async_service_infos(
self, interface: str, service_name: str, domain_name: str, ip: str
) -> AsyncServiceInfo:
Expand Down Expand Up @@ -281,6 +442,10 @@ def get_services() -> Any:
@app.post("/hostname", summary="Set the hostname for mDNS.")
@version(1, 0)
def set_hostname(hostname: str) -> Any:
# beacon.ts has a regex to validate hostname format, but we should check here too
hostname_regex = re.compile(r"^[a-zA-Z0-9-]+$")
if not hostname_regex.match(hostname):
raise ValueError("Invalid characters in hostname")
return beacon.set_hostname(hostname)


Expand Down Expand Up @@ -313,6 +478,18 @@ def get_ip(request: Request) -> Any:
return IpInfo(client_ip=request.scope["client"][0], interface_ip=request.scope["server"][0])


@app.get("/use_tls", summary="Get whether TLS should be enabled")
@version(1, 0)
def get_enable_tls() -> bool:
return beacon.get_enable_tls()


@app.post("/use_tls", summary="Set whether TLS should be enbabled")
@version(1, 0)
def set_enable_tls(enable_tls: bool) -> Any:
return beacon.set_enable_tls(enable_tls)


app = VersionedFastAPI(app, version="1.0.0", prefix_format="/v{major}.{minor}", enable_latest=True)


Expand Down
Loading