1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Introduce initial authorization attempt for SHELLs and KUBECTL

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2022-12-06 15:16:59 -05:00
parent 2e6ced456d
commit 7708e662e1
12 changed files with 116 additions and 59 deletions

View File

@ -6,4 +6,4 @@
/** /**
* This is the header name that we use for request authentication * This is the header name that we use for request authentication
*/ */
export const lensAuthenticationHeader = "LENS-AUTHENTICATION"; export const lensAuthenticationHeader = "Authorization";

View File

@ -3,7 +3,7 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import { getDiForUnitTesting } from "../getDiForUnitTesting"; import { getDiForUnitTesting } from "../getDiForUnitTesting";
import { KubeconfigManager } from "../kubeconfig-manager/kubeconfig-manager"; import type { KubeconfigManager } from "../kubeconfig-manager/kubeconfig-manager";
import type { Cluster } from "../../common/cluster/cluster"; import type { Cluster } from "../../common/cluster/cluster";
import createKubeconfigManagerInjectable from "../kubeconfig-manager/create-kubeconfig-manager.injectable"; import createKubeconfigManagerInjectable from "../kubeconfig-manager/create-kubeconfig-manager.injectable";
import { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token"; import { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token";
@ -30,6 +30,7 @@ import removePathInjectable from "../../common/fs/remove.injectable";
import pathExistsSyncInjectable from "../../common/fs/path-exists-sync.injectable"; import pathExistsSyncInjectable from "../../common/fs/path-exists-sync.injectable";
import readJsonSyncInjectable from "../../common/fs/read-json-sync.injectable"; import readJsonSyncInjectable from "../../common/fs/read-json-sync.injectable";
import writeJsonSyncInjectable from "../../common/fs/write-json-sync.injectable"; import writeJsonSyncInjectable from "../../common/fs/write-json-sync.injectable";
import lensProxyPortInjectable from "../lens-proxy/lens-proxy-port.injectable";
const clusterServerUrl = "https://192.168.64.3:8443"; const clusterServerUrl = "https://192.168.64.3:8443";
@ -90,6 +91,8 @@ describe("kubeconfig manager tests", () => {
ensureServer: ensureServerMock, ensureServer: ensureServerMock,
})); }));
di.inject(lensProxyPortInjectable).set(9191);
const createCluster = di.inject(createClusterInjectionToken); const createCluster = di.inject(createClusterInjectionToken);
createKubeconfigManager = di.inject(createKubeconfigManagerInjectable); createKubeconfigManager = di.inject(createKubeconfigManagerInjectable);
@ -102,8 +105,6 @@ describe("kubeconfig manager tests", () => {
clusterServerUrl, clusterServerUrl,
}); });
jest.spyOn(KubeconfigManager.prototype, "resolveProxyUrl", "get").mockReturnValue("http://127.0.0.1:9191/foo");
kubeConfManager = createKubeconfigManager(clusterFake); kubeConfManager = createKubeconfigManager(clusterFake);
}); });

View File

