Skip to content

Commit

Permalink
Update web server to use tap APIService (#3208)
Browse files Browse the repository at this point in the history
### Motivation

PR #3167 introduced the tap APIService and migrated `linkerd tap` to use it.
Subsequent PRs (#3186 and #3187) updated `linkerd top` and `linkerd profile
--tap` to use the tap APIService. This PR moves the web's Go server to now also
use the tap APIService instead of the public API. It also ensures an error
banner is shown to the user when unauthorized taps fail via `linkerd top`
command in *Overview* and *Top*, and `linkerd tap` command in *Tap*.

### Details

The majority of these changes are focused around piping through the HTTP error
that occurs and making sure the error banner generated displays the error
message explaining to view the tap RBAC docs.

`httpError` is now public (`HTTPError`) and the error message generated is short
enough to fit in a control frame (explained [here](https://github.com/linkerd/linkerd2/blob/kleimkuhler%2Fweb-tap-apiserver/web/srv/api_handlers.go#L173-L175)).

### Testing

The error we are testing for only occurs when the linkerd-web service account is
not authorzied to tap resources. Unforutnately that is not the case on Docker
For Mac (assuming that is what you use locally), so you'll need to test on a
different cluster. I chose a GKE cluster made through the GKE console--not made
through cluster-utils because it adds cluster-admin.

Checkout the branch locally and `bin/docker-build` or `ares-build` if you have
it setup. It should produce a linkerd with the version `git-04e61786`. I have
already pushed the dependent components, so you won't need to `bin/docker-push
git-04e61786`.

Install linkerd on this GKE cluster and try to run `tap` or `top` commands via
the web. You should see the following errors:

### Tap

![web-tap-unauthorized](https://user-images.githubusercontent.com/4572153/62661243-51464900-b925-11e9-907b-29d7ca3f815d.png)

### Top

![web-top-unauthorized](https://user-images.githubusercontent.com/4572153/62661308-894d8c00-b925-11e9-9498-6c9d38b371f6.png)

Signed-off-by: Kevin Leimkuhler <[email protected]>
  • Loading branch information
kleimkuhler authored Aug 8, 2019
1 parent f98bc27 commit 5d7662f
Show file tree
Hide file tree
Showing 15 changed files with 140 additions and 50 deletions.
3 changes: 2 additions & 1 deletion controller/tap/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/linkerd/linkerd2/controller/k8s"
pkgK8s "github.com/linkerd/linkerd2/pkg/k8s"
"github.com/linkerd/linkerd2/pkg/protohttp"
"github.com/linkerd/linkerd2/pkg/tap"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/sirupsen/logrus"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
Expand Down Expand Up @@ -136,7 +137,7 @@ func (h *handler) handleTap(w http.ResponseWriter, req *http.Request, p httprout
req.Header[h.groupHeader],
)
if err != nil {
err = fmt.Errorf("tap authorization failed (%s), visit https://linkerd.io/tap-rbac for more information", err)
err = fmt.Errorf("tap authorization failed (%s), visit %s for more information", err, tap.TapRbacURL)
h.log.Error(err)
renderJSONError(w, err, http.StatusForbidden)
return
Expand Down
21 changes: 12 additions & 9 deletions pkg/protohttp/protohttp.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ const (
numBytesForMessageLength = 4
)

type httpError struct {
// HTTPError is an error which indicates the HTTP response contained an error
type HTTPError struct {
Code int
WrappedError error
}
Expand All @@ -37,24 +38,24 @@ type FlushableResponseWriter interface {
http.Flusher
}

// Error satisfies the error interface for httpError.
func (e httpError) Error() string {
// Error satisfies the error interface for HTTPError.
func (e HTTPError) Error() string {
return fmt.Sprintf("HTTP error, status Code [%d], wrapped error is: %v", e.Code, e.WrappedError)
}

// HTTPRequestToProto converts an HTTP Request to a protobuf request.
func HTTPRequestToProto(req *http.Request, protoRequestOut proto.Message) error {
bytes, err := ioutil.ReadAll(req.Body)
if err != nil {
return httpError{
return HTTPError{
Code: http.StatusBadRequest,
WrappedError: err,
}
}

err = proto.Unmarshal(bytes, protoRequestOut)
if err != nil {
return httpError{
return HTTPError{
Code: http.StatusBadRequest,
WrappedError: err,
}
Expand All @@ -68,7 +69,7 @@ func WriteErrorToHTTPResponse(w http.ResponseWriter, errorObtained error) {
statusCode := defaultHTTPErrorStatusCode
errorToReturn := errorObtained

if httpErr, ok := errorObtained.(httpError); ok {
if httpErr, ok := errorObtained.(HTTPError); ok {
statusCode = httpErr.Code
errorToReturn = httpErr.WrappedError
}
Expand Down Expand Up @@ -171,13 +172,15 @@ func CheckIfResponseHasError(rsp *http.Response) error {
body := string(bytes)
obj, err := k8s.ToRuntimeObject(body)
if err == nil {
return fmt.Errorf("Unexpected API response: %s (%s)", rsp.Status, kerrors.FromObject(obj))
return HTTPError{Code: rsp.StatusCode, WrappedError: kerrors.FromObject(obj)}
}
return fmt.Errorf("Unexpected API response: %s (%s)", rsp.Status, body)

body = fmt.Sprintf("unexpected API response: %s", body)
return HTTPError{Code: rsp.StatusCode, WrappedError: errors.New(body)}
}
}

return fmt.Errorf("Unexpected API response: %s", rsp.Status)
return HTTPError{Code: rsp.StatusCode, WrappedError: errors.New("unexpected API response")}
}

return nil
Expand Down
8 changes: 4 additions & 4 deletions pkg/protohttp/protohttp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func TestHttpRequestToProto(t *testing.T) {
t.Fatalf("Expecting error, got nothing")
}

if httpErr, ok := err.(httpError); ok {
if httpErr, ok := err.(HTTPError); ok {
expectedStatusCode := http.StatusBadRequest
if httpErr.Code != expectedStatusCode || httpErr.WrappedError == nil {
t.Fatalf("Expected error status to be [%d] and contain wrapper error, got status [%d] and error [%v]", expectedStatusCode, httpErr.Code, httpErr.WrappedError)
Expand Down Expand Up @@ -150,7 +150,7 @@ func TestWriteErrorToHttpResponse(t *testing.T) {
t.Run("Writes http specific error correctly to response", func(t *testing.T) {
expectedErrorStatusCode := http.StatusBadGateway
responseWriter := newStubResponseWriter()
httpError := httpError{
httpError := HTTPError{
WrappedError: errors.New("expected to be wrapped"),
Code: http.StatusBadGateway,
}
Expand Down Expand Up @@ -497,7 +497,7 @@ func TestCheckIfResponseHasError(t *testing.T) {
}

err = CheckIfResponseHasError(response)
expectedErr := errors.New("Unexpected API response: 403 Forbidden (res.group \"name\" is forbidden: test-err)")
expectedErr := HTTPError{Code: http.StatusForbidden, WrappedError: statusError}

if !reflect.DeepEqual(err, expectedErr) {
t.Fatalf("Expected %s, got %s", expectedErr, err)
Expand All @@ -515,7 +515,7 @@ func TestCheckIfResponseHasError(t *testing.T) {
t.Fatalf("Expecting error, got nothing")
}

expectedErrorMessage := "Unexpected API response: 503 Service Unavailable"
expectedErrorMessage := "HTTP error, status Code [503], wrapped error is: unexpected API response"
actualErrorMessage := err.Error()
if actualErrorMessage != expectedErrorMessage {
t.Fatalf("Expected error message to be [%s], but it was [%s]", expectedErrorMessage, actualErrorMessage)
Expand Down
4 changes: 4 additions & 0 deletions pkg/tap/tap.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import (
log "github.com/sirupsen/logrus"
)

// TapRbacURL is the link users should visit to remedy issues when attemping
// to tap resources with missing authorizations
const TapRbacURL = "https://linkerd.io/tap-rbac"

// Reader initiates a TapByResourceRequest and returns a buffered Reader.
// It is the caller's responsibility to call Close() on the io.ReadCloser.
func Reader(k8sAPI *k8s.KubernetesAPI, req *pb.TapByResourceRequest, timeout time.Duration) (*bufio.Reader, io.ReadCloser, error) {
Expand Down
4 changes: 4 additions & 0 deletions web/app/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@ a:focus, a:hover, a:visited, a:link, a:active {
.tapGrayed {
color: lightgray;
}

.errorMessage a {
color: white;
}
5 changes: 3 additions & 2 deletions web/app/js/components/ErrorBanner.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import CloseIcon from '@material-ui/icons/Close';
import IconButton from '@material-ui/core/IconButton';
import Linkify from 'react-linkify';
import PropTypes from 'prop-types';
import React from 'react';
import Snackbar from '@material-ui/core/Snackbar';
Expand Down Expand Up @@ -63,12 +64,12 @@ class ErrorSnackbar extends React.Component {
className: classNames(classes.error, classes.margin)
}}
message={(
<div id="message-id" >
<div id="message-id" className="errorMessage" >
<div className={classes.message}>
<WarningIcon className={classNames(classes.icon, classes.iconVariant)} />
{ !status ? null : status + " " }{ _isEmpty(statusText) ? defaultMessage : statusText }
</div>
{ !error ? null : <div>{error}</div> }
<Linkify>{ !error ? null : <div>{error}</div> }</Linkify>
{ !url ? null : <div>{url}</div> }
</div>
)}
Expand Down
22 changes: 15 additions & 7 deletions web/app/js/components/Tap.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { UrlQueryParamTypes, addUrlProps } from 'react-url-query';
import { emptyTapQuery, processTapEvent, setMaxRps, wsCloseCodes } from './util/TapUtils.jsx';
import { WS_ABNORMAL_CLOSURE, WS_NORMAL_CLOSURE, WS_POLICY_VIOLATION, emptyTapQuery, processTapEvent, setMaxRps, wsCloseCodes } from './util/TapUtils.jsx';

import ErrorBanner from './ErrorBanner.jsx';
import PropTypes from 'prop-types';
Expand Down Expand Up @@ -106,12 +106,20 @@ class Tap extends React.Component {
where Chrome browsers incorrectly displays a 1006 close code
https://github.com/linkerd/linkerd2/issues/1630
*/
if (!e.wasClean && e.code !== 1006 && this._isMounted) {
this.setState({
error: {
error: `Websocket close error [${e.code}: ${wsCloseCodes[e.code]}] ${e.reason ? ":" : ""} ${e.reason}`
}
});
if (e.code !== WS_NORMAL_CLOSURE && e.code !== WS_ABNORMAL_CLOSURE && this._isMounted) {
if (e.code === WS_POLICY_VIOLATION) {
this.setState({
error: {
error: e.reason
}
});
} else {
this.setState({
error: {
error: `Websocket close error [${e.code}: ${wsCloseCodes[e.code]}] ${e.reason ? ":" : ""} ${e.reason}`
}
});
}
}
}

Expand Down
5 changes: 1 addition & 4 deletions web/app/js/components/TopModule.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { processNeighborData, processTapEvent, setMaxRps, wsCloseCodes } from './util/TapUtils.jsx';
import { WS_ABNORMAL_CLOSURE, WS_NORMAL_CLOSURE, processNeighborData, processTapEvent, setMaxRps, wsCloseCodes } from './util/TapUtils.jsx';

import ErrorBanner from './ErrorBanner.jsx';
import Percentage from './util/Percentage.js';
Expand All @@ -19,9 +19,6 @@ import _throttle from 'lodash/throttle';
import _values from 'lodash/values';
import { withContext } from './util/AppContext.jsx';

const WS_NORMAL_CLOSURE = 1000;
const WS_ABNORMAL_CLOSURE = 1006;

class TopModule extends React.Component {
static propTypes = {
maxRowsToDisplay: PropTypes.number,
Expand Down
22 changes: 13 additions & 9 deletions web/app/js/components/util/TapUtils.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ export const wsCloseCodes = {
1015: "TLS Handshake"
};

export const WS_NORMAL_CLOSURE = 1000;
export const WS_ABNORMAL_CLOSURE = 1006;
export const WS_POLICY_VIOLATION = 1008;

/*
Use tap data to figure out a resource's unmeshed upstreams/downstreams
*/
Expand Down Expand Up @@ -235,14 +239,14 @@ const getPodList = (endpoint, display, labels, ResourceLink) => {
podList = (
<React.Fragment>
{
_map(display.pods, (namespace, pod, i) => {
if (i > displayLimit) {
return null;
} else {
return <div key={pod}>{resourceShortLink("pod", { pod, namespace }, ResourceLink)}</div>;
}
})
}
_map(display.pods, (namespace, pod, i) => {
if (i > displayLimit) {
return null;
} else {
return <div key={pod}>{resourceShortLink("pod", { pod, namespace }, ResourceLink)}</div>;
}
})
}
{ (_size(display.pods) > displayLimit ? "..." : "") }
</React.Fragment>
);
Expand All @@ -255,7 +259,7 @@ const getIpList = (endpoint, display) => {
let ipList = endpoint.str;
if (display) {
ipList = _take(Object.keys(display.ips), displayLimit).join(", ") +
(_size(display.ips) > displayLimit ? "..." : "");
(_size(display.ips) > displayLimit ? "..." : "");
}
return <div className="popover-td">{ipList}</div>;
};
Expand Down
1 change: 1 addition & 0 deletions web/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"react": "16.5.0",
"react-dom": "16.5.0",
"react-iframe": "1.7.16",
"react-linkify": "1.0.0-alpha",
"react-router": "4.3.1",
"react-router-dom": "4.3.1",
"react-router-prop-types": "1.0.4",
Expand Down
25 changes: 25 additions & 0 deletions web/app/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6150,6 +6150,13 @@ levn@^0.3.0, levn@~0.3.0:
prelude-ls "~1.1.2"
type-check "~0.3.2"

linkify-it@^2.0.3:
version "2.2.0"
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-2.2.0.tgz#e3b54697e78bf915c70a38acd78fd09e0058b1cf"
integrity sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==
dependencies:
uc.micro "^1.0.1"

load-json-file@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
Expand Down Expand Up @@ -7902,6 +7909,14 @@ react-lifecycles-compat@^3.0.2, react-lifecycles-compat@^3.0.4:
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==

[email protected]:
version "1.0.0-alpha"
resolved "https://registry.yarnpkg.com/react-linkify/-/react-linkify-1.0.0-alpha.tgz#b391c7b88e3443752fafe76a95ca4434e82e70d5"
integrity sha512-7gcIUvJkAXXttt1fmBK9cwn+1jTa4hbKLGCZ9J1U6EOkyb2/+LKL1Z28d9rtDLMnpvImlNlLPdTPooorl5cpmg==
dependencies:
linkify-it "^2.0.3"
tlds "^1.199.0"

[email protected]:
version "4.3.1"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-4.3.1.tgz#4c2619fc24c4fa87c9fd18f4fb4a43fe63fbd5c6"
Expand Down Expand Up @@ -9196,6 +9211,11 @@ tiny-warning@^1.0.0:
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.2.tgz#1dfae771ee1a04396bdfde27a3adcebc6b648b28"
integrity sha512-rru86D9CpQRLvsFG5XFdy0KdLAvjdQDyZCsRcuu60WtzFylDM3eAWSxEVz5kzL2Gp544XiUvPbVKtOA/txLi9Q==

tlds@^1.199.0:
version "1.203.1"
resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.203.1.tgz#4dc9b02f53de3315bc98b80665e13de3edfc1dfc"
integrity sha512-7MUlYyGJ6rSitEZ3r1Q1QNV8uSIzapS8SmmhSusBuIc7uIxPPwsKllEP0GRp1NS6Ik6F+fRZvnjDWm3ecv2hDw==

tmp@^0.0.33:
version "0.0.33"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
Expand Down Expand Up @@ -9355,6 +9375,11 @@ ua-parser-js@^0.7.18:
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.19.tgz#94151be4c0a7fb1d001af7022fdaca4642659e4b"
integrity sha512-T3PVJ6uz8i0HzPxOF9SWzWAlfN/DavlpQqepn22xgve/5QecC+XMCAtmUNnY7C9StehaV6exjUCI801lOI7QlQ==

uc.micro@^1.0.1:
version "1.0.6"
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac"
integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==

uglify-es@^3.3.4:
version "3.3.9"
resolved "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.3.9.tgz#0c1c4f0700bed8dbc124cdb304d2592ca203e677"
Expand Down
9 changes: 8 additions & 1 deletion web/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/linkerd/linkerd2/pkg/admin"
"github.com/linkerd/linkerd2/pkg/config"
"github.com/linkerd/linkerd2/pkg/flags"
"github.com/linkerd/linkerd2/pkg/k8s"
pkgK8s "github.com/linkerd/linkerd2/pkg/k8s"
"github.com/linkerd/linkerd2/web/srv"
log "github.com/sirupsen/logrus"
Expand All @@ -27,6 +28,7 @@ func main() {
staticDir := flag.String("static-dir", "app/dist", "directory to search for static files")
reload := flag.Bool("reload", true, "reloading set to true or false")
controllerNamespace := flag.String("controller-namespace", "linkerd", "namespace in which Linkerd is installed")
kubeConfigPath := flag.String("kubeconfig", "", "path to kube config")
flags.ConfigureAndParse()

_, _, err := net.SplitHostPort(*apiAddr) // Verify apiAddr is of the form host:port.
Expand All @@ -45,6 +47,11 @@ func main() {
log.Warnf("failed to load cluster domain from global config: [%s] (falling back to %s)", err, clusterDomain)
}

k8sAPI, err := k8s.NewAPI(*kubeConfigPath, "", "", 0)
if err != nil {
log.Fatalf("failed to construct Kubernetes API client: [%s]", err)
}

installConfig, err := config.Install(pkgK8s.MountPathInstallConfig)
if err != nil {
log.Warnf("failed to load uuid from install config: [%s] (disregard warning if running in development mode)", err)
Expand All @@ -54,7 +61,7 @@ func main() {
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)

server := srv.NewServer(*addr, *grafanaAddr, *templateDir, *staticDir, uuid, *controllerNamespace, clusterDomain, *reload, client)
server := srv.NewServer(*addr, *grafanaAddr, *templateDir, *staticDir, uuid, *controllerNamespace, clusterDomain, *reload, client, k8sAPI)

go func() {
log.Infof("starting HTTP server on %+v", *addr)
Expand Down
Loading

0 comments on commit 5d7662f

Please sign in to comment.