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 13 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
2 changes: 1 addition & 1 deletion bootstrap/bootstrap/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,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 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(() => 'False to change TLS configuration')
matt-bathyscope marked this conversation as resolved.
Show resolved Hide resolved
},
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
158 changes: 154 additions & 4 deletions core/services/beacon/main.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
#!/usr/bin/env python3
import argparse
import asyncio
import datetime
import itertools
import logging
import os
import pathlib
import shlex
import shutil
import signal
import socket
import subprocess
from typing import Any, Dict, List, Optional

import psutil
Expand All @@ -19,11 +25,14 @@
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"

TLS_CERT_PATH = "/etc/blueos/nginx/blueos.crt"
TLS_KEY_PATH = "/etc/blueos/nginx/blueos.key"


class AsyncRunner:
def __init__(self, ip_version: IPVersion, interface: str, interface_name: str) -> None:
Expand Down Expand Up @@ -78,7 +87,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 +104,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 Down Expand Up @@ -133,6 +142,135 @@ 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

# 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 = "/etc/blueos/nginx/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("/home/pi/tools/nginx/nginx_tls.conf.template", config_path, follow_symlinks=False)
else:
shutil.copy("/home/pi/tools/nginx/nginx.conf.template", config_path, follow_symlinks=False)

def nginx_config_is_valid(self, config_path: str = "/etc/blueos/nginx/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 = "/etc/blueos/nginx/nginx.conf",
new_config_path: str = "/etc/blueos/nginx/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
if not os.path.exists("/run/nginx.pid"):
raise SystemError("No nginx master PID found")
with open("/run/nginx.pid", "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 @@ -313,6 +451,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
19 changes: 19 additions & 0 deletions core/services/beacon/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from commonwealth.settings import settings
from loguru import logger
from pykson import (
BooleanField,
IntegerField,
JsonObject,
ListField,
Expand Down Expand Up @@ -200,3 +201,21 @@ def migrate(self, data: Dict[str, Any]) -> None:
super().migrate(data)

data["VERSION"] = SettingsV4.VERSION


class SettingsV5(SettingsV4):
VERSION = 5
use_tls = BooleanField()

def __init__(self, *args: str, **kwargs: int) -> None:
super().__init__(*args, **kwargs)
self.VERSION = SettingsV5.VERSION

def migrate(self, data: Dict[str, Any]) -> None:
if data["VERSION"] == SettingsV5.VERSION:
return

if data["VERSION"] < SettingsV5.VERSION:
super().migrate(data)

data["VERSION"] = SettingsV5.VERSION
9 changes: 8 additions & 1 deletion core/start-blueos-core
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ mkdir -p /usr/blueos/userdata/settings
find /usr/blueos -type d -exec chmod a+rw {} \;
find /usr/blueos -type f -exec chmod a+rw {} \;

# copy nginx configs over from $TOOLS_PATH to persistent storage if we don't already have one
if [ ! -d "/etc/blueos/nginx" ]; then
mkdir -p /etc/blueos/nginx
cp $TOOLS_PATH/nginx/nginx.conf /etc/blueos/nginx/nginx.conf
cp $TOOLS_PATH/nginx/cors.conf /etc/blueos/nginx/cors.conf
fi

# These services have priority because they do the fundamental for the vehicle to work,
# and by initializing them first we reduce the time users have to wait to control the vehicle.
# From tests with QGC and Pi3, the reboot time was ~1min42s when not using this strategy,
Expand Down Expand Up @@ -124,7 +131,7 @@ SERVICES=(
'ping',0,"nice -19 $RUN_AS_REGULAR_USER $SERVICES_PATH/ping/main.py"
'user_terminal',0,"cat /etc/motd"
'ttyd',250,'nice -19 ttyd -p 8088 sh -c "/usr/bin/tmux attach -t user_terminal || /usr/bin/tmux new -s user_terminal"'
'nginx',250,"nice -18 nginx -g \"daemon off;\" -c $TOOLS_PATH/nginx/nginx.conf"
'nginx',250,"nice -18 nginx -g \"daemon off;\" -c /etc/blueos/nginx/nginx.conf"
'log_zipper',250,"nice -20 $SERVICES_PATH/log_zipper/main.py '/shortcuts/system_logs/\\\\*\\\\*/\\\\*.log' --max-age-minutes 60"
'bag_of_holding',250,"$SERVICES_PATH/bag_of_holding/main.py"
)
Expand Down
1 change: 1 addition & 0 deletions core/tools/nginx/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ http {

server {
listen 80;
listen [::]:80;

add_header Access-Control-Allow-Origin *;

Expand Down
Loading