@ -14,6 +14,7 @@ import getDirnameOfPathInjectable from "../../common/path/get-dirname.injectable
import pathExistsInjectable from "../../common/fs/path-exists.injectable"; import pathExistsInjectable from "../../common/fs/path-exists.injectable";
import writeFileInjectable from "../../common/fs/write-file.injectable"; import writeFileInjectable from "../../common/fs/write-file.injectable";
import removePathInjectable from "../../common/fs/remove.injectable"; import removePathInjectable from "../../common/fs/remove.injectable";
import authHeaderValueInjectable from "../lens-proxy/auth-header-value.injectable";
export interface KubeConfigManagerInstantiationParameter { export interface KubeConfigManagerInstantiationParameter {
cluster: Cluster; cluster: Cluster;
@ -29,6 +30,7 @@ const createKubeconfigManagerInjectable = getInjectable({
directoryForTemp: di.inject(directoryForTempInjectable), directoryForTemp: di.inject(directoryForTempInjectable),
logger: di.inject(loggerInjectable), logger: di.inject(loggerInjectable),
lensProxyPort: di.inject(lensProxyPortInjectable), lensProxyPort: di.inject(lensProxyPortInjectable),
authHeaderValue: di.inject(authHeaderValueInjectable),
joinPaths: di.inject(joinPathsInjectable), joinPaths: di.inject(joinPathsInjectable),
getDirnameOfPath: di.inject(getDirnameOfPathInjectable), getDirnameOfPath: di.inject(getDirnameOfPathInjectable),
removePath: di.inject(removePathInjectable), removePath: di.inject(removePathInjectable),

View File

@ -15,11 +15,13 @@ import type { GetDirnameOfPath } from "../../common/path/get-dirname.injectable"
import type { PathExists } from "../../common/fs/path-exists.injectable"; import type { PathExists } from "../../common/fs/path-exists.injectable";
import type { RemovePath } from "../../common/fs/remove.injectable"; import type { RemovePath } from "../../common/fs/remove.injectable";
import type { WriteFile } from "../../common/fs/write-file.injectable"; import type { WriteFile } from "../../common/fs/write-file.injectable";
import { lensAuthenticationHeader } from "../../common/vars/auth-header";
export interface KubeconfigManagerDependencies { export interface KubeconfigManagerDependencies {
readonly directoryForTemp: string; readonly directoryForTemp: string;
readonly logger: Logger; readonly logger: Logger;
readonly lensProxyPort: { get: () => number }; readonly lensProxyPort: { get: () => number };
readonly authHeaderValue: string;
joinPaths: JoinPaths; joinPaths: JoinPaths;
getDirnameOfPath: GetDirnameOfPath; getDirnameOfPath: GetDirnameOfPath;
pathExists: PathExists; pathExists: PathExists;
@ -47,7 +49,7 @@ export class KubeconfigManager {
* @returns The path to the temporary kubeconfig * @returns The path to the temporary kubeconfig
*/ */
async getPath(): Promise<string> { async getPath(): Promise<string> {
if (this.tempFilePath === null || !(await this.dependencies.pathExists(this.tempFilePath))) { if (this.tempFilePath === null) {
return await this.ensureFile(); return await this.ensureFile();
} }
@ -85,10 +87,6 @@ export class KubeconfigManager {
} }
} }
get resolveProxyUrl() {
return `http://127.0.0.1:${this.dependencies.lensProxyPort.get()}/${this.cluster.id}`;
}
/** /**
* Creates new "temporary" kubeconfig that point to the kubectl-proxy. * Creates new "temporary" kubeconfig that point to the kubectl-proxy.
* This way any user of the config does not need to know anything about the auth etc. details. * This way any user of the config does not need to know anything about the auth etc. details.
@ -101,16 +99,23 @@ export class KubeconfigManager {
`kubeconfig-${id}`, `kubeconfig-${id}`,
); );
const kubeConfig = await cluster.getKubeconfig(); const kubeConfig = await cluster.getKubeconfig();
const searchParams = new URLSearchParams({
[lensAuthenticationHeader]: this.dependencies.authHeaderValue,
});
const proxyConfig: PartialDeep<KubeConfig> = { const proxyConfig: PartialDeep<KubeConfig> = {
currentContext: contextName, currentContext: contextName,
clusters: [ clusters: [
{ {
name: contextName, name: contextName,
server: this.resolveProxyUrl, server: `http://127.0.0.1:${this.dependencies.lensProxyPort.get()}/${this.cluster.id}?${searchParams}`,
}, },
], ],
users: [ users: [
{ name: "proxy" }, {
name: "proxy",
token: this.dependencies.authHeaderValue,
},
], ],
contexts: [ contexts: [
{ {

View File

@ -8,7 +8,7 @@ import { lensAuthenticationHeaderValueInjectionToken } from "../../common/auth/h
const authHeaderValueInjectable = getInjectable({ const authHeaderValueInjectable = getInjectable({
id: "auth-header-value", id: "auth-header-value",
instantiate: () => uuid.v4(), instantiate: () => `Bearer ${uuid.v4()}`,
injectionToken: lensAuthenticationHeaderValueInjectionToken, injectionToken: lensAuthenticationHeaderValueInjectionToken,
}); });

View File

@ -19,6 +19,7 @@ import type { RouteRequest } from "../router/route-request.injectable";
import { lensAuthenticationHeader } from "../../common/vars/auth-header"; import { lensAuthenticationHeader } from "../../common/vars/auth-header";
import { contentTypes } from "../router/router-content-types"; import { contentTypes } from "../router/router-content-types";
import { writeServerResponseFor } from "../router/write-server-response"; import { writeServerResponseFor } from "../router/write-server-response";
import { URL } from "url";
type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | undefined; type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | undefined;
@ -78,10 +79,10 @@ export class LensProxy {
this.proxyServer this.proxyServer
.on("upgrade", (req: ServerIncomingMessage, socket: net.Socket, head: Buffer) => { .on("upgrade", (req: ServerIncomingMessage, socket: net.Socket, head: Buffer) => {
const cluster = dependencies.getClusterForRequest(req); const cluster = this.dependencies.getClusterForRequest(req);
const authHeader = req.headers[lensAuthenticationHeader.toLowerCase()]; const url = new URL(req.url, "http://localhost");
if (authHeader !== this.dependencies.authHeaderValue) { if (url.searchParams.get(lensAuthenticationHeader) !== this.dependencies.authHeaderValue) {
this.dependencies.logger.warn(`[LENS-PROXY]: Request from url=${req.url} missing authentication`); this.dependencies.logger.warn(`[LENS-PROXY]: Request from url=${req.url} missing authentication`);
socket.destroy(); socket.destroy();
@ -92,11 +93,21 @@ export class LensProxy {
this.dependencies.logger.error(`[LENS-PROXY]: Could not find cluster for upgrade request from url=${req.url}`); this.dependencies.logger.error(`[LENS-PROXY]: Could not find cluster for upgrade request from url=${req.url}`);
socket.destroy(); socket.destroy();
} else { } else {
const isInternal = req.url.startsWith(`${apiPrefix}?`); void (async () => {
const reqHandler = isInternal ? dependencies.shellApiRequest : dependencies.kubeApiUpgradeRequest; try {
if (url.pathname === apiPrefix) {
await dependencies.shellApiRequest({ req, socket, head, cluster });
} else if (url.pathname.startsWith(`${apiKubePrefix}/`)) {
req.url = req.url.slice(apiKubePrefix.length);
(async () => reqHandler({ req, socket, head, cluster }))() await dependencies.kubeApiUpgradeRequest({ req, socket, head, cluster });
.catch(error => this.dependencies.logger.error("[LENS-PROXY]: failed to handle proxy upgrade", error)); } else {
this.dependencies.logger.warn(`[LENS-PROXY]: unknown upgrade request, url=${req.url}`);
}
} catch (error) {
this.dependencies.logger.error("[LENS-PROXY]: failed to handle proxy upgrade", error);
}
})();
} }
}); });
} }
@ -224,7 +235,6 @@ export class LensProxy {
protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ClusterContextHandler): Promise<httpProxy.ServerOptions | undefined> { protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ClusterContextHandler): Promise<httpProxy.ServerOptions | undefined> {
if (req.url?.startsWith(apiKubePrefix)) { if (req.url?.startsWith(apiKubePrefix)) {
delete req.headers.authorization;
req.url = req.url.replace(apiKubePrefix, ""); req.url = req.url.replace(apiKubePrefix, "");
return contextHandler.getApiTarget(isLongRunningRequest(req.url)); return contextHandler.getApiTarget(isLongRunningRequest(req.url));

View File

@ -7,13 +7,12 @@ import { chunk } from "lodash";
import type { ConnectionOptions } from "tls"; import type { ConnectionOptions } from "tls";
import { connect } from "tls"; import { connect } from "tls";
import url from "url"; import url from "url";
import { apiKubePrefix } from "../../../common/vars";
import type { ProxyApiRequestArgs } from "./types"; import type { ProxyApiRequestArgs } from "./types";
const skipRawHeaders = new Set(["Host", "Authorization"]); const skipRawHeaders = new Set(["Host", "Authorization"]);
export async function kubeApiUpgradeRequest({ req, socket, head, cluster }: ProxyApiRequestArgs) { export async function kubeApiUpgradeRequest({ req, socket, head, cluster }: ProxyApiRequestArgs) {
const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, ""); const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url;
const proxyCa = cluster.contextHandler.resolveAuthProxyCa(); const proxyCa = cluster.contextHandler.resolveAuthProxyCa();
const apiUrl = url.parse(cluster.apiUrl); const apiUrl = url.parse(cluster.apiUrl);
const pUrl = url.parse(proxyUrl); const pUrl = url.parse(proxyUrl);

View File

@ -16,7 +16,15 @@ describe("WebsocketApi tests", () => {
let api: TestWebSocketApi; let api: TestWebSocketApi;
beforeEach(() => { beforeEach(() => {
api = new TestWebSocketApi({}); api = new TestWebSocketApi({
authHeaderValue: "some-value",
defaultParams: {
flushOnOpen: true,
logging: false,
pingMessage: "{}",
reconnectDelay: 10,
},
}, {});
}); });
describe("before connection", () => { describe("before connection", () => {

View File

@ -4,7 +4,9 @@
*/ */
import { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import assert from "assert"; import assert from "assert";
import authHeaderValueInjectable from "../auth/auth-header.injectable";
import hostedClusterIdInjectable from "../cluster-frame-context/hosted-cluster-id.injectable"; import hostedClusterIdInjectable from "../cluster-frame-context/hosted-cluster-id.injectable";
import defaultWebsocketApiParamsInjectable from "./default-websocket-params.injectable";
import type { TerminalApiQuery } from "./terminal-api"; import type { TerminalApiQuery } from "./terminal-api";
import { TerminalApi } from "./terminal-api"; import { TerminalApi } from "./terminal-api";
@ -14,12 +16,16 @@ const createTerminalApiInjectable = getInjectable({
id: "create-terminal-api", id: "create-terminal-api",
instantiate: (di): CreateTerminalApi => { instantiate: (di): CreateTerminalApi => {
const hostedClusterId = di.inject(hostedClusterIdInjectable); const hostedClusterId = di.inject(hostedClusterIdInjectable);
const authHeaderValue = di.inject(authHeaderValueInjectable);
const defaultParams = di.inject(defaultWebsocketApiParamsInjectable);
return (query) => { return (query) => {
assert(hostedClusterId, "Can only create terminal APIs within a cluster frame"); assert(hostedClusterId, "Can only create terminal APIs within a cluster frame");
return new TerminalApi({ return new TerminalApi({
hostedClusterId, hostedClusterId,
authHeaderValue,
defaultParams,
}, query); }, query);
}; };
}, },

View File

@ -0,0 +1,22 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { TerminalMessage } from "../../common/terminal/channels";
import { TerminalChannels } from "../../common/terminal/channels";
import isDevelopmentInjectable from "../../common/vars/is-development.injectable";
import type { DefaultWebsocketApiParams } from "./websocket-api";
const defaultWebsocketApiParamsInjectable = getInjectable({
id: "default-websocket-api-params",
instantiate: (di): DefaultWebsocketApiParams => ({
logging: di.inject(isDevelopmentInjectable),
reconnectDelay: 10,
flushOnOpen: true,
pingMessage: JSON.stringify({ type: TerminalChannels.PING } as TerminalMessage),
}),
});
export default defaultWebsocketApiParamsInjectable;

View File

@ -3,15 +3,16 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import type { WebSocketEvents } from "./websocket-api"; import type { WebSocketApiDependencies, WebSocketEvents } from "./websocket-api";
import { WebSocketApi } from "./websocket-api"; import { WebSocketApi } from "./websocket-api";
import isEqual from "lodash/isEqual"; import isEqual from "lodash/isEqual";
import url from "url"; import { URLSearchParams } from "url";
import { makeObservable, observable } from "mobx"; import { makeObservable, observable } from "mobx";
import { ipcRenderer } from "electron"; import { ipcRenderer } from "electron";
import logger from "../../common/logger"; import logger from "../../common/logger";
import { once } from "lodash"; import { once } from "lodash";
import { type TerminalMessage, TerminalChannels } from "../../common/terminal/channels"; import { type TerminalMessage, TerminalChannels } from "../../common/terminal/channels";
import { object } from "../utils";
enum TerminalColor { enum TerminalColor {
RED = "\u001b[31m", RED = "\u001b[31m",
@ -25,7 +26,7 @@ enum TerminalColor {
NO_COLOR = "\u001b[0m", NO_COLOR = "\u001b[0m",
} }
export interface TerminalApiQuery extends Record<string, string | undefined> { export interface TerminalApiQuery extends Partial<Record<string, string>> {
id: string; id: string;
node?: string; node?: string;
type?: string; type?: string;
@ -36,7 +37,7 @@ export interface TerminalEvents extends WebSocketEvents {
connected: () => void; connected: () => void;
} }
export interface TerminalApiDependencies { export interface TerminalApiDependencies extends WebSocketApiDependencies {
readonly hostedClusterId: string; readonly hostedClusterId: string;
} }
@ -46,7 +47,7 @@ export class TerminalApi extends WebSocketApi<TerminalEvents> {
@observable public isReady = false; @observable public isReady = false;
constructor(protected readonly dependencies: TerminalApiDependencies, protected readonly query: TerminalApiQuery) { constructor(protected readonly dependencies: TerminalApiDependencies, protected readonly query: TerminalApiQuery) {
super({ super(dependencies, {
flushOnOpen: false, flushOnOpen: false,
pingInterval: 30, pingInterval: 30,
}); });
@ -73,17 +74,12 @@ export class TerminalApi extends WebSocketApi<TerminalEvents> {
} }
const { hostname, protocol, port } = location; const { hostname, protocol, port } = location;
const socketUrl = url.format({ const wsProtocol = protocol.includes("https") ? "wss" : "ws";
protocol: protocol.includes("https") ? "wss" : "ws", const searchParams = new URLSearchParams([
hostname, ...object.entries(this.query),
port, ["shellToken", Buffer.from(authTokenArray).toString("base64")],
pathname: "/api", ]);
query: { const socketUrl = `${wsProtocol}://${hostname}:${port}/api?${searchParams}`;
...this.query,
shellToken: Buffer.from(authTokenArray).toString("base64"),
},
slashes: true,
});
const onReady = once((data?: string) => { const onReady = once((data?: string) => {
this.isReady = true; this.isReady = true;
@ -128,9 +124,9 @@ export class TerminalApi extends WebSocketApi<TerminalEvents> {
} }
} }
protected _onMessage({ data, ...evt }: MessageEvent<string>): void { protected _onMessage({ data, ...evt }: MessageEvent): void {
try { try {
const message = JSON.parse(data) as TerminalMessage; const message = JSON.parse(data as string) as TerminalMessage;
switch (message.type) { switch (message.type) {
case TerminalChannels.STDOUT: case TerminalChannels.STDOUT:

View File

@ -7,11 +7,10 @@ import { observable, makeObservable } from "mobx";
import EventEmitter from "events"; import EventEmitter from "events";
import type TypedEventEmitter from "typed-emitter"; import type TypedEventEmitter from "typed-emitter";
import type { Arguments } from "typed-emitter"; import type { Arguments } from "typed-emitter";
import { isDevelopment } from "../../common/vars";
import type { Defaulted } from "../utils"; import type { Defaulted } from "../utils";
import { TerminalChannels, type TerminalMessage } from "../../common/terminal/channels"; import { lensAuthenticationHeader } from "../../common/vars/auth-header";
interface WebsocketApiParams { export interface WebsocketApiParams {
/** /**
* Flush pending commands on open socket * Flush pending commands on open socket
* *
@ -64,28 +63,32 @@ export interface WebSocketEvents {
close: () => void; close: () => void;
} }
export interface WebSocketApiDependencies {
readonly authHeaderValue: string;
readonly defaultParams: DefaultWebsocketApiParams;
}
export type DefaultWebsocketApiParamNames = "logging" | "reconnectDelay" | "flushOnOpen" | "pingMessage";
export type DefaultWebsocketApiParams = Pick<Defaulted<WebsocketApiParams, DefaultWebsocketApiParamNames>, DefaultWebsocketApiParamNames>;
export class WebSocketApi<Events extends WebSocketEvents> extends (EventEmitter as { new<T>(): TypedEventEmitter<T> })<Events> { export class WebSocketApi<Events extends WebSocketEvents> extends (EventEmitter as { new<T>(): TypedEventEmitter<T> })<Events> {
protected socket: WebSocket | null = null; protected socket: WebSocket | null = null;
protected pendingCommands: string[] = []; protected readonly pendingCommands: string[] = [];
protected reconnectTimer?: number; protected reconnectTimer?: number;
protected pingTimer?: number; protected pingTimer?: number;
protected params: Defaulted<WebsocketApiParams, keyof typeof WebSocketApi["defaultParams"]>; protected readonly params: Defaulted<WebsocketApiParams, "logging" | "reconnectDelay" | "flushOnOpen" | "pingMessage">;
@observable readyState = WebSocketApiState.PENDING; @observable readyState = WebSocketApiState.PENDING;
private static readonly defaultParams = { constructor(
logging: isDevelopment, protected readonly dependencies: WebSocketApiDependencies,
reconnectDelay: 10, rawParams: WebsocketApiParams,
flushOnOpen: true, ) {
pingMessage: JSON.stringify({ type: TerminalChannels.PING } as TerminalMessage),
};
constructor(params: WebsocketApiParams) {
super(); super();
makeObservable(this); makeObservable(this);
this.params = { this.params = {
...WebSocketApi.defaultParams, ...this.dependencies.defaultParams,
...params, ...rawParams,
}; };
const { pingInterval } = this.params; const { pingInterval } = this.params;
@ -102,6 +105,11 @@ export class WebSocketApi<Events extends WebSocketEvents> extends (EventEmitter
// close previous connection first // close previous connection first
this.socket?.close(); this.socket?.close();
const authParam = new URLSearchParams({ [lensAuthenticationHeader]: this.dependencies.authHeaderValue });
const addingParam = url.includes("?") ? "&" : "?";
url += `${addingParam}${authParam}`;
// start new connection // start new connection
this.socket = new WebSocket(url); this.socket = new WebSocket(url);
this.socket.addEventListener("open", ev => this._onOpen(ev)); this.socket.addEventListener("open", ev => this._onOpen(ev));
@ -129,7 +137,7 @@ export class WebSocketApi<Events extends WebSocketEvents> extends (EventEmitter
if (!this.socket) return; if (!this.socket) return;
this.socket.close(); this.socket.close();
this.socket = null; this.socket = null;
this.pendingCommands = []; this.pendingCommands.length = 0;
this.clearAllListeners(); this.clearAllListeners();
clearTimeout(this.reconnectTimer); clearTimeout(this.reconnectTimer);
clearInterval(this.pingTimer); clearInterval(this.pingTimer);
@ -153,7 +161,7 @@ export class WebSocketApi<Events extends WebSocketEvents> extends (EventEmitter
protected flush() { protected flush() {
const commands = this.pendingCommands; const commands = this.pendingCommands;
this.pendingCommands = []; this.pendingCommands.length = 0;
for (const command of commands) { for (const command of commands) {
this.send(command); this.send(command);
@ -168,7 +176,7 @@ export class WebSocketApi<Events extends WebSocketEvents> extends (EventEmitter
} }
protected _onMessage({ data }: MessageEvent): void { protected _onMessage({ data }: MessageEvent): void {
this.emit("data", ...[data] as Arguments<Events["data"]>); this.emit("data", ...[data] as string[] as Arguments<Events["data"]>);
this.writeLog("%cMESSAGE", "color:black;font-weight:bold;", data); this.writeLog("%cMESSAGE", "color:black;font-weight:bold;", data);
} }