diff --git a/cmd/kubediscovery/kubediscovery.go b/cmd/kubediscovery/kubediscovery.go new file mode 100644 index 00000000000..fb74fde3344 --- /dev/null +++ b/cmd/kubediscovery/kubediscovery.go @@ -0,0 +1,41 @@ +/* +Copyright 2016 The Kubernetes Authors. +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 + http://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 main + +import ( + "log" + "net/http" + "os" + + kd "k8s.io/kubernetes/pkg/kubediscovery" +) + +func main() { + + // Make sure the CA cert for the cluster exists and is readable. + // We are expecting a base64 encoded version of the cert PEM as this is how + // the cert would most likely be provided via kubernetes secrets. + if _, err := os.Stat(kd.CAPath); os.IsNotExist(err) { + log.Fatalf("CA does not exist: %s", kd.CAPath) + } + // Test read permissions + file, err := os.Open(kd.CAPath) + if err != nil { + log.Fatalf("ERROR: Unable to read %s", kd.CAPath) + } + file.Close() + + router := kd.NewRouter() + log.Printf("Listening for requests on port 9898.") + log.Fatal(http.ListenAndServe(":9898", router)) +} diff --git a/discovery/Dockerfile b/discovery/Dockerfile new file mode 100644 index 00000000000..2e500ec31b2 --- /dev/null +++ b/discovery/Dockerfile @@ -0,0 +1,6 @@ +FROM golang + +ADD kubediscovery /usr/bin/ +ENTRYPOINT /usr/bin/kubernetes-discovery + +EXPOSE 8080 diff --git a/discovery/README.md b/discovery/README.md new file mode 100644 index 00000000000..3ade93b4a18 --- /dev/null +++ b/discovery/README.md @@ -0,0 +1,39 @@ +# kubernetes-discovery +An initial implementation of a Kubernetes discovery service using JSON Web Signatures. + +This prototype is expected to be run by Kubernetes itself for the time being, +and will hopefully be merged into the core API at a later time. + +## Requirements + +Generate a CA cert save it to: /tmp/secret/ca.pem to run the service or unit tests. (will not be required for unit tests for long) Similarly when run within kubernetes we expect a secret to be provided at this location as well. (see below) + +## Build And Run From Source + +``` +$ make WHAT=cmd/kubediscovery +$ _output/local/bin/linux/amd64/kubediscovery +2016/08/23 19:17:28 Listening for requests on port 9898. + +``` + +## Running in Docker + +This image is published temporarily on Docker Hub as dgoodwin/kubediscovery + +`docker run --rm -p 9898:9898 -v /tmp/secret/ca.pem:/tmp/secret/ca.pem --name kubediscovery dgoodwin/kubediscovery` + +## Running in Kubernetes + +A dummy certificate is included in ca-secret.yaml. + +``` +kubectl create -f ca-secret.yaml +kubectl create -f kubediscovery.yaml +``` + +## Testing the API + +`curl "http://localhost:9898/cluster-info/v1/?token-id=TOKENID"` + +You should see JSON containing a signed payload. For code to verify and decode that payload see handler_test.go. diff --git a/discovery/ca-secret.yaml b/discovery/ca-secret.yaml new file mode 100644 index 00000000000..f0811c342fa --- /dev/null +++ b/discovery/ca-secret.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Secret +metadata: + name: ca-secret +type: Opaque +data: + ca.pem: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUR4RENDQXF5Z0F3SUJBZ0lVV3pqUDl5RUk0eHlRSnBzVHVERU4yV2ROaUFzd0RRWUpLb1pJaHZjTkFRRUwKQlFBd2FERUxNQWtHQTFVRUJoTUNWVk14RHpBTkJnTlZCQWdUQms5eVpXZHZiakVSTUE4R0ExVUVCeE1JVUc5eQpkR3hoYm1ReEV6QVJCZ05WQkFvVENrdDFZbVZ5Ym1WMFpYTXhDekFKQmdOVkJBc1RBa05CTVJNd0VRWURWUVFECkV3cExkV0psY201bGRHVnpNQjRYRFRFMk1EZ3hNVEUyTkRnd01Gb1hEVEl4TURneE1ERTJORGd3TUZvd2FERUwKTUFrR0ExVUVCaE1DVlZNeER6QU5CZ05WQkFnVEJrOXlaV2R2YmpFUk1BOEdBMVVFQnhNSVVHOXlkR3hoYm1ReApFekFSQmdOVkJBb1RDa3QxWW1WeWJtVjBaWE14Q3pBSkJnTlZCQXNUQWtOQk1STXdFUVlEVlFRREV3cExkV0psCmNtNWxkR1Z6TUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF3QkhNOGN6anc0Q1cKK05wbklhV012RzZlcVhtelNZT20vbHdaNUhOMnVLck9xaTNHYUUyTjFKd2tzcGRmMXNOUGFZMHdPR2xkbURIZgoxSnlyTW8rUFdLVUVjWko1WGE4Vm02d2I0MlpjczN3MEp5dlEzWFJjaDQyMFJRWGRKayszcmMybWRvSVRkL0lmCnZjWms0N0RzQTMrQU5QSUlSTzdWRmZpS1JNRFpTUDR1OThnVjI2eW1zbjc0TzFVKzNVUHR1TEFTVTFLck9FTk4KR01FWG0ydTJpdmVvbTJrbjFlZTZuM1hCR1o2bU52cUNPdWUxRXdza0gvWkhoUVh1UDgyV1U5dVk0aGVORnoyQwpBNmR0Q0Q0c3Z6eHc3ZFQ2cVhsV0ZIWUYrc3VLVDhXNkczd3NkOWxzV0ZVY0ZWL0lwaTVobEVaTWprNFNoY3RqCjdpYnlrRURKM1FJREFRQUJvMll3WkRBT0JnTlZIUThCQWY4RUJBTUNBUVl3RWdZRFZSMFRBUUgvQkFnd0JnRUIKL3dJQkFqQWRCZ05WSFE0RUZnUVVOdnhRZ3o5ZTNXS2VscU1KTmZXNE1KUHYzc0V3SHdZRFZSMGpCQmd3Rm9BVQpOdnhRZ3o5ZTNXS2VscU1KTmZXNE1KUHYzc0V3RFFZSktvWklodmNOQVFFTEJRQURnZ0VCQUp1TUhYUms1TEVyCmxET1p4Mm9aRUNQZ29reXMzSGJsM05oempXd2pncXdxNVN6a011V3QrUnVkdnRTK0FUQjFtTjRjYTN0eSt2bWcKT09heTkvaDZoditmSE5jZHpYdWR5dFZYZW1KN3F4ZFoxd25DUUcwdnpqOWRZY0xFSGpJWi94dU1jNlY3dnJ4YwpSc0preGp5aE01UXBmRHd0eVZKeGpkUmVBZ0huSyswTkNieHdtQ3cyRGIvOXpudm9LWGk4TEQwbkQzOFQxY3R3CmhmdGxwTmRoZXFNRlpEZXBuTUYwY2g2cHo5TFV5Mkh1cnhrV2dkWVNjY2VNU0hPTzBMcG4xeVVBMWczOTJhUjUKWk81Zm5KMW95Vm1LVWFCeDJCMndsSVlUSXlES1ZiMnY1UXNHbnYvRHVTMDZhcmVLTmsvTGpHRTRlMXlHOHJkcwpacnZHMzNvUmtEbz0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= diff --git a/discovery/kubediscovery.yaml b/discovery/kubediscovery.yaml new file mode 100644 index 00000000000..feaa39de540 --- /dev/null +++ b/discovery/kubediscovery.yaml @@ -0,0 +1,37 @@ +apiVersion: extensions/v1beta1 +kind: ReplicaSet +metadata: + name: kubediscovery + # these labels can be applied automatically + # from the labels in the pod template if not set + # labels: + # app: guestbook + # tier: frontend +spec: + # this replicas value is default + # modify it according to your case + replicas: 1 + # selector can be applied automatically + # from the labels in the pod template if not set, + # but we are specifying the selector here to + # demonstrate its usage. + template: + metadata: + labels: + app: kubediscovery + spec: + hostNetwork: true + containers: + - name: kubediscovery + image: dgoodwin/kubediscovery + imagePullPolicy: IfNotPresent + ports: + - containerPort: 9898 + volumeMounts: + - name: ca-secret-vol + mountPath: /tmp/secret + readOnly: true + volumes: + - name: ca-secret-vol + secret: + secretName: ca-secret diff --git a/pkg/kubediscovery/handlers.go b/pkg/kubediscovery/handlers.go new file mode 100644 index 00000000000..15ad86386d8 --- /dev/null +++ b/pkg/kubediscovery/handlers.go @@ -0,0 +1,165 @@ +/* +Copyright 2016 The Kubernetes Authors. +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 + http://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 kubediscovery + +import ( + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + + "github.com/square/go-jose" +) + +// TODO: Just using a hardcoded token for now. +const tempTokenId string = "TOKENID" +const tempToken string = "EF1BA4F26DDA9FE2" + +// CAPath is the expected location of our cluster's CA to be distributed to +// clients looking to connect. Because we expect to use kubernetes secrets +// for the time being, this file is expected to be a base64 encoded version +// of the normal cert PEM. +const CAPath = "/tmp/secret/ca.pem" + +// tokenLoader is an interface for abstracting how we validate +// token IDs and lookup their corresponding token. +type tokenLoader interface { + // Lookup returns the token for a given token ID, or an error if the token ID + // does not exist. Both token and it's ID are expected to be hex encoded strings. + Lookup(tokenId string) (string, error) +} + +type hardcodedTokenLoader struct { +} + +func (tl *hardcodedTokenLoader) Lookup(tokenId string) (string, error) { + if tokenId == tempTokenId { + return tempToken, nil + } + return "", errors.New(fmt.Sprintf("invalid token: %s", tokenId)) +} + +// caLoader is an interface for abstracting how we load the CA certificates +// for the cluster. +type caLoader interface { + LoadPEM() (string, error) +} + +// fsCALoader is a caLoader for loading the PEM encoded CA from +// /tmp/secret/ca.pem. +type fsCALoader struct { +} + +func (cl *fsCALoader) LoadPEM() (string, error) { + file, err := os.Open(CAPath) + if err != nil { + return "", err + } + + data, err := ioutil.ReadAll(file) + if err != nil { + return "", err + } + + return string(data), nil +} + +// ClusterInfoHandler implements the http.ServeHTTP method and allows us to +// mock out portions of the request handler in tests. +type ClusterInfoHandler struct { + tokenLoader tokenLoader + caLoader caLoader +} + +func NewClusterInfoHandler() *ClusterInfoHandler { + tl := hardcodedTokenLoader{} + cl := fsCALoader{} + return &ClusterInfoHandler{ + tokenLoader: &tl, + caLoader: &cl, + } +} + +func (cih *ClusterInfoHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) { + tokenId := req.FormValue("token-id") + log.Printf("Got token ID: %s", tokenId) + token, err := cih.tokenLoader.Lookup(tokenId) + if err != nil { + log.Printf("Invalid token: %s", err) + http.Error(resp, "Forbidden", http.StatusForbidden) + return + } + log.Printf("Loaded token: %s", token) + + caPEM, err := cih.caLoader.LoadPEM() + caB64 := base64.StdEncoding.EncodeToString([]byte(caPEM)) + + if err != nil { + http.Error(resp, "Error encoding CA", http.StatusInternalServerError) + return + } + + clusterInfo := ClusterInfo{ + Type: "ClusterInfo", + Version: "v1", + RootCertificates: caB64, + } + + // Instantiate an signer using HMAC-SHA256. + hmacTestKey := fromHexBytes(token) + signer, err := jose.NewSigner(jose.HS256, hmacTestKey) + if err != nil { + http.Error(resp, fmt.Sprintf("Error creating JWS signer: %s", err), http.StatusInternalServerError) + return + } + + payload, err := json.Marshal(clusterInfo) + if err != nil { + http.Error(resp, fmt.Sprintf("Error serializing clusterInfo to JSON: %s", err), + http.StatusInternalServerError) + return + } + + // Sign a sample payload. Calling the signer returns a protected JWS object, + // which can then be serialized for output afterwards. An error would + // indicate a problem in an underlying cryptographic primitive. + jws, err := signer.Sign(payload) + if err != nil { + http.Error(resp, fmt.Sprintf("Error signing clusterInfo to JSON: %s", err), + http.StatusInternalServerError) + return + } + + // Serialize the encrypted object using the full serialization format. + // Alternatively you can also use the compact format here by calling + // object.CompactSerialize() instead. + serialized := jws.FullSerialize() + + resp.Write([]byte(serialized)) + +} + +// TODO: Move into test package +// TODO: Should we use base64 instead? +func fromHexBytes(base16 string) []byte { + val, err := hex.DecodeString(base16) + if err != nil { + panic(fmt.Sprintf("Invalid test data: %s", err)) + } + return val +} diff --git a/pkg/kubediscovery/handlers_test.go b/pkg/kubediscovery/handlers_test.go new file mode 100644 index 00000000000..2374fef911b --- /dev/null +++ b/pkg/kubediscovery/handlers_test.go @@ -0,0 +1,100 @@ +/* +Copyright 2016 The Kubernetes Authors. +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 + http://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 kubediscovery + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/square/go-jose" +) + +func TestClusterInfoIndex(t *testing.T) { + tests := map[string]struct { + url string + expStatus int + }{ + "no token": { + "/cluster-info/v1/", + http.StatusForbidden, + }, + "valid token": { + fmt.Sprintf("/cluster-info/v1/?token-id=%s", tempTokenId), + http.StatusOK, + }, + "invalid token": { + "/cluster-info/v1/?token-id=JUNK", + http.StatusForbidden, + }, + } + + for name, test := range tests { + t.Logf("Running test: %s", name) + // Create a request to pass to our handler. We don't have any query parameters for now, so we'll + // pass 'nil' as the third parameter. + req, err := http.NewRequest("GET", test.url, nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + // TODO: mock/stub here + handler := NewClusterInfoHandler() + + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != test.expStatus { + t.Errorf("handler returned wrong status code: got %v want %v", + status, test.expStatus) + continue + } + + // If we were expecting valid status validate the body: + if test.expStatus == http.StatusOK { + var ci ClusterInfo + + body := string(rr.Body.Bytes()) + + // Parse the JSON web signature: + jws, err := jose.ParseSigned(body) + if err != nil { + t.Errorf("Error parsing JWS from request body: %s", err) + continue + } + + // Now we can verify the signature on the payload. An error here would + // indicate the the message failed to verify, e.g. because the signature was + // broken or the message was tampered with. + var clusterInfoBytes []byte + hmacTestKey := fromHexBytes(tempToken) + clusterInfoBytes, err = jws.Verify(hmacTestKey) + if err != nil { + t.Errorf("Error verifing signature: %s", err) + continue + } + + err = json.Unmarshal(clusterInfoBytes, &ci) + if err != nil { + t.Errorf("Unable to unmarshall payload to JSON: error=%s body=%s", err, rr.Body.String()) + continue + } + if ci.RootCertificates == "" { + t.Error("No root certificates in response") + continue + } + } + } +} diff --git a/pkg/kubediscovery/model.go b/pkg/kubediscovery/model.go new file mode 100644 index 00000000000..41234ec91f7 --- /dev/null +++ b/pkg/kubediscovery/model.go @@ -0,0 +1,21 @@ +/* +Copyright 2016 The Kubernetes Authors. +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 + http://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 kubediscovery + +// TODO: Sync with kubeadm api type +type ClusterInfo struct { + Type string + Version string + RootCertificates string `json:"rootCertificates"` + // TODO: ClusterID, Endpoints +} diff --git a/pkg/kubediscovery/routes.go b/pkg/kubediscovery/routes.go new file mode 100644 index 00000000000..c0df303b3db --- /dev/null +++ b/pkg/kubediscovery/routes.go @@ -0,0 +1,51 @@ +/* +Copyright 2016 The Kubernetes Authors. +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 + http://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 kubediscovery + +import ( + "net/http" + + "github.com/gorilla/mux" +) + +type Route struct { + Name string + Method string + Pattern string + Handler http.Handler +} + +type Routes []Route + +var routes = Routes{ + Route{ + "ClusterInfoIndex", + "GET", + "/cluster-info/v1/", + NewClusterInfoHandler(), + }, +} + +func NewRouter() *mux.Router { + + router := mux.NewRouter().StrictSlash(true) + for _, route := range routes { + router. + Methods(route.Method). + Path(route.Pattern). + Name(route.Name). + Handler(route.Handler) + } + + return router +}