From a155654a44a2655ca039d1d64b82625d7bc525ec Mon Sep 17 00:00:00 2001 From: "Schiefelbein, Andrew" Date: Fri, 21 Aug 2020 12:06:12 -0500 Subject: [PATCH] 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 --- .gitignore | 4 + .../services/websocket/websocket.service.ts | 2 +- etc/airshipui.json.example | 15 +++ pkg/commands/root.go | 34 ++--- pkg/configs/configs.go | 122 +++++++++++++++++- pkg/configs/configs_test.go | 64 --------- pkg/configs/testdata/airshipui.json | 12 -- pkg/configs/testdata/airshipui_invalid.json | 6 - pkg/configs/testdata/config.yaml | 73 ----------- pkg/configs/testdata/kubeconfig.yaml | 19 --- pkg/cryptography/cryptography.go | 110 ++++++++++++++++ .../cryptography_test.go} | 42 +++--- pkg/webservice/server.go | 11 +- pkg/webservice/websocket_test.go | 120 ----------------- 14 files changed, 292 insertions(+), 342 deletions(-) create mode 100755 etc/airshipui.json.example delete mode 100644 pkg/configs/configs_test.go delete mode 100644 pkg/configs/testdata/airshipui.json delete mode 100644 pkg/configs/testdata/airshipui_invalid.json delete mode 100644 pkg/configs/testdata/config.yaml delete mode 100644 pkg/configs/testdata/kubeconfig.yaml create mode 100755 pkg/cryptography/cryptography.go rename pkg/{webservice/server_test.go => cryptography/cryptography_test.go} (52%) mode change 100644 => 100755 delete mode 100644 pkg/webservice/websocket_test.go diff --git a/.gitignore b/.gitignore index e99be82..071dada 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ dist /build *.exe +# local conf files +etc/*.pem +etc/*.json + # Only exists if Bazel was run /bazel-out diff --git a/client/src/services/websocket/websocket.service.ts b/client/src/services/websocket/websocket.service.ts index e9766e6..1f2c1cb 100644 --- a/client/src/services/websocket/websocket.service.ts +++ b/client/src/services/websocket/websocket.service.ts @@ -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)); diff --git a/etc/airshipui.json.example b/etc/airshipui.json.example new file mode 100755 index 0000000..20ad5ac --- /dev/null +++ b/etc/airshipui.json.example @@ -0,0 +1,15 @@ +{ + "webservice": { + "host": "", + "port": , + "publicKey": "/", + "privateKey": "/" + }, + "dashboards": [ + { + "name": "Dash", + "baseURL": "https://", + "path": "//" + } + ] +} \ No newline at end of file diff --git a/pkg/commands/root.go b/pkg/commands/root.go index bab5a1c..eae2f4f 100644 --- a/pkg/commands/root.go +++ b/pkg/commands/root.go @@ -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 -} diff --git a/pkg/configs/configs.go b/pkg/configs/configs.go index 9d50600..564a897 100644 --- a/pkg/configs/configs.go +++ b/pkg/configs/configs.go @@ -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 } diff --git a/pkg/configs/configs_test.go b/pkg/configs/configs_test.go deleted file mode 100644 index ffc01cd..0000000 --- a/pkg/configs/configs_test.go +++ /dev/null @@ -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) -} diff --git a/pkg/configs/testdata/airshipui.json b/pkg/configs/testdata/airshipui.json deleted file mode 100644 index eae4084..0000000 --- a/pkg/configs/testdata/airshipui.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "authMethod": { - "url": "http://fake.auth.method.com/auth" - }, - "dashboards": [ - { - "name": "dummy_dashboard", - "baseURL": "http://dummyhost", - "path": "fake/login/path" - } - ] -} diff --git a/pkg/configs/testdata/airshipui_invalid.json b/pkg/configs/testdata/airshipui_invalid.json deleted file mode 100644 index 9ed07f0..0000000 --- a/pkg/configs/testdata/airshipui_invalid.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "authMethod": { - "url": "http://fake.auth.method.com/auth" - }, - "dashboards": [], -} diff --git a/pkg/configs/testdata/config.yaml b/pkg/configs/testdata/config.yaml deleted file mode 100644 index 41f85a6..0000000 --- a/pkg/configs/testdata/config.yaml +++ /dev/null @@ -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: {} diff --git a/pkg/configs/testdata/kubeconfig.yaml b/pkg/configs/testdata/kubeconfig.yaml deleted file mode 100644 index 20428d8..0000000 --- a/pkg/configs/testdata/kubeconfig.yaml +++ /dev/null @@ -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 diff --git a/pkg/cryptography/cryptography.go b/pkg/cryptography/cryptography.go new file mode 100755 index 0000000..4903bcc --- /dev/null +++ b/pkg/cryptography/cryptography.go @@ -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 +} diff --git a/pkg/webservice/server_test.go b/pkg/cryptography/cryptography_test.go old mode 100644 new mode 100755 similarity index 52% rename from pkg/webservice/server_test.go rename to pkg/cryptography/cryptography_test.go index 93eaab7..abd8010 --- a/pkg/webservice/server_test.go +++ b/pkg/cryptography/cryptography_test.go @@ -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) } diff --git a/pkg/webservice/server.go b/pkg/webservice/server.go index 1c488f7..79ec641 100755 --- a/pkg/webservice/server.go +++ b/pkg/webservice/server.go @@ -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)) } diff --git a/pkg/webservice/websocket_test.go b/pkg/webservice/websocket_test.go deleted file mode 100644 index d0c53dc..0000000 --- a/pkg/webservice/websocket_test.go +++ /dev/null @@ -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 -}