Merge pull request #974 from prymitive/fetch-headers

fix(ui): pass configured alertmanager headers to in-browser fetch calls
This commit is contained in:
Łukasz Mierzwa
2019-09-26 21:10:34 +01:00
committed by GitHub
24 changed files with 266 additions and 50 deletions

View File

@@ -13,6 +13,7 @@ import (
"github.com/prymitive/karma/internal/filters"
"github.com/prymitive/karma/internal/models"
"github.com/prymitive/karma/internal/slices"
"github.com/prymitive/karma/internal/uri"
log "github.com/sirupsen/logrus"
)
@@ -111,11 +112,20 @@ func getUpstreams() models.AlertmanagerAPISummary {
Name: upstream.Name,
URI: upstream.SanitizedURI(),
PublicURI: upstream.PublicURI(),
Headers: map[string]string{},
Error: upstream.Error(),
Version: upstream.Version(),
Cluster: upstream.ClusterID(),
ClusterMembers: members,
}
if !upstream.ProxyRequests {
for k, v := range uri.HeadersForBasicAuth(u.PublicURI) {
u.Headers[k] = v
}
for k, v := range upstream.HTTPHeaders {
u.Headers[k] = v
}
}
summary.Instances = append(summary.Instances, u)
summary.Counters.Total++

View File

@@ -69,10 +69,10 @@ alertmanager:
(`https://user:password@alertmanager.example.com`) and you don't want it to
be visible to users then ensure `proxy: true` is also set in order to avoid
leaking auth information to the browser.
Without proxy mode full URI needs to be passed to karma web UI code.
With proxy mode all requests will be routed via karma HTTP server and since
karma has full URI in the config it only needs Alertmanager name in that
request.
Note: if URI contains username and password and proxy option is NOT enabled
(see below), then the username & password information will be stripped from
the URI and `Authorization` header using Basic Auth will be set for all
in browser requests.
To set a different URI for all browser requests (can be any valid URI) see
`external_uri` option below.
- `external_uri` - base URI of this Alertmanager server used for all browser

View File

