Add TLS to the UI

1.  Add the libraries needed to do arbitrary https
2.  Update the main protocol to HTTPS and WSS
3.  Moved the UI conf file to the etc dir in the tree

Fixes 54

Change-Id: I142366f053e73fb413291af458c8b5dcb9ab388a
This commit is contained in:
Schiefelbein, Andrew 2020-08-21 12:06:12 -05:00
parent c3a0184f6b
commit a155654a44
14 changed files with 292 additions and 342 deletions

4
.gitignore vendored
View File

@ -11,6 +11,10 @@ dist
/build
*.exe
# local conf files
etc/*.pem
etc/*.json
# Only exists if Bazel was run
/bazel-out

View File

@ -53,7 +53,7 @@ export class WebsocketService implements OnDestroy {
this.ws.close();
}
this.ws = new WebSocket('ws://localhost:8080/ws');
this.ws = new WebSocket('wss://localhost:10443/ws');
this.ws.onmessage = (event) => {
this.messageHandler(WebsocketService.messageToObject(event.data));

15
etc/airshipui.json.example Executable file
View File

@ -0,0 +1,15 @@
{
"webservice": {
"host": "<host>",
"port": <port>,
"publicKey": "<path>/<cert.pem>",
"privateKey": "<path>/<key.pem>"
},
"dashboards": [
{
"name": "Dash",
"baseURL": "https://<FQDN>",
"path": "/<example>/<path>"
}
]
}

View File

@ -17,7 +17,6 @@ package commands
import (
"os"
"os/signal"
"path/filepath"
"syscall"
"github.com/spf13/cobra"
@ -38,11 +37,22 @@ var rootCmd = &cobra.Command{
func init() {
// Add a 'version' command, in addition to the '--version' option that is auto created
rootCmd.AddCommand(newVersionCmd())
// Add the config file Flag
rootCmd.Flags().StringVarP(
&configs.UIConfigFile,
"conf",
"c",
"etc/airshipui.json",
"This will set the location of the conf file needed to start the UI",
)
// Add the logging level flag
rootCmd.Flags().IntVar(
&log.LogLevel,
"loglevel",
6,
"This well set the log level, anything at or below that level will be viewed, all others suppressed\n"+
"This will set the log level, anything at or below that level will be viewed, all others suppressed\n"+
" 6 -- Trace\n"+
" 5 -- Debug\n"+
" 4 -- Info\n"+
@ -53,16 +63,9 @@ func init() {
}
func launch(cmd *cobra.Command, args []string) {
// set default config path
// TODO: do we want to make this a flag that can be passed in?
airshipUIConfigPath, err := getDefaultConfigPath()
if err != nil {
log.Errorf("Error setting config path %s", err)
}
// Read AirshipUI config file
if err := configs.SetUIConfig(airshipUIConfigPath); err != nil {
log.Errorf("config %s", err)
if err := configs.SetUIConfig(); err != nil {
log.Fatalf("config %s", err)
}
// start webservice and listen for the the ctl + c to exit
@ -83,12 +86,3 @@ func Execute() {
os.Exit(1)
}
}
func getDefaultConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.FromSlash(home + "/.airship/airshipui.json"), nil
}

View File

@ -15,20 +15,28 @@
package configs
import (
"crypto/rsa"
"encoding/json"
"io/ioutil"
"os"
"path"
"path/filepath"
"opendev.org/airship/airshipctl/pkg/config"
"opendev.org/airship/airshipui/pkg/cryptography"
"opendev.org/airship/airshipui/pkg/log"
)
// variables related to UI config
var (
UIConfig Config
UIConfig Config
UIConfigFile string
etcDir *string
)
// Config basic structure to hold configuration params for Airship UI
type Config struct {
WebService *WebService `json:"webservice,omitempty"`
AuthMethod *AuthMethod `json:"authMethod,omitempty"`
Dashboards []Dashboard `json:"dashboards,omitempty"`
}
@ -40,6 +48,14 @@ type AuthMethod struct {
URL string `json:"url,omitempty"`
}
// WebService describes the things we need to know to start the web container
type WebService struct {
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
PublicKey string `json:"publicKey,omitempty"`
PrivateKey string `json:"privateKey,omitempty"`
}
// Dashboard structure
type Dashboard struct {
Name string `json:"name,omitempty"`
@ -111,11 +127,11 @@ type WsMessage struct {
// SetUIConfig sets the UIConfig object with values obtained from
// airshipui.json, located at 'filename'
// TODO: add watcher to the json file to reload conf on change
func SetUIConfig(filename string) error {
f, err := os.Open(filename)
// TODO: add watcher to the json file to reload conf on change (maybe not needed)
func SetUIConfig() error {
f, err := os.Open(UIConfigFile)
if err != nil {
return err
return checkConfigs()
}
defer f.Close()
@ -129,5 +145,101 @@ func SetUIConfig(filename string) error {
return err
}
return checkConfigs()
}
func checkConfigs() error {
if UIConfig.WebService == nil {
log.Debug("No UI config found, generating ssl keys & host & port info")
err := setEtcDir()
if err != nil {
return err
}
privateKeyFile := filepath.Join(*etcDir, "key.pem")
publicKeyFile := filepath.Join(*etcDir, "cert.pem")
err = writeTestSSL(privateKeyFile, publicKeyFile)
if err != nil {
return err
}
UIConfig.WebService = &WebService{
Host: "localhost",
Port: 10443,
PublicKey: publicKeyFile,
PrivateKey: privateKeyFile,
}
err = cryptography.TestCertValidity(publicKeyFile)
if err != nil {
return err
}
bytes, err := json.Marshal(UIConfig)
if err != nil {
return err
}
err = ioutil.WriteFile(UIConfigFile, bytes, 0440)
if err != nil {
return err
}
}
return nil
}
func writeTestSSL(privateKeyFile string, publicKeyFile string) error {
// get and write out private key
log.Warnf("Generating private key %s. DO NOT USE THIS FOR PRODUCTION", privateKeyFile)
privateKey, err := getAndWritePrivateKey(privateKeyFile)
if err != nil {
return err
}
// get and write out public key
log.Warnf("Generating public key %s. DO NOT USE THIS FOR PRODUCTION", publicKeyFile)
err = getAndWritePublicKey(publicKeyFile, privateKey)
if err != nil {
return err
}
return nil
}
func getAndWritePrivateKey(fileName string) (*rsa.PrivateKey, error) {
privateKeyBytes, privateKey, err := cryptography.GeneratePrivateKey()
if err != nil {
return nil, err
}
err = ioutil.WriteFile(fileName, privateKeyBytes, 0600)
if err != nil {
return nil, err
}
return privateKey, nil
}
func getAndWritePublicKey(fileName string, privateKey *rsa.PrivateKey) error {
publicKeyBytes, err := cryptography.GeneratePublicKey(privateKey)
if err != nil {
return err
}
err = ioutil.WriteFile(fileName, publicKeyBytes, 0600)
if err != nil {
return err
}
return nil
}
func setEtcDir() error {
if etcDir == nil {
dir, err := filepath.Abs(filepath.Dir(os.Args[0]))
if err != nil {
return err
}
dir, err = filepath.Abs(filepath.Join(path.Dir(dir), "etc"))
if err != nil {
return err
}
etcDir = &dir
}
return nil
}

View File

@ -1,64 +0,0 @@
/*
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package configs
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
fakeFile string = "/fake/config/path"
testFile string = "testdata/airshipui.json"
invalidTestFile string = "testdata/airshipui_invalid.json"
)
// DummyDashboardsConfig returns an array of populated Dashboard structs
func dummyDashboardsConfig() []Dashboard {
return []Dashboard{
{
Name: "dummy_dashboard",
BaseURL: "http://dummyhost",
Path: "fake/login/path",
},
}
}
func dummyAuthMethodConfig() *AuthMethod {
return &AuthMethod{
URL: "http://fake.auth.method.com/auth",
}
}
func TestSetUIConfig(t *testing.T) {
conf := Config{
Dashboards: dummyDashboardsConfig(),
AuthMethod: dummyAuthMethodConfig(),
}
err := SetUIConfig(testFile)
require.NoError(t, err)
assert.Equal(t, conf, UIConfig)
err = SetUIConfig(invalidTestFile)
require.Error(t, err)
}
func TestFileNotFound(t *testing.T) {
err := SetUIConfig(fakeFile)
assert.Error(t, err)
}

View File

@ -1,12 +0,0 @@
{
"authMethod": {
"url": "http://fake.auth.method.com/auth"
},
"dashboards": [
{
"name": "dummy_dashboard",
"baseURL": "http://dummyhost",
"path": "fake/login/path"
}
]
}

View File

@ -1,6 +0,0 @@
{
"authMethod": {
"url": "http://fake.auth.method.com/auth"
},
"dashboards": [],
}

View File

@ -1,73 +0,0 @@
apiVersion: airshipit.org/v1alpha1
bootstrapInfo:
default:
builder:
networkConfigFileName: network-config
outputMetadataFileName: output-metadata.yaml
userDataFileName: user-data
container:
containerRuntime: docker
image: quay.io/airshipit/isogen:latest-debian_stable
volume: /srv/iso:/config
remoteDirect:
isoUrl: http://localhost:8099/debian-custom.iso
dummy_bootstrap_config:
builder:
networkConfigFileName: netconfig
outputMetadataFileName: output-metadata.yaml
userDataFileName: user-data
container:
containerRuntime: docker
image: dummy_image:dummy_tag
volume: /dummy:dummy
clusters:
kubernetes:
clusterType:
target:
bootstrapInfo: default
clusterKubeconf: kubernetes_target
managementConfiguration: default
contexts:
admin@kubernetes:
contextKubeconf: kubernetes_target
currentContext: admin@kubernetes
kind: Config
managementConfiguration:
default:
insecure: true
systemActionRetries: 30
systemRebootDelay: 30
type: redfish
dummy_management_config:
insecure: true
type: redfish
manifests:
default:
primaryRepositoryName: primary
repositories:
primary:
checkout:
branch: master
commitHash: ""
force: false
tag: ""
url: https://opendev.org/airship/treasuremap
subPath: treasuremap/manifests/site
targetPath: /tmp/default
dummy_manifest:
primaryRepositoryName: primary
repositories:
primary:
auth:
sshKey: testdata/test-key.pem
type: ssh-key
checkout:
branch: ""
commitHash: ""
force: false
tag: v1.0.1
url: http://dummy.url.com/manifests.git
subPath: manifests/site/test-site
targetPath: /var/tmp/
users:
admin: {}

View File

@ -1,19 +0,0 @@
apiVersion: v1
clusters:
- cluster:
certificate-authority: pki/cluster-ca.pem
server: https://10.0.0.1:6553
name: kubernetes_target
contexts:
- context:
cluster: kubernetes_target
user: admin
name: admin@kubernetes
current-context: admin@kubernetes
kind: Config
preferences: {}
users:
- name: admin
user:
client-certificate: pki/admin.pem
client-key: pki/admin-key.pem

110
pkg/cryptography/cryptography.go Executable file
View File

@ -0,0 +1,110 @@
/*
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package cryptography
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"io/ioutil"
"math/big"
"time"
"opendev.org/airship/airshipui/pkg/log"
)
const (
keySize = 4096 // 4k key
)
// GeneratePrivateKey will a pem encoded private key and an rsa private key object
func GeneratePrivateKey() ([]byte, *rsa.PrivateKey, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, keySize)
if err != nil {
log.Error("Problem generating private key", err)
return nil, nil, err
}
buf := &bytes.Buffer{}
err = pem.Encode(buf, &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
})
if err != nil {
log.Error("Problem generating private key pem", err)
return nil, nil, err
}
return buf.Bytes(), privateKey, nil
}
// GeneratePublicKey will create a pem encoded cert
func GeneratePublicKey(privateKey *rsa.PrivateKey) ([]byte, error) {
template := generateCSR()
derCert, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return nil, err
}
buf := &bytes.Buffer{}
err = pem.Encode(buf, &pem.Block{
Type: "CERTIFICATE",
Bytes: derCert,
})
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// generateCSR creates the base information needed to create the certificate
func generateCSR() x509.Certificate {
return x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "localhost",
Organization: []string{"Airship UI"},
},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1, 0, 0),
BasicConstraintsValid: true,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
}
}
// TestCertValidity will check if the cert defined in the conf is not past its not after date
func TestCertValidity(pemFile string) error {
r, err := ioutil.ReadFile(pemFile)
if err != nil {
log.Error(err)
return err
}
block, _ := pem.Decode(r)
_, err = x509.ParseCertificate(block.Bytes)
if err != nil {
log.Error(err)
return err
}
// calculate the validity of the cert
// TODO: Add a cert check for time based validity here
// fmt.Println(cert.NotAfter)
return nil
}

View File

@ -12,31 +12,35 @@
limitations under the License.
*/
package webservice
package cryptography
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
serverAddr string = "localhost:8080"
)
func init() {
go WebServer()
// wait for the webserver to come up
time.Sleep(250 * time.Millisecond)
}
func TestRootURI(t *testing.T) {
resp, err := http.Get("http://" + serverAddr)
func TestGeneratePrivateKey(t *testing.T) {
pem, key, err := GeneratePrivateKey()
require.NoError(t, err)
defer resp.Body.Close()
// this will be not found because of where the webservice starts
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
require.NotNil(t, key)
require.NotNil(t, pem)
}
func TestGeneratePublicKey(t *testing.T) {
_, privateKey, err := GeneratePrivateKey()
require.NoError(t, err)
cert, err := GeneratePublicKey(privateKey)
require.NoError(t, err)
require.NotNil(t, cert)
}
func TestTestCertValidity(t *testing.T) {
_, privateKey, err := GeneratePrivateKey()
require.NoError(t, err)
cert, err := GeneratePublicKey(privateKey)
require.NoError(t, err)
require.NotNil(t, cert)
}

View File

@ -16,6 +16,7 @@ package webservice
import (
"net/http"
"strconv"
"github.com/pkg/errors"
"opendev.org/airship/airshipui/pkg/configs"
@ -85,7 +86,11 @@ func WebServer() {
// start proxies for web based use
startProxies()
// TODO: pull ports out into conf files
log.Info("Attempting to start webservice on localhost:8080")
log.Fatal(http.ListenAndServe(":8080", webServerMux))
// Calculate the address and start on the host and port specified in the config
addr := configs.UIConfig.WebService.Host + ":" + strconv.Itoa(configs.UIConfig.WebService.Port)
log.Infof("Attempting to start webservice on %s", addr)
log.Fatal(http.ListenAndServeTLS(addr,
configs.UIConfig.WebService.PublicKey,
configs.UIConfig.WebService.PrivateKey,
webServerMux))
}

View File

@ -1,120 +0,0 @@
/*
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package webservice
import (
"encoding/json"
"net/url"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"opendev.org/airship/airshipui/pkg/configs"
"opendev.org/airship/airshipui/pkg/log"
)
const (
// client messages
keepalive string = `{"type":"ui","component":"keepalive"}`
unknownType string = `{"type":"fake_type","component":"initialize"}`
unknownComponent string = `{"type":"ui","component":"fake_component"}`
)
var client *websocket.Conn
func init() {
u := url.URL{Scheme: "ws", Host: serverAddr, Path: "/ws"}
var err error
client, _, err = websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
log.Fatal(err)
}
time.Sleep(10 * time.Millisecond)
// get server response to "initialize" message from client which is sent by default
var response configs.WsMessage
err = client.ReadJSON(&response)
if err != nil {
log.Fatal(err)
}
}
func TestKeepalive(t *testing.T) {
// get server response to "keepalive" message from client
response, err := getResponse(keepalive)
require.NoError(t, err)
expected := configs.WsMessage{
SessionID: response.SessionID,
Type: configs.UI,
Component: configs.Keepalive,
// don't fail on timestamp diff
Timestamp: response.Timestamp,
}
assert.Equal(t, expected, response)
}
func TestUnknownType(t *testing.T) {
response, err := getResponse(unknownType)
require.NoError(t, err)
expected := configs.WsMessage{
SessionID: response.SessionID,
Type: "fake_type",
Component: configs.Initialize,
// don't fail on timestamp diff
Timestamp: response.Timestamp,
Error: "Requested type: fake_type, not found",
}
assert.Equal(t, expected, response)
}
func TestUnknownComponent(t *testing.T) {
response, err := getResponse(unknownComponent)
require.NoError(t, err)
expected := configs.WsMessage{
SessionID: response.SessionID,
Type: configs.UI,
Component: "fake_component",
// don't fail on timestamp diff
Timestamp: response.Timestamp,
Error: "Requested component: fake_component, not found",
}
assert.Equal(t, expected, response)
}
func getResponse(message string) (configs.WsMessage, error) {
err := client.WriteJSON(json.RawMessage(message))
time.Sleep(50 * time.Millisecond)
if err != nil {
return configs.WsMessage{}, err
}
var response configs.WsMessage
err = client.ReadJSON(&response)
if err != nil {
return configs.WsMessage{}, err
}
return response, nil
}