diff --git a/pkg/api/conversion.go b/pkg/api/conversion.go index 89bfdefff5..bd33db677d 100644 --- a/pkg/api/conversion.go +++ b/pkg/api/conversion.go @@ -35,6 +35,10 @@ func init() { obj.LabelSelector = labels.Everything() obj.FieldSelector = fields.Everything() }, + func(obj *PodExecOptions) { + obj.Stderr = true + obj.Stdout = true + }, ) Scheme.AddConversionFuncs( func(in *util.Time, out *util.Time, s conversion.Scope) error { diff --git a/pkg/api/latest/latest.go b/pkg/api/latest/latest.go index 63b131c54e..887bba1a5b 100644 --- a/pkg/api/latest/latest.go +++ b/pkg/api/latest/latest.go @@ -125,7 +125,14 @@ func init() { } // these kinds should be excluded from the list of resources - ignoredKinds := util.NewStringSet("ListOptions", "DeleteOptions", "Status", "ContainerManifest") + ignoredKinds := util.NewStringSet( + "ListOptions", + "DeleteOptions", + "Status", + "ContainerManifest", + "PodLogOptions", + "PodExecOptions", + "PodProxyOptions") // enumerate all supported versions, get the kinds, and register with the mapper how to address our resources for _, version := range versions { diff --git a/pkg/api/register.go b/pkg/api/register.go index 2918a15328..4a35296199 100644 --- a/pkg/api/register.go +++ b/pkg/api/register.go @@ -58,6 +58,8 @@ func init() { &DeleteOptions{}, &ListOptions{}, &PodLogOptions{}, + &PodExecOptions{}, + &PodProxyOptions{}, ) // Legacy names are supported Scheme.AddKnownTypeWithName("", "Minion", &Node{}) @@ -97,3 +99,5 @@ func (*PersistentVolumeClaimList) IsAnAPIObject() {} func (*DeleteOptions) IsAnAPIObject() {} func (*ListOptions) IsAnAPIObject() {} func (*PodLogOptions) IsAnAPIObject() {} +func (*PodExecOptions) IsAnAPIObject() {} +func (*PodProxyOptions) IsAnAPIObject() {} diff --git a/pkg/api/rest/rest.go b/pkg/api/rest/rest.go index 069ed3c2f8..967b92eade 100644 --- a/pkg/api/rest/rest.go +++ b/pkg/api/rest/rest.go @@ -171,6 +171,32 @@ type Redirector interface { ResourceLocation(ctx api.Context, id string) (remoteLocation *url.URL, transport http.RoundTripper, err error) } +// ConnectHandler is a handler for HTTP connection requests. It extends the standard +// http.Handler interface by adding a method that returns an error object if an error +// occurred during the handling of the request. +type ConnectHandler interface { + http.Handler + + // RequestError returns an error if one occurred during handling of an HTTP request + RequestError() error +} + +// Connecter is a storage object that responds to a connection request +type Connecter interface { + // Connect returns a ConnectHandler that will handle the request/response for a request + Connect(ctx api.Context, id string, options runtime.Object) (ConnectHandler, error) + + // NewConnectOptions returns an empty options object that will be used to pass + // options to the Connect method. If nil, then a nil options object is passed to + // Connect. It may return a bool and a string. If true, the value of the request + // path below the object will be included as the named string in the serialization + // of the runtime object. + NewConnectOptions() (runtime.Object, bool, string) + + // ConnectMethods returns the list of HTTP methods handled by Connect + ConnectMethods() []string +} + // ResourceStreamer is an interface implemented by objects that prefer to be streamed from the server // instead of decoded directly. type ResourceStreamer interface { diff --git a/pkg/api/serialization_test.go b/pkg/api/serialization_test.go index d8c02773de..ca79e6f940 100644 --- a/pkg/api/serialization_test.go +++ b/pkg/api/serialization_test.go @@ -129,7 +129,7 @@ func TestList(t *testing.T) { } var nonRoundTrippableTypes = util.NewStringSet("ContainerManifest", "ContainerManifestList") -var nonInternalRoundTrippableTypes = util.NewStringSet("List", "ListOptions") +var nonInternalRoundTrippableTypes = util.NewStringSet("List", "ListOptions", "PodExecOptions") func TestRoundTripTypes(t *testing.T) { // api.Scheme.Log(t) diff --git a/pkg/api/types.go b/pkg/api/types.go index cc91b969af..5868517c84 100644 --- a/pkg/api/types.go +++ b/pkg/api/types.go @@ -1304,6 +1304,37 @@ type PodLogOptions struct { Follow bool } +// PodExecOptions is the query options to a Pod's remote exec call +type PodExecOptions struct { + TypeMeta + + // Stdin if true indicates that stdin is to be redirected for the exec call + Stdin bool + + // Stdout if true indicates that stdout is to be redirected for the exec call + Stdout bool + + // Stderr if true indicates that stderr is to be redirected for the exec call + Stderr bool + + // TTY if true indicates that a tty will be allocated for the exec call + TTY bool + + // Container in which to execute the command. + Container string + + // Command is the remote command to execute + Command string +} + +// PodProxyOptions is the query options to a Pod's proxy call +type PodProxyOptions struct { + TypeMeta + + // Path is the URL path to use for the current proxy request + Path string +} + // Status is a return value for calls that don't return other objects. // TODO: this could go in apiserver, but I'm including it here so clients needn't // import both. diff --git a/pkg/api/v1beta1/register.go b/pkg/api/v1beta1/register.go index 42d8be4dca..f2a137292e 100644 --- a/pkg/api/v1beta1/register.go +++ b/pkg/api/v1beta1/register.go @@ -66,6 +66,8 @@ func init() { &DeleteOptions{}, &ListOptions{}, &PodLogOptions{}, + &PodExecOptions{}, + &PodProxyOptions{}, ) // Future names are supported api.Scheme.AddKnownTypeWithName("v1beta1", "Node", &Minion{}) @@ -106,3 +108,5 @@ func (*PersistentVolumeClaimList) IsAnAPIObject() {} func (*DeleteOptions) IsAnAPIObject() {} func (*ListOptions) IsAnAPIObject() {} func (*PodLogOptions) IsAnAPIObject() {} +func (*PodExecOptions) IsAnAPIObject() {} +func (*PodProxyOptions) IsAnAPIObject() {} diff --git a/pkg/api/v1beta1/types.go b/pkg/api/v1beta1/types.go index 8ad1fb9ea9..57d41de70e 100644 --- a/pkg/api/v1beta1/types.go +++ b/pkg/api/v1beta1/types.go @@ -1159,6 +1159,37 @@ type PodLogOptions struct { Follow bool `json:"follow,omitempty" description:"follow the log stream of the pod; defaults to false"` } +// PodExecOptions is the query options to a Pod's remote exec call +type PodExecOptions struct { + TypeMeta `json:",inline"` + + // Stdin if true indicates that stdin is to be redirected for the exec call + Stdin bool `json:"stdin,omitempty" description:"redirect the standard input stream of the pod for this call; defaults to false"` + + // Stdout if true indicates that stdout is to be redirected for the exec call + Stdout bool `json:"stdout,omitempty" description:"redirect the standard output stream of the pod for this call; defaults to true"` + + // Stderr if true indicates that stderr is to be redirected for the exec call + Stderr bool `json:"stderr,omitempty" description:"redirect the standard error stream of the pod for this call; defaults to true"` + + // TTY if true indicates that a tty will be allocated for the exec call + TTY bool `json:"tty,omitempty" description:"allocate a terminal for this exec call; defaults to false"` + + // Container in which to execute the command. + Container string `json:"container,omitempty" description:"the container in which to execute the command. Defaults to only container if there is only one container in the pod."` + + // Command is the remote command to execute + Command string `json:"command" description:"the command to execute"` +} + +// PodProxyOptions is the query options to a Pod's proxy call +type PodProxyOptions struct { + TypeMeta `json:",inline"` + + // Path is the URL path to use for the current proxy request + Path string `json:"path,omitempty" description:"URL path to use in proxy request to pod"` +} + // Status is a return value for calls that don't return other objects. // TODO: this could go in apiserver, but I'm including it here so clients needn't // import both. diff --git a/pkg/api/v1beta2/register.go b/pkg/api/v1beta2/register.go index ccd8f06477..3cc2bc7d54 100644 --- a/pkg/api/v1beta2/register.go +++ b/pkg/api/v1beta2/register.go @@ -66,6 +66,8 @@ func init() { &DeleteOptions{}, &ListOptions{}, &PodLogOptions{}, + &PodExecOptions{}, + &PodProxyOptions{}, ) // Future names are supported api.Scheme.AddKnownTypeWithName("v1beta2", "Node", &Minion{}) @@ -106,3 +108,5 @@ func (*PersistentVolumeClaimList) IsAnAPIObject() {} func (*DeleteOptions) IsAnAPIObject() {} func (*ListOptions) IsAnAPIObject() {} func (*PodLogOptions) IsAnAPIObject() {} +func (*PodExecOptions) IsAnAPIObject() {} +func (*PodProxyOptions) IsAnAPIObject() {} diff --git a/pkg/api/v1beta2/types.go b/pkg/api/v1beta2/types.go index a76ea787be..52e1fc720c 100644 --- a/pkg/api/v1beta2/types.go +++ b/pkg/api/v1beta2/types.go @@ -1186,6 +1186,37 @@ type PodLogOptions struct { Follow bool `json:"follow,omitempty" description:"follow the log stream of the pod; defaults to false"` } +// PodExecOptions is the query options to a Pod's remote exec call +type PodExecOptions struct { + TypeMeta `json:",inline"` + + // Stdin if true indicates that stdin is to be redirected for the exec call + Stdin bool `json:"stdin,omitempty" description:"redirect the standard input stream of the pod for this call; defaults to false"` + + // Stdout if true indicates that stdout is to be redirected for the exec call + Stdout bool `json:"stdout,omitempty" description:"redirect the standard output stream of the pod for this call; defaults to true"` + + // Stderr if true indicates that stderr is to be redirected for the exec call + Stderr bool `json:"stderr,omitempty" description:"redirect the standard error stream of the pod for this call; defaults to true"` + + // TTY if true indicates that a tty will be allocated for the exec call + TTY bool `json:"tty,omitempty" description:"allocate a terminal for this exec call; defaults to false"` + + // Container in which to execute the command. + Container string `json:"container,omitempty" description:"the container in which to execute the command. Defaults to only container if there is only one container in the pod."` + + // Command is the remote command to execute + Command string `json:"command" description:"the command to execute"` +} + +// PodProxyOptions is the query options to a Pod's proxy call +type PodProxyOptions struct { + TypeMeta `json:",inline"` + + // Path is the URL path to use for the current proxy request + Path string `json:"path,omitempty" description:"URL path to use in proxy request to pod"` +} + // Status is a return value for calls that don't return other objects. // TODO: this could go in apiserver, but I'm including it here so clients needn't // import both. diff --git a/pkg/api/v1beta3/register.go b/pkg/api/v1beta3/register.go index dd35b5de23..538ea5a9ac 100644 --- a/pkg/api/v1beta3/register.go +++ b/pkg/api/v1beta3/register.go @@ -59,6 +59,8 @@ func init() { &DeleteOptions{}, &ListOptions{}, &PodLogOptions{}, + &PodExecOptions{}, + &PodProxyOptions{}, ) // Legacy names are supported api.Scheme.AddKnownTypeWithName("v1beta3", "Minion", &Node{}) @@ -98,3 +100,5 @@ func (*PersistentVolumeClaimList) IsAnAPIObject() {} func (*DeleteOptions) IsAnAPIObject() {} func (*ListOptions) IsAnAPIObject() {} func (*PodLogOptions) IsAnAPIObject() {} +func (*PodExecOptions) IsAnAPIObject() {} +func (*PodProxyOptions) IsAnAPIObject() {} diff --git a/pkg/api/v1beta3/types.go b/pkg/api/v1beta3/types.go index 1c02a1ae59..a70316c06d 100644 --- a/pkg/api/v1beta3/types.go +++ b/pkg/api/v1beta3/types.go @@ -1291,6 +1291,37 @@ type PodLogOptions struct { Follow bool `json:"follow,omitempty" description:"follow the log stream of the pod; defaults to false"` } +// PodExecOptions is the query options to a Pod's remote exec call +type PodExecOptions struct { + TypeMeta `json:",inline"` + + // Stdin if true indicates that stdin is to be redirected for the exec call + Stdin bool `json:"stdin,omitempty" description:"redirect the standard input stream of the pod for this call; defaults to false"` + + // Stdout if true indicates that stdout is to be redirected for the exec call + Stdout bool `json:"stdout,omitempty" description:"redirect the standard output stream of the pod for this call; defaults to true"` + + // Stderr if true indicates that stderr is to be redirected for the exec call + Stderr bool `json:"stderr,omitempty" description:"redirect the standard error stream of the pod for this call; defaults to true"` + + // TTY if true indicates that a tty will be allocated for the exec call + TTY bool `json:"tty,omitempty" description:"allocate a terminal for this exec call; defaults to false"` + + // Container in which to execute the command. + Container string `json:"container,omitempty" description:"the container in which to execute the command. Defaults to only container if there is only one container in the pod."` + + // Command is the remote command to execute + Command string `json:"command" description:"the command to execute"` +} + +// PodProxyOptions is the query options to a Pod's proxy call +type PodProxyOptions struct { + TypeMeta `json:",inline"` + + // Path is the URL path to use for the current proxy request + Path string `json:"path,omitempty" description:"URL path to use in proxy request to pod"` +} + // Status is a return value for calls that don't return other objects. type Status struct { TypeMeta `json:",inline"` diff --git a/pkg/apiserver/api_installer.go b/pkg/apiserver/api_installer.go index 10ff4c90c5..4215ef8741 100644 --- a/pkg/apiserver/api_installer.go +++ b/pkg/apiserver/api_installer.go @@ -139,6 +139,7 @@ func (a *APIInstaller) registerResourceHandlers(path string, storage rest.Storag patcher, isPatcher := storage.(rest.Patcher) watcher, isWatcher := storage.(rest.Watcher) _, isRedirector := storage.(rest.Redirector) + connecter, isConnecter := storage.(rest.Connecter) storageMeta, isMetadata := storage.(rest.StorageMetadata) if !isMetadata { storageMeta = defaultStorageMetadata{} @@ -193,6 +194,22 @@ func (a *APIInstaller) registerResourceHandlers(path string, storage rest.Storag isGetter = true } + var ( + connectOptions runtime.Object + connectOptionsKind string + connectSubpath bool + connectSubpathKey string + ) + if isConnecter { + connectOptions, connectSubpath, connectSubpathKey = connecter.NewConnectOptions() + if connectOptions != nil { + _, connectOptionsKind, err = a.group.Typer.ObjectVersionAndKind(connectOptions) + if err != nil { + return err + } + } + } + var ctxFn ContextFunc ctxFn = func(req *restful.Request) api.Context { if ctx, ok := context.Get(req.Request); ok { @@ -238,6 +255,8 @@ func (a *APIInstaller) registerResourceHandlers(path string, storage rest.Storag actions = appendIf(actions, action{"REDIRECT", "redirect/" + itemPath, nameParams, namer}, isRedirector) actions = appendIf(actions, action{"PROXY", "proxy/" + itemPath + "/{path:*}", proxyParams, namer}, isRedirector) actions = appendIf(actions, action{"PROXY", "proxy/" + itemPath, nameParams, namer}, isRedirector) + actions = appendIf(actions, action{"CONNECT", itemPath, nameParams, namer}, isConnecter) + actions = appendIf(actions, action{"CONNECT", itemPath + "/{path:*}", nameParams, namer}, isConnecter && connectSubpath) } else { // v1beta3 format with namespace in path @@ -275,6 +294,8 @@ func (a *APIInstaller) registerResourceHandlers(path string, storage rest.Storag actions = appendIf(actions, action{"REDIRECT", "redirect/" + itemPath, nameParams, namer}, isRedirector) actions = appendIf(actions, action{"PROXY", "proxy/" + itemPath + "/{path:*}", proxyParams, namer}, isRedirector) actions = appendIf(actions, action{"PROXY", "proxy/" + itemPath, nameParams, namer}, isRedirector) + actions = appendIf(actions, action{"CONNECT", itemPath, nameParams, namer}, isConnecter) + actions = appendIf(actions, action{"CONNECT", itemPath + "/{path:*}", nameParams, namer}, isConnecter && connectSubpath) // list across namespace. namer = scopeNaming{scope, a.group.Linker, gpath.Join(a.prefix, itemPath), true} @@ -315,6 +336,8 @@ func (a *APIInstaller) registerResourceHandlers(path string, storage rest.Storag actions = appendIf(actions, action{"REDIRECT", "redirect/" + itemPath, nameParams, namer}, isRedirector) actions = appendIf(actions, action{"PROXY", "proxy/" + itemPath + "/{path:*}", proxyParams, namer}, isRedirector) actions = appendIf(actions, action{"PROXY", "proxy/" + itemPath, nameParams, namer}, isRedirector) + actions = appendIf(actions, action{"CONNECT", itemPath, nameParams, namer}, isConnecter) + actions = appendIf(actions, action{"CONNECT", itemPath + "/{path:*}", nameParams, namer}, isConnecter && connectSubpath) } } @@ -480,6 +503,23 @@ func (a *APIInstaller) registerResourceHandlers(path string, storage rest.Storag addProxyRoute(ws, "PUT", a.prefix, action.Path, proxyHandler, kind, resource, action.Params) addProxyRoute(ws, "POST", a.prefix, action.Path, proxyHandler, kind, resource, action.Params) addProxyRoute(ws, "DELETE", a.prefix, action.Path, proxyHandler, kind, resource, action.Params) + case "CONNECT": + for _, method := range connecter.ConnectMethods() { + route := ws.Method(method).Path(action.Path). + To(ConnectResource(connecter, reqScope, connectOptionsKind, connectSubpath, connectSubpathKey)). + Filter(m). + Doc("connect " + method + " requests to " + kind). + Operation("connect" + method + kind). + Produces("*/*"). + Consumes("*/*"). + Writes("string") + if connectOptions != nil { + if err := addObjectParams(ws, route, connectOptions); err != nil { + return err + } + } + ws.Route(route) + } default: return fmt.Errorf("unrecognized action verb: %s", action.Verb) } diff --git a/pkg/apiserver/apiserver_test.go b/pkg/apiserver/apiserver_test.go index a15a4dbc70..fc2d2ebc8f 100644 --- a/pkg/apiserver/apiserver_test.go +++ b/pkg/apiserver/apiserver_test.go @@ -348,6 +348,19 @@ func (s *SimpleStream) InputStream(version, accept string) (io.ReadCloser, bool, return s, false, s.contentType, s.err } +type SimpleConnectHandler struct { + response string + err error +} + +func (h *SimpleConnectHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + w.Write([]byte(h.response)) +} + +func (h *SimpleConnectHandler) RequestError() error { + return h.err +} + func (storage *SimpleRESTStorage) Get(ctx api.Context, id string) (runtime.Object, error) { storage.checkContext(ctx) if id == "binary" { @@ -443,6 +456,39 @@ func (storage *SimpleRESTStorage) ResourceLocation(ctx api.Context, id string) ( return &locationCopy, nil, nil } +// Implement Connecter +type ConnecterRESTStorage struct { + connectHandler rest.ConnectHandler + emptyConnectOptions runtime.Object + receivedConnectOptions runtime.Object + receivedID string + takesPath string +} + +// Implement Connecter +var _ = rest.Connecter(&ConnecterRESTStorage{}) + +func (s *ConnecterRESTStorage) New() runtime.Object { + return &Simple{} +} + +func (s *ConnecterRESTStorage) Connect(ctx api.Context, id string, options runtime.Object) (rest.ConnectHandler, error) { + s.receivedConnectOptions = options + s.receivedID = id + return s.connectHandler, nil +} + +func (s *ConnecterRESTStorage) ConnectMethods() []string { + return []string{"GET", "POST", "PUT", "DELETE"} +} + +func (s *ConnecterRESTStorage) NewConnectOptions() (runtime.Object, bool, string) { + if len(s.takesPath) > 0 { + return s.emptyConnectOptions, true, s.takesPath + } + return s.emptyConnectOptions, false, "" +} + type LegacyRESTStorage struct { *SimpleRESTStorage } @@ -1108,6 +1154,135 @@ func TestGetMissing(t *testing.T) { } } +func TestConnect(t *testing.T) { + responseText := "Hello World" + itemID := "theID" + connectStorage := &ConnecterRESTStorage{ + connectHandler: &SimpleConnectHandler{ + response: responseText, + }, + } + storage := map[string]rest.Storage{ + "simple/connect": connectStorage, + } + handler := handle(storage) + server := httptest.NewServer(handler) + defer server.Close() + + resp, err := http.Get(server.URL + "/api/version/simple/" + itemID + "/connect") + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("unexpected response: %#v", resp) + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if connectStorage.receivedID != itemID { + t.Errorf("Unexpected item id. Expected: %s. Actual: %s.", itemID, connectStorage.receivedID) + } + if string(body) != responseText { + t.Errorf("Unexpected response. Expected: %s. Actual: %s.", responseText, string(body)) + } +} + +func TestConnectWithOptions(t *testing.T) { + responseText := "Hello World" + itemID := "theID" + connectStorage := &ConnecterRESTStorage{ + connectHandler: &SimpleConnectHandler{ + response: responseText, + }, + emptyConnectOptions: &SimpleGetOptions{}, + } + storage := map[string]rest.Storage{ + "simple/connect": connectStorage, + } + handler := handle(storage) + server := httptest.NewServer(handler) + defer server.Close() + + resp, err := http.Get(server.URL + "/api/version/simple/" + itemID + "/connect?param1=value1¶m2=value2") + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("unexpected response: %#v", resp) + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if connectStorage.receivedID != itemID { + t.Errorf("Unexpected item id. Expected: %s. Actual: %s.", itemID, connectStorage.receivedID) + } + if string(body) != responseText { + t.Errorf("Unexpected response. Expected: %s. Actual: %s.", responseText, string(body)) + } + opts, ok := connectStorage.receivedConnectOptions.(*SimpleGetOptions) + if !ok { + t.Errorf("Unexpected options type: %#v", connectStorage.receivedConnectOptions) + } + if opts.Param1 != "value1" && opts.Param2 != "value2" { + t.Errorf("Unexpected options value: %#v", opts) + } +} + +func TestConnectWithOptionsAndPath(t *testing.T) { + responseText := "Hello World" + itemID := "theID" + testPath := "a/b/c/def" + connectStorage := &ConnecterRESTStorage{ + connectHandler: &SimpleConnectHandler{ + response: responseText, + }, + emptyConnectOptions: &SimpleGetOptions{}, + takesPath: "atAPath", + } + storage := map[string]rest.Storage{ + "simple/connect": connectStorage, + } + handler := handle(storage) + server := httptest.NewServer(handler) + defer server.Close() + + resp, err := http.Get(server.URL + "/api/version/simple/" + itemID + "/connect/" + testPath + "?param1=value1¶m2=value2") + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("unexpected response: %#v", resp) + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if connectStorage.receivedID != itemID { + t.Errorf("Unexpected item id. Expected: %s. Actual: %s.", itemID, connectStorage.receivedID) + } + if string(body) != responseText { + t.Errorf("Unexpected response. Expected: %s. Actual: %s.", responseText, string(body)) + } + opts, ok := connectStorage.receivedConnectOptions.(*SimpleGetOptions) + if !ok { + t.Errorf("Unexpected options type: %#v", connectStorage.receivedConnectOptions) + } + if opts.Param1 != "value1" && opts.Param2 != "value2" { + t.Errorf("Unexpected options value: %#v", opts) + } + if opts.Path != testPath { + t.Errorf("Unexpected path value. Expected: %s. Actual: %s.", testPath, opts.Path) + } +} + func TestDelete(t *testing.T) { storage := map[string]rest.Storage{} simpleStorage := SimpleRESTStorage{} diff --git a/pkg/apiserver/proxy.go b/pkg/apiserver/proxy.go index 8b4336beed..1cdb700ba6 100644 --- a/pkg/apiserver/proxy.go +++ b/pkg/apiserver/proxy.go @@ -17,12 +17,9 @@ limitations under the License. package apiserver import ( - "bytes" - "compress/gzip" "crypto/tls" "fmt" "io" - "io/ioutil" "net" "net/http" "net/http/httputil" @@ -36,47 +33,13 @@ import ( "github.com/GoogleCloudPlatform/kubernetes/pkg/api/rest" "github.com/GoogleCloudPlatform/kubernetes/pkg/httplog" "github.com/GoogleCloudPlatform/kubernetes/pkg/runtime" - "github.com/GoogleCloudPlatform/kubernetes/pkg/util" "github.com/GoogleCloudPlatform/kubernetes/pkg/util/httpstream" + proxyutil "github.com/GoogleCloudPlatform/kubernetes/pkg/util/proxy" "github.com/GoogleCloudPlatform/kubernetes/third_party/golang/netutil" "github.com/golang/glog" - "golang.org/x/net/html" ) -// tagsToAttrs states which attributes of which tags require URL substitution. -// Sources: http://www.w3.org/TR/REC-html40/index/attributes.html -// http://www.w3.org/html/wg/drafts/html/master/index.html#attributes-1 -var tagsToAttrs = map[string]util.StringSet{ - "a": util.NewStringSet("href"), - "applet": util.NewStringSet("codebase"), - "area": util.NewStringSet("href"), - "audio": util.NewStringSet("src"), - "base": util.NewStringSet("href"), - "blockquote": util.NewStringSet("cite"), - "body": util.NewStringSet("background"), - "button": util.NewStringSet("formaction"), - "command": util.NewStringSet("icon"), - "del": util.NewStringSet("cite"), - "embed": util.NewStringSet("src"), - "form": util.NewStringSet("action"), - "frame": util.NewStringSet("longdesc", "src"), - "head": util.NewStringSet("profile"), - "html": util.NewStringSet("manifest"), - "iframe": util.NewStringSet("longdesc", "src"), - "img": util.NewStringSet("longdesc", "src", "usemap"), - "input": util.NewStringSet("src", "usemap", "formaction"), - "ins": util.NewStringSet("cite"), - "link": util.NewStringSet("href"), - "object": util.NewStringSet("classid", "codebase", "data", "usemap"), - "q": util.NewStringSet("cite"), - "script": util.NewStringSet("src"), - "source": util.NewStringSet("src"), - "video": util.NewStringSet("poster", "src"), - - // TODO: css URLs hidden in style elements. -} - // ProxyHandler provides a http.Handler which will proxy traffic to locations // specified by items implementing Redirector. type ProxyHandler struct { @@ -210,10 +173,10 @@ func (r *ProxyHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { if len(namespace) > 0 { prepend = path.Join(r.prefix, "namespaces", namespace, resource, id) } - transport = &proxyTransport{ - proxyScheme: req.URL.Scheme, - proxyHost: req.URL.Host, - proxyPathPrepend: prepend, + transport = &proxyutil.Transport{ + Scheme: req.URL.Scheme, + Host: req.URL.Host, + PathPrepend: prepend, } } proxy.Transport = transport @@ -321,149 +284,3 @@ func singleJoiningSlash(a, b string) string { } return a + b } - -type proxyTransport struct { - proxyScheme string - proxyHost string - proxyPathPrepend string -} - -func (t *proxyTransport) RoundTrip(req *http.Request) (*http.Response, error) { - // Add reverse proxy headers. - req.Header.Set("X-Forwarded-Uri", t.proxyPathPrepend+req.URL.Path) - req.Header.Set("X-Forwarded-Host", t.proxyHost) - req.Header.Set("X-Forwarded-Proto", t.proxyScheme) - - resp, err := http.DefaultTransport.RoundTrip(req) - - if err != nil { - message := fmt.Sprintf("Error: '%s'\nTrying to reach: '%v'", err.Error(), req.URL.String()) - resp = &http.Response{ - StatusCode: http.StatusServiceUnavailable, - Body: ioutil.NopCloser(strings.NewReader(message)), - } - return resp, nil - } - - if redirect := resp.Header.Get("Location"); redirect != "" { - resp.Header.Set("Location", t.rewriteURL(redirect, req.URL)) - } - - cType := resp.Header.Get("Content-Type") - cType = strings.TrimSpace(strings.SplitN(cType, ";", 2)[0]) - if cType != "text/html" { - // Do nothing, simply pass through - return resp, nil - } - - return t.fixLinks(req, resp) -} - -// rewriteURL rewrites a single URL to go through the proxy, if the URL refers -// to the same host as sourceURL, which is the page on which the target URL -// occurred. If any error occurs (e.g. parsing), it returns targetURL. -func (t *proxyTransport) rewriteURL(targetURL string, sourceURL *url.URL) string { - url, err := url.Parse(targetURL) - if err != nil { - return targetURL - } - if url.Host != "" && url.Host != sourceURL.Host { - return targetURL - } - - url.Scheme = t.proxyScheme - url.Host = t.proxyHost - origPath := url.Path - - if strings.HasPrefix(url.Path, "/") { - // The path is rooted at the host. Just add proxy prepend. - url.Path = path.Join(t.proxyPathPrepend, url.Path) - } else { - // The path is relative to sourceURL. - url.Path = path.Join(t.proxyPathPrepend, path.Dir(sourceURL.Path), url.Path) - } - - if strings.HasSuffix(origPath, "/") { - // Add back the trailing slash, which was stripped by path.Join(). - url.Path += "/" - } - - return url.String() -} - -// updateURLs checks and updates any of n's attributes that are listed in tagsToAttrs. -// Any URLs found are, if they're relative, updated with the necessary changes to make -// a visit to that URL also go through the proxy. -// sourceURL is the URL of the page which we're currently on; it's required to make -// relative links work. -func (t *proxyTransport) updateURLs(n *html.Node, sourceURL *url.URL) { - if n.Type != html.ElementNode { - return - } - attrs, ok := tagsToAttrs[n.Data] - if !ok { - return - } - for i, attr := range n.Attr { - if !attrs.Has(attr.Key) { - continue - } - n.Attr[i].Val = t.rewriteURL(attr.Val, sourceURL) - } -} - -// scan recursively calls f for every n and every subnode of n. -func (t *proxyTransport) scan(n *html.Node, f func(*html.Node)) { - f(n) - for c := n.FirstChild; c != nil; c = c.NextSibling { - t.scan(c, f) - } -} - -// fixLinks modifies links in an HTML file such that they will be redirected through the proxy if needed. -func (t *proxyTransport) fixLinks(req *http.Request, resp *http.Response) (*http.Response, error) { - origBody := resp.Body - defer origBody.Close() - - newContent := &bytes.Buffer{} - var reader io.Reader = origBody - var writer io.Writer = newContent - encoding := resp.Header.Get("Content-Encoding") - switch encoding { - case "gzip": - var err error - reader, err = gzip.NewReader(reader) - if err != nil { - return nil, fmt.Errorf("errorf making gzip reader: %v", err) - } - gzw := gzip.NewWriter(writer) - defer gzw.Close() - writer = gzw - // TODO: support flate, other encodings. - case "": - // This is fine - default: - // Some encoding we don't understand-- don't try to parse this - glog.Errorf("Proxy encountered encoding %v for text/html; can't understand this so not fixing links.", encoding) - return resp, nil - } - - doc, err := html.Parse(reader) - if err != nil { - glog.Errorf("Parse failed: %v", err) - return resp, err - } - - t.scan(doc, func(n *html.Node) { t.updateURLs(n, req.URL) }) - if err := html.Render(writer, doc); err != nil { - glog.Errorf("Failed to render: %v", err) - } - - resp.Body = ioutil.NopCloser(newContent) - // Update header node with new content-length - // TODO: Remove any hash/signature headers here? - resp.Header.Del("Content-Length") - resp.ContentLength = int64(newContent.Len()) - - return resp, err -} diff --git a/pkg/apiserver/proxy_test.go b/pkg/apiserver/proxy_test.go index 9d2f25ecd0..38509095bb 100644 --- a/pkg/apiserver/proxy_test.go +++ b/pkg/apiserver/proxy_test.go @@ -17,7 +17,6 @@ limitations under the License. package apiserver import ( - "bytes" "compress/gzip" "fmt" "io" @@ -29,207 +28,9 @@ import ( "testing" "github.com/GoogleCloudPlatform/kubernetes/pkg/api/rest" - "golang.org/x/net/html" "golang.org/x/net/websocket" ) -func parseURLOrDie(inURL string) *url.URL { - parsed, err := url.Parse(inURL) - if err != nil { - panic(err) - } - return parsed -} - -// fmtHTML parses and re-emits 'in', effectively canonicalizing it. -func fmtHTML(in string) string { - doc, err := html.Parse(strings.NewReader(in)) - if err != nil { - panic(err) - } - out := &bytes.Buffer{} - if err := html.Render(out, doc); err != nil { - panic(err) - } - return string(out.Bytes()) -} - -func TestProxyTransport(t *testing.T) { - testTransport := &proxyTransport{ - proxyScheme: "http", - proxyHost: "foo.com", - proxyPathPrepend: "/proxy/minion/minion1:10250", - } - testTransport2 := &proxyTransport{ - proxyScheme: "https", - proxyHost: "foo.com", - proxyPathPrepend: "/proxy/minion/minion1:8080", - } - type Item struct { - input string - sourceURL string - transport *proxyTransport - output string - contentType string - forwardedURI string - redirect string - redirectWant string - } - - table := map[string]Item{ - "normal": { - input: `
kubelet.loggoogle.log
`, - sourceURL: "http://myminion.com/logs/log.log", - transport: testTransport, - output: `
kubelet.loggoogle.log
`, - contentType: "text/html", - forwardedURI: "/proxy/minion/minion1:10250/logs/log.log", - }, - "trailing slash": { - input: `
kubelet.loggoogle.log
`, - sourceURL: "http://myminion.com/logs/log.log", - transport: testTransport, - output: `
kubelet.loggoogle.log
`, - contentType: "text/html", - forwardedURI: "/proxy/minion/minion1:10250/logs/log.log", - }, - "content-type charset": { - input: `
kubelet.loggoogle.log
`, - sourceURL: "http://myminion.com/logs/log.log", - transport: testTransport, - output: `
kubelet.loggoogle.log
`, - contentType: "text/html; charset=utf-8", - forwardedURI: "/proxy/minion/minion1:10250/logs/log.log", - }, - "content-type passthrough": { - input: `
kubelet.loggoogle.log
`, - sourceURL: "http://myminion.com/logs/log.log", - transport: testTransport, - output: `
kubelet.loggoogle.log
`, - contentType: "text/plain", - forwardedURI: "/proxy/minion/minion1:10250/logs/log.log", - }, - "subdir": { - input: `kubelet.loggoogle.log`, - sourceURL: "http://myminion.com/whatever/apt/somelog.log", - transport: testTransport2, - output: `kubelet.loggoogle.log`, - contentType: "text/html", - forwardedURI: "/proxy/minion/minion1:8080/whatever/apt/somelog.log", - }, - "image": { - input: `
`, - sourceURL: "http://myminion.com/", - transport: testTransport, - output: `
`, - contentType: "text/html", - forwardedURI: "/proxy/minion/minion1:10250/", - }, - "abs": { - input: `