@@ -35,12 +35,12 @@ var uriTests = []uriTest{
{
rawURI: "http://user:pass@alertmanager.example.com",
proxy: false,
publicURI: "http://user:pass@alertmanager.example.com",
publicURI: "http://alertmanager.example.com",
},
{
rawURI: "https://user:pass@alertmanager.example.com/foo",
proxy: false,
publicURI: "https://user:pass@alertmanager.example.com/foo",
publicURI: "https://alertmanager.example.com/foo",
},
{
rawURI: "http://user:pass@alertmanager.example.com",

View File

@@ -211,7 +211,7 @@ func (am *Alertmanager) PublicURI() string {
if am.ExternalURI != "" {
return am.ExternalURI
}
return am.URI
return uri.WithoutUserinfo(am.URI)
}
func (am *Alertmanager) pullAlerts(version string) error {

View File

@@ -29,11 +29,12 @@ type AlertmanagerAPIStatus struct {
URI string `json:"uri"`
// this is URI client should use to talk to this Alertmanager, it might be
// same as real or proxied URI
PublicURI string `json:"publicURI"`
Error string `json:"error"`
Version string `json:"version"`
Cluster string `json:"cluster"`
ClusterMembers []string `json:"clusterMembers"`
PublicURI string `json:"publicURI"`
Headers map[string]string `json:"headers"`
Error string `json:"error"`
Version string `json:"version"`
Cluster string `json:"cluster"`
ClusterMembers []string `json:"clusterMembers"`
}
// AlertmanagerAPICounters returns number of Alertmanager instances in each

View File

@@ -1,6 +1,7 @@
package uri
import (
"encoding/base64"
"net/url"
"path"
)
@@ -30,3 +31,35 @@ func SanitizeURI(s string) string {
}
return s
}
// HeadersForBasicAuth checks if the passed uri contains user & password
// (http://user:pass@example.com) and if so generates headers for Basic Auth
// based on
func HeadersForBasicAuth(s string) map[string]string {
headers := map[string]string{}
u, err := url.Parse(s)
if err != nil {
return headers
}
if u.User != nil {
if password, pwdSet := u.User.Password(); pwdSet {
auth := u.User.Username() + ":" + password
headers["Authorization"] = "Basic " + base64.StdEncoding.EncodeToString([]byte(auth))
}
}
return headers
}
// WithoutUserinfo takes an URL and returns a copy of it with basic auth
// stripped
func WithoutUserinfo(s string) string {
u, err := url.Parse(s)
if err != nil {
return s
}
u.User = nil
return u.String()
}

View File

@@ -7,35 +7,48 @@ import (
)
type joinURLTest struct {
base string
sub string
url string
base string
sub string
url string
isValid bool
}
var joinURLTests = []joinURLTest{
{
base: "http://localhost",
sub: "/sub",
url: "http://localhost/sub",
base: "http://localhost",
sub: "/sub",
url: "http://localhost/sub",
isValid: true,
},
{
base: "http://localhost",
sub: "/sub/",
url: "http://localhost/sub",
base: "http://localhost",
sub: "/sub/",
url: "http://localhost/sub",
isValid: true,
},
{
base: "http://am.example.com",
sub: "/api/v1/alerts",
url: "http://am.example.com/api/v1/alerts",
base: "http://am.example.com",
sub: "/api/v1/alerts",
url: "http://am.example.com/api/v1/alerts",
isValid: true,
},
{
base: "%gh&%ij",
sub: "/a + b",
url: "",
isValid: false,
},
}
func TestJoinURL(t *testing.T) {
for _, testCase := range joinURLTests {
url, err := uri.JoinURL(testCase.base, testCase.sub)
if err != nil {
if err != nil && testCase.isValid {
t.Errorf("joinURL(%v, %v) failed: %s", testCase.base, testCase.sub, err.Error())
}
if err == nil && !testCase.isValid {
t.Errorf("expected error for '%s' and '%s' but got '%s'", testCase.base, testCase.sub, url)
}
if url != testCase.url {
t.Errorf("Invalid joined url from '%s' + '%s', expected '%s', got '%s'", testCase.base, testCase.sub, testCase.url, url)
}
@@ -80,6 +93,10 @@ var sanitizeURITests = []sanitizeURITest{
raw: "https://user:pass@alertmanager.example.com/foo",
sanitized: "https://user:xxx@alertmanager.example.com/foo",
},
{
raw: "%gh&%ij",
sanitized: "%gh&%ij",
},
}
func TestSanitizedURI(t *testing.T) {
@@ -91,3 +108,62 @@ func TestSanitizedURI(t *testing.T) {
}
}
}
func TestHeadersForBasicAuth(t *testing.T) {
type headersTest struct {
uri string
isSet bool
value string
}
testCases := []headersTest{
{
uri: "http://localhost.com",
isSet: false,
},
{
uri: "http://user@localhost.com",
isSet: false,
},
{
uri: "http://user:pass@localhost.com",
isSet: true,
value: "Basic dXNlcjpwYXNz",
},
{
uri: "%gh&%ij",
isSet: false,
},
}
for _, test := range testCases {
headers := uri.HeadersForBasicAuth(test.uri)
value, isSet := headers["Authorization"]
if isSet != test.isSet {
t.Errorf("[%s] expected Authorization header: %v, was set: %v", test.uri, test.isSet, isSet)
}
if value != test.value {
t.Errorf("[%s] expected Authorization value: %s, value: %s", test.uri, test.value, value)
}
}
}
func TestURIWithoutUserinfo(t *testing.T) {
type userinfoTest struct {
uri string
parsed string
}
testCases := []userinfoTest{
{uri: "http://localhost", parsed: "http://localhost"},
{uri: "http://localhost?foo=bar", parsed: "http://localhost?foo=bar"},
{uri: "http://user@localhost", parsed: "http://localhost"},
{uri: "http://user:pass@localhost", parsed: "http://localhost"},
{uri: "http://user:pass@localhost?foo=bar#1", parsed: "http://localhost?foo=bar#1"},
{uri: "%gh&%ij", parsed: "%gh&%ij"},
}
for _, test := range testCases {
parsed := uri.WithoutUserinfo(test.uri)
if parsed != test.parsed {
t.Errorf("'%s' got parsed as '%s', expected: '%s'", test.uri, parsed, test.parsed)
}
}
}

View File

@@ -19,6 +19,7 @@
"favico.js": "0.3.10",
"fontfaceobserver": "2.1.0",
"lodash.debounce": "4.0.8",
"lodash.merge": "4.6.2",
"lodash.throttle": "4.1.1",
"lodash.uniqueid": "4.0.1",
"mobx": "5.13.0",

6
ui/src/Common/Fetch.js Normal file
View File

@@ -0,0 +1,6 @@
import merge from "lodash.merge";
const FetchWithCredentials = async (uri, options) =>
await fetch(uri, merge({}, { credentials: "include" }, options));
export { FetchWithCredentials };

View File

@@ -0,0 +1,40 @@
import { FetchWithCredentials } from "./Fetch";
beforeEach(() => {
fetch.resetMocks();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe("FetchWithCredentials", () => {
it("fetch passes '{credentials: include}' to all requests", async () => {
const request = FetchWithCredentials("http://example.com", {});
await expect(request).resolves.toBeUndefined();
expect(fetch).toHaveBeenCalledWith("http://example.com", {
credentials: "include"
});
});
it("custom keys are merged with defaults", async () => {
const request = FetchWithCredentials("http://example.com", {
foo: "bar"
});
await expect(request).resolves.toBeUndefined();
expect(fetch).toHaveBeenCalledWith("http://example.com", {
credentials: "include",
foo: "bar"
});
});
it("custom credentials are used when passed", async () => {
const request = FetchWithCredentials("http://example.com", {
credentials: "none"
});
await expect(request).resolves.toBeUndefined();
expect(fetch).toHaveBeenCalledWith("http://example.com", {
credentials: "none"
});
});
});

View File

@@ -15,6 +15,7 @@ import { faCircleNotch } from "@fortawesome/free-solid-svg-icons/faCircleNotch";
import { APIAlertmanagerUpstream } from "Models/API";
import { AlertStore, FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore";
import { FormatQuery, QueryOperators, StaticLabels } from "Common/Query";
import { FetchWithCredentials } from "Common/Fetch";
import { Modal } from "Components/Modal";
import {
LabelSetList,
@@ -136,7 +137,7 @@ const DeleteSilenceModalContent = observer(
FormatQuery(StaticLabels.SilenceID, QueryOperators.Equal, silenceID)
]);
this.previewState.fetch = fetch(alertsURI, { credentials: "include" })
this.previewState.fetch = FetchWithCredentials(alertsURI, {})
.then(result => result.json())
.then(result => {
this.previewState.groupsToUniqueLabels(Object.values(result.groups));
@@ -165,9 +166,9 @@ const DeleteSilenceModalContent = observer(
? `${alertmanager.publicURI}/api/v2/silence/${silenceID}`
: `${alertmanager.publicURI}/api/v1/silence/${silenceID}`;
this.deleteState.fetch = fetch(uri, {
this.deleteState.fetch = FetchWithCredentials(uri, {
method: "DELETE",
credentials: "include"
headers: alertmanager.headers
})
.then(result => {
if (isOpenAPI) {

View File

@@ -122,7 +122,7 @@ describe("<DeleteSilenceModalContent />", () => {
expect(tree.find("ErrorMessage")).toHaveLength(1);
});
it("[v1] sends a DELETE request after clicking 'Confirm' button ", async () => {
it("[v1] sends a DELETE request after clicking 'Confirm' button", async () => {
await VerifyResponse({ status: "success" });
expect(fetch.mock.calls[1][0]).toBe(
"http://am.example.com/api/v1/silence/123456789"
@@ -130,7 +130,7 @@ describe("<DeleteSilenceModalContent />", () => {
expect(fetch.mock.calls[1][1]).toMatchObject({ method: "DELETE" });
});
it("[v2] sends a DELETE request after clicking 'Confirm' button ", async () => {
it("[v2] sends a DELETE request after clicking 'Confirm' button", async () => {
alertmanager.version = "0.16.2";
await VerifyResponse({ status: "success" });
expect(fetch.mock.calls[1][0]).toBe(
@@ -139,6 +139,33 @@ describe("<DeleteSilenceModalContent />", () => {
expect(fetch.mock.calls[1][1]).toMatchObject({ method: "DELETE" });
});
it("[v1] sends headers from alertmanager config", async () => {
alertmanager.headers = { Authorization: "Basic ***" };
await VerifyResponse({ status: "success" });
expect(fetch.mock.calls[1][0]).toBe(
"http://am.example.com/api/v1/silence/123456789"
);
expect(fetch.mock.calls[1][1]).toMatchObject({
credentials: "include",
method: "DELETE",
headers: { Authorization: "Basic ***" }
});
});
it("[v1] sends headers from alertmanager config", async () => {
alertmanager.headers = { Authorization: "Basic ***" };
alertmanager.version = "0.16.2";
await VerifyResponse({ status: "success" });
expect(fetch.mock.calls[1][0]).toBe(
"http://am.example.com/api/v2/silence/123456789"
);
expect(fetch.mock.calls[1][1]).toMatchObject({
credentials: "include",
method: "DELETE",
headers: { Authorization: "Basic ***" }
});
});
it("'Confirm' button is no-op after successful DELETE", async () => {
const tree = await VerifyResponse({ status: "success" });
expect(fetch.mock.calls[1][0]).toBe(

View File

@@ -67,6 +67,7 @@ beforeEach(() => {
cluster: "default",
uri: "file:///mock",
publicURI: "http://example.com",
headers: {},
error: "",
version: "0.15.0",
clusterMembers: ["default"]
@@ -203,6 +204,7 @@ describe("<Silence />", () => {
cluster: "default",
uri: "file:///mock",
publicURI: "http://example.com",
headers: {},
error: "",
version: "0.15.0",
clusterMembers: ["default"]

View File

@@ -7,6 +7,7 @@ import { observer } from "mobx-react";
import Creatable from "react-select/creatable";
import { StaticLabels } from "Common/Query";
import { FetchWithCredentials } from "Common/Fetch";
import { FormatBackendURI } from "Stores/AlertStore";
import { Settings } from "Stores/Settings";
import { ReactSelectStyles } from "Components/MultiSelect";
@@ -33,9 +34,10 @@ const SortLabelName = observer(
});
populateNameSuggestions = action(() => {
this.nameSuggestionsFetch = fetch(FormatBackendURI(`labelNames.json`), {
credentials: "include"
})
this.nameSuggestionsFetch = FetchWithCredentials(
FormatBackendURI(`labelNames.json`),
{}
)
.then(
result => result.json(),
err => {

View File

@@ -15,6 +15,7 @@ import { faSearch } from "@fortawesome/free-solid-svg-icons/faSearch";
import { AlertStore, FormatBackendURI } from "Stores/AlertStore";
import { Settings } from "Stores/Settings";
import { IsMobile } from "Common/Device";
import { FetchWithCredentials } from "Common/Fetch";
import { FilterInputLabel } from "Components/Labels/FilterInputLabel";
import { AutosuggestTheme } from "./Constants";
import { History } from "./History";
@@ -72,9 +73,9 @@ const FilterInput = observer(
onSuggestionsFetchRequested = debounce(
action(({ value }) => {
if (value !== "") {
this.inputStore.suggestionsFetch = fetch(
this.inputStore.suggestionsFetch = FetchWithCredentials(
FormatBackendURI(`autocomplete.json?term=${value}`),
{ credentials: "include" }
{}
)
.then(
result => result.json(),

View File

@@ -22,6 +22,7 @@ beforeEach(() => {
name: "am1",
uri: "http://am1.example.com",
publicURI: "http://am1.example.com",
headers: {},
error: "",
version: "0.15.0",
cluster: "ha",
@@ -31,6 +32,7 @@ beforeEach(() => {
name: "am2",
uri: "http://am2.example.com",
publicURI: "http://am2.example.com",
headers: {},
error: "",
version: "0.15.0",
cluster: "ha",
@@ -40,6 +42,7 @@ beforeEach(() => {
name: "am3",
uri: "http://am3.example.com",
publicURI: "http://am3.example.com",
headers: {},
error: "",
version: "0.15.0",
cluster: "am3",

View File

@@ -8,6 +8,7 @@ import { SilenceFormMatcher } from "Models/SilenceForm";
import { MultiSelect } from "Components/MultiSelect";
import { ValidationError } from "Components/MultiSelect/ValidationError";
import { FormatBackendURI } from "Stores/AlertStore";
import { FetchWithCredentials } from "Common/Fetch";
const LabelNameInput = observer(
class LabelNameInput extends MultiSelect {
@@ -19,9 +20,10 @@ const LabelNameInput = observer(
populateNameSuggestions = action(() => {
const { matcher } = this.props;
this.nameSuggestionsFetch = fetch(FormatBackendURI(`labelNames.json`), {
credentials: "include"
})
this.nameSuggestionsFetch = FetchWithCredentials(
FormatBackendURI(`labelNames.json`),
{}
)
.then(
result => result.json(),
err => {
@@ -43,11 +45,9 @@ const LabelNameInput = observer(
populateValueSuggestions = action(() => {
const { matcher } = this.props;
this.valueSuggestionsFetch = fetch(
this.valueSuggestionsFetch = FetchWithCredentials(
FormatBackendURI(`labelValues.json?name=${matcher.name}`),
{
credentials: "include"
}
{}
)
.then(
result => result.json(),

View File

@@ -14,6 +14,7 @@ import { FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { SilenceFormMatcher } from "Models/SilenceForm";
import { TooltipWrapper } from "Components/TooltipWrapper";
import { FetchWithCredentials } from "Common/Fetch";
import { MatcherToFilter, AlertManagersToFilter } from "../Matchers";
const MatchCounter = observer(
@@ -54,7 +55,7 @@ const MatchCounter = observer(
const alertsURI =
FormatBackendURI("alerts.json?") + FormatAlertsQ(filters);
this.matchedAlerts.fetch = fetch(alertsURI, { credentials: "include" })
this.matchedAlerts.fetch = FetchWithCredentials(alertsURI, {})
.then(result => {
return result.json();
})

View File

@@ -15,6 +15,7 @@ import {
LabelSetList,
GroupListToUniqueLabelsList
} from "Components/LabelSetList";
import { FetchWithCredentials } from "Common/Fetch";
import { MatcherToFilter, AlertManagersToFilter } from "../Matchers";
const FetchError = ({ message }) => (
@@ -67,7 +68,7 @@ const SilencePreview = observer(
const alertsURI =
FormatBackendURI("alerts.json?") + FormatAlertsQ(filters);
this.matchedAlerts.fetch = fetch(alertsURI, { credentials: "include" })
this.matchedAlerts.fetch = FetchWithCredentials(alertsURI, {})
.then(result => {
return result.json();
})

View File

@@ -13,6 +13,7 @@ import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclama
import { APISilenceMatcher } from "Models/API";
import { AlertStore } from "Stores/AlertStore";
import { FetchWithCredentials } from "Common/Fetch";
const SubmitState = Object.freeze({
InProgress: "InProgress",
@@ -108,13 +109,13 @@ const SilenceSubmitProgress = observer(
? `${am.publicURI}/api/v2/silences`
: `${am.publicURI}/api/v1/silences`;
this.submitState.fetch = fetch(uri, {
this.submitState.fetch = FetchWithCredentials(uri, {
method: "POST",
body: JSON.stringify(payload),
headers: {
"Content-Type": "application/json"
},
credentials: "include"
"Content-Type": "application/json",
...am.headers
}
})
.then(result => {
if (isOpenAPI) {

View File

@@ -15,6 +15,7 @@ beforeEach(() => {
name: "mockAlertmanager",
uri: "file:///mock",
publicURI: "http://example.com",
headers: { foo: "bar" },
error: "",
version: "0.15.0",
cluster: "mockAlertmanager",
@@ -68,7 +69,7 @@ describe("<SilenceSubmitProgress />", () => {
const payload = fetch.mock.calls[0][1];
expect(payload).toMatchObject({
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", foo: "bar" },
body: JSON.stringify({
matchers: [],
startsAt: "now",
@@ -93,6 +94,7 @@ describe("<SilenceSubmitProgress />", () => {
name: "am1",
uri: "file:///mock",
publicURI: "http://am1.example.com",
headers: {},
error: "",
version: "0.15.0",
cluster: "ha",
@@ -102,6 +104,7 @@ describe("<SilenceSubmitProgress />", () => {
name: "am2",
uri: "file:///mock",
publicURI: "http://am2.example.com",
headers: {},
error: "",
version: "0.15.0",
cluster: "ha",
@@ -147,6 +150,7 @@ describe("<SilenceSubmitProgress />", () => {
name: "am1",
uri: "file:///mock",
publicURI: "http://am1.example.com",
headers: {},
error: "",
version: "0.15.0",
cluster: "ha",

View File

@@ -70,6 +70,7 @@ const APIAlertmanagerUpstream = PropTypes.exact({
cluster: PropTypes.string.isRequired,
uri: PropTypes.string.isRequired,
publicURI: PropTypes.string.isRequired,
headers: PropTypes.object.isRequired,
error: PropTypes.string.isRequired,
version: PropTypes.string.isRequired,
clusterMembers: PropTypes.arrayOf(PropTypes.string).isRequired

View File

@@ -6,6 +6,8 @@ import equal from "fast-deep-equal";
import qs from "qs";
import { FetchWithCredentials } from "Common/Fetch";
const QueryStringEncodeOptions = {
encodeValuesOnly: true, // don't encode q[]
indices: false // go-gin doesn't support parsing q[0]=foo&q[1]=bar
@@ -247,7 +249,7 @@ class AlertStore {
`alerts.json?sortOrder=${sortOrder}&sortLabel=${sortLabel}&sortReverse=${sortReverse}&`
) + FormatAPIFilterQuery(this.filters.values.map(f => f.raw));
return fetch(alertsURI, { credentials: "include" })
return FetchWithCredentials(alertsURI, {})
.then(result => {
this.status.setProcessing();
return result.json();

View File

@@ -71,6 +71,9 @@ const MockAlertmanager = () => ({
cluster: "default",
uri: "http://localhost",
publicURI: "http://am.example.com",
headers: {
Authorization: "Basic foo bar"
},
error: "",
version: "0.15.0",
clusterMembers: ["default"]