Add standalone kube-discovery JWS discovery API.

This is a standalone pod which will be configured by kubeadm for the
time being. A token ID/token map, endpoints list, and CA cert are
provided as secrets.

Callers request the cluster info by shared secret (token ID), and if the
token ID matches a JWS signed payload is returned using the other half
of the shared secret to validate.
This commit is contained in:
Devan Goodwin 2016-08-23 15:29:40 -03:00
parent b841a8bad3
commit d17a236af3
9 changed files with 467 additions and 0 deletions

View File

@ -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))
}

6
discovery/Dockerfile Normal file
View File

@ -0,0 +1,6 @@
FROM golang
ADD kubediscovery /usr/bin/
ENTRYPOINT /usr/bin/kubernetes-discovery
EXPOSE 8080

39
discovery/README.md Normal file
View File

@ -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.

7
discovery/ca-secret.yaml Normal file
View File

@ -0,0 +1,7 @@
apiVersion: v1
kind: Secret
metadata:
name: ca-secret
type: Opaque
data:
ca.pem: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUR4RENDQXF5Z0F3SUJBZ0lVV3pqUDl5RUk0eHlRSnBzVHVERU4yV2ROaUFzd0RRWUpLb1pJaHZjTkFRRUwKQlFBd2FERUxNQWtHQTFVRUJoTUNWVk14RHpBTkJnTlZCQWdUQms5eVpXZHZiakVSTUE4R0ExVUVCeE1JVUc5eQpkR3hoYm1ReEV6QVJCZ05WQkFvVENrdDFZbVZ5Ym1WMFpYTXhDekFKQmdOVkJBc1RBa05CTVJNd0VRWURWUVFECkV3cExkV0psY201bGRHVnpNQjRYRFRFMk1EZ3hNVEUyTkRnd01Gb1hEVEl4TURneE1ERTJORGd3TUZvd2FERUwKTUFrR0ExVUVCaE1DVlZNeER6QU5CZ05WQkFnVEJrOXlaV2R2YmpFUk1BOEdBMVVFQnhNSVVHOXlkR3hoYm1ReApFekFSQmdOVkJBb1RDa3QxWW1WeWJtVjBaWE14Q3pBSkJnTlZCQXNUQWtOQk1STXdFUVlEVlFRREV3cExkV0psCmNtNWxkR1Z6TUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF3QkhNOGN6anc0Q1cKK05wbklhV012RzZlcVhtelNZT20vbHdaNUhOMnVLck9xaTNHYUUyTjFKd2tzcGRmMXNOUGFZMHdPR2xkbURIZgoxSnlyTW8rUFdLVUVjWko1WGE4Vm02d2I0MlpjczN3MEp5dlEzWFJjaDQyMFJRWGRKayszcmMybWRvSVRkL0lmCnZjWms0N0RzQTMrQU5QSUlSTzdWRmZpS1JNRFpTUDR1OThnVjI2eW1zbjc0TzFVKzNVUHR1TEFTVTFLck9FTk4KR01FWG0ydTJpdmVvbTJrbjFlZTZuM1hCR1o2bU52cUNPdWUxRXdza0gvWkhoUVh1UDgyV1U5dVk0aGVORnoyQwpBNmR0Q0Q0c3Z6eHc3ZFQ2cVhsV0ZIWUYrc3VLVDhXNkczd3NkOWxzV0ZVY0ZWL0lwaTVobEVaTWprNFNoY3RqCjdpYnlrRURKM1FJREFRQUJvMll3WkRBT0JnTlZIUThCQWY4RUJBTUNBUVl3RWdZRFZSMFRBUUgvQkFnd0JnRUIKL3dJQkFqQWRCZ05WSFE0RUZnUVVOdnhRZ3o5ZTNXS2VscU1KTmZXNE1KUHYzc0V3SHdZRFZSMGpCQmd3Rm9BVQpOdnhRZ3o5ZTNXS2VscU1KTmZXNE1KUHYzc0V3RFFZSktvWklodmNOQVFFTEJRQURnZ0VCQUp1TUhYUms1TEVyCmxET1p4Mm9aRUNQZ29reXMzSGJsM05oempXd2pncXdxNVN6a011V3QrUnVkdnRTK0FUQjFtTjRjYTN0eSt2bWcKT09heTkvaDZoditmSE5jZHpYdWR5dFZYZW1KN3F4ZFoxd25DUUcwdnpqOWRZY0xFSGpJWi94dU1jNlY3dnJ4YwpSc0preGp5aE01UXBmRHd0eVZKeGpkUmVBZ0huSyswTkNieHdtQ3cyRGIvOXpudm9LWGk4TEQwbkQzOFQxY3R3CmhmdGxwTmRoZXFNRlpEZXBuTUYwY2g2cHo5TFV5Mkh1cnhrV2dkWVNjY2VNU0hPTzBMcG4xeVVBMWczOTJhUjUKWk81Zm5KMW95Vm1LVWFCeDJCMndsSVlUSXlES1ZiMnY1UXNHbnYvRHVTMDZhcmVLTmsvTGpHRTRlMXlHOHJkcwpacnZHMzNvUmtEbz0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=

View File

@ -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

View File

@ -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
}

View File

@ -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
}
}
}
}

View File

@ -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
}

View File

@ -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
}