mirror of
https://github.com/amir20/dozzle.git
synced 2025-12-24 06:28:42 +01:00
Adds authentication as a feature with simple username and password configuration (#1127)
* Starting work for auth * Adds flags * Does redirect * Refactors code * Adds vue templates * Completes logic * Cleans up some of the error messages * Refactors * Updates readme * Updates titles * Adds logout * Cleans up imports
This commit is contained in:
2
.reflex
2
.reflex
@@ -1 +1 @@
|
|||||||
-r '\.go$' -R 'node_modules' -R '^static/' -R '^.cache/' -G '*_test.go' -s -- go run main.go --level debug
|
-r '\.(go|vue|html|js|css)$' -R 'node_modules' -G '*_test.go' -s -- go run main.go --level debug --username amir --password pass --key test
|
||||||
|
|||||||
3
Makefile
3
Makefile
@@ -28,5 +28,8 @@ test: fake_static
|
|||||||
build: static
|
build: static
|
||||||
CGO_ENABLED=0 go build -ldflags "-s -w"
|
CGO_ENABLED=0 go build -ldflags "-s -w"
|
||||||
|
|
||||||
|
dev:
|
||||||
|
yarn dev
|
||||||
|
|
||||||
int:
|
int:
|
||||||
docker-compose -f integration/docker-compose.test.yml up --build --force-recreate --exit-code-from integration
|
docker-compose -f integration/docker-compose.test.yml up --build --force-recreate --exit-code-from integration
|
||||||
|
|||||||
38
README.md
38
README.md
@@ -10,33 +10,33 @@ Dozzle is a simple, lightweight application that provides you with a web based i
|
|||||||
[](https://hub.docker.com/r/amir20/dozzle/)
|
[](https://hub.docker.com/r/amir20/dozzle/)
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* Intelligent fuzzy search for container names 🤖
|
- Intelligent fuzzy search for container names 🤖
|
||||||
* Search logs using regex 🔦
|
- Search logs using regex 🔦
|
||||||
* Small memory footprint 🏎
|
- Small memory footprint 🏎
|
||||||
* Split screen for viewing multiple logs
|
- Split screen for viewing multiple logs
|
||||||
* Download logs easy
|
- Download logs easy
|
||||||
* Live stats with memory and CPU usage
|
- Live stats with memory and CPU usage
|
||||||
|
- Authentication with username and password 🚨
|
||||||
|
|
||||||
While dozzle should work for most, it is not meant to be a full logging solution. For enterprise applications, products like [Loggly](https://www.loggly.com), [Papertrail](https://papertrailapp.com) or [Kibana](https://www.elastic.co/products/kibana) are more suited.
|
While Dozzle should work for most, it is not meant to be a full logging solution. For enterprise applications, products like [Loggly](https://www.loggly.com), [Papertrail](https://papertrailapp.com) or [Kibana](https://www.elastic.co/products/kibana) are more suited.
|
||||||
|
|
||||||
Dozzle won't cost any money and aims to focus only on real-time logs.
|
Dozzle won't cost any money and aims to focus only on real-time logs.
|
||||||
|
|
||||||
## Getting dozzle
|
## Getting Dozzle
|
||||||
|
|
||||||
Dozzle is a very small Docker container (4 MB compressed). Pull the latest release from the index:
|
Dozzle is a very small Docker container (4 MB compressed). Pull the latest release from the index:
|
||||||
|
|
||||||
$ docker pull amir20/dozzle:latest
|
$ docker pull amir20/dozzle:latest
|
||||||
|
|
||||||
## Using dozzle
|
## Using Dozzle
|
||||||
|
|
||||||
The simplest way to use dozzle is to run the docker container. Also, mount the Docker Unix socket with `--volume` to `/var/run/docker.sock`:
|
The simplest way to use dozzle is to run the docker container. Also, mount the Docker Unix socket with `--volume` to `/var/run/docker.sock`:
|
||||||
|
|
||||||
$ docker run --name dozzle -d --volume=/var/run/docker.sock:/var/run/docker.sock -p 8888:8080 amir20/dozzle:latest
|
$ docker run --name dozzle -d --volume=/var/run/docker.sock:/var/run/docker.sock -p 8888:8080 amir20/dozzle:latest
|
||||||
|
|
||||||
dozzle will be available at [http://localhost:8888/](http://localhost:8888/). You can change `-p 8888:8080` to any port. For example, if you want to view dozzle over port 4040 then you would do `-p 4040:8080`.
|
Dozzle will be available at [http://localhost:8888/](http://localhost:8888/). You can change `-p 8888:8080` to any port. For example, if you want to view dozzle over port 4040 then you would do `-p 4040:8080`.
|
||||||
|
|
||||||
### With Docker swarm
|
### With Docker swarm
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ dozzle will be available at [http://localhost:8888/](http://localhost:8888/). Yo
|
|||||||
|
|
||||||
#### Security
|
#### Security
|
||||||
|
|
||||||
dozzle doesn't support authentication out of the box. You can control the device dozzle binds to by passing `--addr` parameter. For example,
|
You can control the device Dozzle binds to by passing `--addr` parameter. For example,
|
||||||
|
|
||||||
$ docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8888:1224 amir20/dozzle:latest --addr localhost:1224
|
$ docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8888:1224 amir20/dozzle:latest --addr localhost:1224
|
||||||
|
|
||||||
@@ -73,14 +73,16 @@ If you wish to restrict the containers shown you can pass the `--filter` paramet
|
|||||||
|
|
||||||
this would then only allow you to view containers with a name starting with "foo". You can use other filters like `status` as well, please check the official docker [command line docs](https://docs.docker.com/engine/reference/commandline/ps/#filtering) for available filters.
|
this would then only allow you to view containers with a name starting with "foo". You can use other filters like `status` as well, please check the official docker [command line docs](https://docs.docker.com/engine/reference/commandline/ps/#filtering) for available filters.
|
||||||
|
|
||||||
|
Dozzle supports very simple authentication out of the box with username and password. You should deploy using SSL to keep the credentials safe.
|
||||||
|
|
||||||
#### Changing base URL
|
#### Changing base URL
|
||||||
|
|
||||||
dozzle by default mounts to "/". If you want to control the base path you can use the `--base` option. For example, if you want to mount at "/foobar",
|
Dozzle by default mounts to "/". If you want to control the base path you can use the `--base` option. For example, if you want to mount at "/foobar",
|
||||||
then you can override by using `--base /foobar`. See env variables below for using `DOZZLE_BASE` to change this.
|
then you can override by using `--base /foobar`. See env variables below for using `DOZZLE_BASE` to change this.
|
||||||
|
|
||||||
$ docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle:latest --base /foobar
|
$ docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle:latest --base /foobar
|
||||||
|
|
||||||
dozzle will be available at [http://localhost:8080/foobar/](http://localhost:8080/foobar/).
|
Dozzle will be available at [http://localhost:8080/foobar/](http://localhost:8080/foobar/).
|
||||||
|
|
||||||
#### Environment variables and configuration
|
#### Environment variables and configuration
|
||||||
|
|
||||||
@@ -94,6 +96,11 @@ Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can
|
|||||||
| n/a | `DOCKER_API_VERSION` | not set |
|
| n/a | `DOCKER_API_VERSION` | not set |
|
||||||
| `--tailSize` | `DOZZLE_TAILSIZE` | `300` |
|
| `--tailSize` | `DOZZLE_TAILSIZE` | `300` |
|
||||||
| `--filter` | `DOZZLE_FILTER` | `""` |
|
| `--filter` | `DOZZLE_FILTER` | `""` |
|
||||||
|
| `--username` | `DOZZLE_USERNAME` | `""` |
|
||||||
|
| `--password` | `DOZZLE_PASSWORD` | `""` |
|
||||||
|
| `--key` | `DOZZLE_KEY` | `""` |
|
||||||
|
|
||||||
|
Note: When using username and password `DOZZLE_KEY` is required for session management.
|
||||||
|
|
||||||
## Troubleshooting and FAQs
|
## Troubleshooting and FAQs
|
||||||
|
|
||||||
@@ -121,6 +128,7 @@ Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can
|
|||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -132,8 +140,8 @@ Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can
|
|||||||
- Only allow `<script>` and `<style>` files from `self`
|
- Only allow `<script>` and `<style>` files from `self`
|
||||||
|
|
||||||
Dozzle opens all links with `rel="noopener"`.
|
Dozzle opens all links with `rel="noopener"`.
|
||||||
</details>
|
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<mobile-menu v-if="isMobile"></mobile-menu>
|
<mobile-menu v-if="isMobile"></mobile-menu>
|
||||||
|
|
||||||
<splitpanes @resized="onResized($event)">
|
<splitpanes @resized="onResized($event)">
|
||||||
<pane min-size="10" :size="settings.menuWidth" v-if="!isMobile && !collapseNav">
|
<pane min-size="10" :size="settings.menuWidth" v-if="!authorizationNeeded && !isMobile && !collapseNav">
|
||||||
<side-menu @search="showFuzzySearch"></side-menu>
|
<side-menu @search="showFuzzySearch"></side-menu>
|
||||||
</pane>
|
</pane>
|
||||||
<pane min-size="10">
|
<pane min-size="10">
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
<pane class="has-min-height router-view">
|
<pane class="has-min-height router-view">
|
||||||
<router-view></router-view>
|
<router-view></router-view>
|
||||||
</pane>
|
</pane>
|
||||||
<pane v-for="other in activeContainers" :key="other.id" v-if="!isMobile">
|
<template v-if="!isMobile">
|
||||||
|
<pane v-for="other in activeContainers" :key="other.id">
|
||||||
<log-container
|
<log-container
|
||||||
:id="other.id"
|
:id="other.id"
|
||||||
show-title
|
show-title
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
@close="removeActiveContainer(other)"
|
@close="removeActiveContainer(other)"
|
||||||
></log-container>
|
></log-container>
|
||||||
</pane>
|
</pane>
|
||||||
|
</template>
|
||||||
</splitpanes>
|
</splitpanes>
|
||||||
</pane>
|
</pane>
|
||||||
</splitpanes>
|
</splitpanes>
|
||||||
@@ -28,7 +30,7 @@
|
|||||||
class="button is-small is-rounded is-settings-control"
|
class="button is-small is-rounded is-settings-control"
|
||||||
:class="{ collapsed: collapseNav }"
|
:class="{ collapsed: collapseNav }"
|
||||||
id="hide-nav"
|
id="hide-nav"
|
||||||
v-if="!isMobile"
|
v-if="!isMobile && !authorizationNeeded"
|
||||||
>
|
>
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<icon :name="collapseNav ? 'chevron-right' : 'chevron-left'"></icon>
|
<icon :name="collapseNav ? 'chevron-right' : 'chevron-left'"></icon>
|
||||||
@@ -106,7 +108,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(["isMobile", "settings", "containers"]),
|
...mapState(["isMobile", "settings", "containers", "authorizationNeeded"]),
|
||||||
...mapGetters(["visibleContainers", "activeContainers"]),
|
...mapGetters(["visibleContainers", "activeContainers"]),
|
||||||
hasSmallerScrollbars() {
|
hasSmallerScrollbars() {
|
||||||
return this.settings.smallerScrollbars;
|
return this.settings.smallerScrollbars;
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
<script type="application/json" id="config__json">
|
<script type="application/json" id="config__json">
|
||||||
{
|
{
|
||||||
"base": "{{ .Base }}",
|
"base": "{{ .Base }}",
|
||||||
"version": "{{ .Version }}"
|
"version": "{{ .Version }}",
|
||||||
|
"authorizationNeeded": "{{ .AuthorizationNeeded }}",
|
||||||
|
"secured": "{{ .Secured }}"
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import Autocomplete from "buefy/dist/esm/autocomplete";
|
|||||||
import store from "./store";
|
import store from "./store";
|
||||||
import config from "./store/config";
|
import config from "./store/config";
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import { Container, Settings, Index, Show, ContainerNotFound, PageNotFound } from "./pages";
|
import { Container, Settings, Index, Show, ContainerNotFound, PageNotFound, Login } from "./pages";
|
||||||
|
|
||||||
Vue.use(VueRouter);
|
Vue.use(VueRouter);
|
||||||
Vue.use(Meta);
|
Vue.use(Meta);
|
||||||
@@ -47,6 +47,11 @@ const routes = [
|
|||||||
component: Show,
|
component: Show,
|
||||||
name: "show",
|
name: "show",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/login",
|
||||||
|
component: Login,
|
||||||
|
name: "login",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/*",
|
path: "/*",
|
||||||
component: PageNotFound,
|
component: PageNotFound,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { mapActions, mapGetters, mapState } from "vuex";
|
import { mapGetters } from "vuex";
|
||||||
import Search from "../components/Search";
|
import Search from "../components/Search";
|
||||||
import LogContainer from "../components/LogContainer";
|
import LogContainer from "../components/LogContainer";
|
||||||
|
|
||||||
|
|||||||
@@ -14,5 +14,10 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
name: "ContainerNotFound",
|
name: "ContainerNotFound",
|
||||||
|
metaInfo() {
|
||||||
|
return {
|
||||||
|
title: "Not Found",
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,8 +3,15 @@
|
|||||||
<section class="hero is-small mt-4">
|
<section class="hero is-small mt-4">
|
||||||
<div class="hero-body">
|
<div class="hero-body">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
<h1 class="title">Hello, there!</h1>
|
<h1 class="title">Hello, there!</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="column is-narrow" v-if="secured">
|
||||||
|
<a class="button is-warning is-light is-small" :href="`${base}/logout`">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="level section is-mobile">
|
<section class="level section is-mobile">
|
||||||
@@ -84,6 +91,8 @@ export default {
|
|||||||
version: config.version,
|
version: config.version,
|
||||||
search: null,
|
search: null,
|
||||||
sort: "running",
|
sort: "running",
|
||||||
|
secured: config.secured,
|
||||||
|
base: config.base,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
83
assets/pages/Login.vue
Normal file
83
assets/pages/Login.vue
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<div class="hero is-halfheight">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container">
|
||||||
|
<section class="columns is-centered section">
|
||||||
|
<div class="column is-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-header-title is-size-4">Authentication Required</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<form action="" method="post" @submit.prevent="onLogin" ref="form">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Username</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" name="username" autocomplete="username" v-model="username" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Password</label>
|
||||||
|
<div class="control">
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
v-model="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="help is-danger" v-if="error">Username and password are not valid.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field is-grouped is-grouped-centered mt-5">
|
||||||
|
<p class="control">
|
||||||
|
<button class="button is-light" type="reset">Reset</button>
|
||||||
|
</p>
|
||||||
|
<p class="control">
|
||||||
|
<button class="button is-primary" type="submit">Submit</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import config from "../store/config";
|
||||||
|
export default {
|
||||||
|
name: "Login",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
username: null,
|
||||||
|
password: null,
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
metaInfo() {
|
||||||
|
return {
|
||||||
|
title: "Authentication Required",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async onLogin() {
|
||||||
|
const response = await fetch(`${config.base}/api/validateCredentials`, {
|
||||||
|
body: new FormData(this.$refs.form),
|
||||||
|
method: "post",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status == 200) {
|
||||||
|
this.error = false;
|
||||||
|
window.location.href = `${config.base}/`;
|
||||||
|
} else {
|
||||||
|
this.error = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -14,5 +14,10 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
name: "PageNotFound",
|
name: "PageNotFound",
|
||||||
|
metaInfo() {
|
||||||
|
return {
|
||||||
|
title: "404 Error",
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export { default as Show } from "./Show.vue";
|
|||||||
export { default as Container } from "./Container.vue";
|
export { default as Container } from "./Container.vue";
|
||||||
export { default as Settings } from "./Settings.vue";
|
export { default as Settings } from "./Settings.vue";
|
||||||
export { default as PageNotFound } from "./PageNotFound.vue";
|
export { default as PageNotFound } from "./PageNotFound.vue";
|
||||||
|
export { default as Login } from "./Login.vue";
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ const config = JSON.parse(document.querySelector("script#config__json").textCont
|
|||||||
if (config.version == "{{ .Version }}") {
|
if (config.version == "{{ .Version }}") {
|
||||||
config.version = "dev";
|
config.version = "dev";
|
||||||
config.base = "";
|
config.base = "";
|
||||||
|
config.authorizationNeeded = false;
|
||||||
|
config.secured = false;
|
||||||
} else {
|
} else {
|
||||||
config.version = config.version.replace(/^v/, "");
|
config.version = config.version.replace(/^v/, "");
|
||||||
|
config.authorizationNeeded = config.authorizationNeeded === "true";
|
||||||
|
config.secured = config.secured === "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const state = {
|
|||||||
searchFilter: null,
|
searchFilter: null,
|
||||||
isMobile: mql.matches,
|
isMobile: mql.matches,
|
||||||
settings: storage.get(DOZZLE_SETTINGS_KEY),
|
settings: storage.get(DOZZLE_SETTINGS_KEY),
|
||||||
|
authorizationNeeded: config.authorizationNeeded,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mutations = {
|
const mutations = {
|
||||||
@@ -101,10 +102,12 @@ const getters = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!config.authorizationNeeded) {
|
||||||
const es = new EventSource(`${config.base}/api/events/stream`);
|
const es = new EventSource(`${config.base}/api/events/stream`);
|
||||||
es.addEventListener("containers-changed", (e) => store.commit("SET_CONTAINERS", JSON.parse(e.data)), false);
|
es.addEventListener("containers-changed", (e) => store.commit("SET_CONTAINERS", JSON.parse(e.data)), false);
|
||||||
es.addEventListener("container-stat", (e) => store.dispatch("UPDATE_STATS", JSON.parse(e.data)), false);
|
es.addEventListener("container-stat", (e) => store.dispatch("UPDATE_STATS", JSON.parse(e.data)), false);
|
||||||
es.addEventListener("container-die", (e) => store.dispatch("UPDATE_CONTAINER", JSON.parse(e.data)), false);
|
es.addEventListener("container-die", (e) => store.dispatch("UPDATE_CONTAINER", JSON.parse(e.data)), false);
|
||||||
|
}
|
||||||
|
|
||||||
mql.addEventListener("change", (e) => store.commit("SET_MOBILE_WIDTH", e.matches));
|
mql.addEventListener("change", (e) => store.commit("SET_MOBILE_WIDTH", e.matches));
|
||||||
|
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -13,6 +13,7 @@ require (
|
|||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/golang/protobuf v1.5.1 // indirect
|
github.com/golang/protobuf v1.5.1 // indirect
|
||||||
github.com/gorilla/mux v1.8.0
|
github.com/gorilla/mux v1.8.0
|
||||||
|
github.com/gorilla/sessions v1.2.1
|
||||||
github.com/magiconair/properties v1.8.5
|
github.com/magiconair/properties v1.8.5
|
||||||
github.com/mitchellh/mapstructure v1.4.1 // indirect
|
github.com/mitchellh/mapstructure v1.4.1 // indirect
|
||||||
github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 // indirect
|
github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 // indirect
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -112,6 +112,10 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGa
|
|||||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||||
|
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||||
|
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||||
|
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
|
||||||
|
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
||||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
||||||
|
|||||||
23
main.go
23
main.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
_ "net/http/pprof"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -25,6 +26,9 @@ var (
|
|||||||
tailSize = 300
|
tailSize = 300
|
||||||
filters map[string]string
|
filters map[string]string
|
||||||
version = "dev"
|
version = "dev"
|
||||||
|
key string
|
||||||
|
username string
|
||||||
|
password string
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed static
|
//go:embed static
|
||||||
@@ -40,6 +44,9 @@ func init() {
|
|||||||
pflag.String("level", "info", "logging level")
|
pflag.String("level", "info", "logging level")
|
||||||
pflag.Int("tailSize", 300, "Tail size to use for initial container logs")
|
pflag.Int("tailSize", 300, "Tail size to use for initial container logs")
|
||||||
pflag.StringToStringVar(&filters, "filter", map[string]string{}, "Container filters to use for showing logs")
|
pflag.StringToStringVar(&filters, "filter", map[string]string{}, "Container filters to use for showing logs")
|
||||||
|
pflag.String("key", "", "Dozzle secure key used for session encryption. Should be a random generated string. Use openssl rand -base64 32 to create one.")
|
||||||
|
pflag.String("username", "", "Dozzle username to use for authentication. Requires key and password.")
|
||||||
|
pflag.String("password", "", "Dozzle password for authentication. Requires username and key.")
|
||||||
pflag.Parse()
|
pflag.Parse()
|
||||||
|
|
||||||
viper.AutomaticEnv()
|
viper.AutomaticEnv()
|
||||||
@@ -50,6 +57,9 @@ func init() {
|
|||||||
base = viper.GetString("base")
|
base = viper.GetString("base")
|
||||||
level = viper.GetString("level")
|
level = viper.GetString("level")
|
||||||
tailSize = viper.GetInt("tailSize")
|
tailSize = viper.GetInt("tailSize")
|
||||||
|
key = viper.GetString("key")
|
||||||
|
username = viper.GetString("username")
|
||||||
|
password = viper.GetString("password")
|
||||||
|
|
||||||
// Until https://github.com/spf13/viper/issues/911 is fixed. We have to use this hacky way.
|
// Until https://github.com/spf13/viper/issues/911 is fixed. We have to use this hacky way.
|
||||||
// filters = viper.GetStringMapString("filter")
|
// filters = viper.GetStringMapString("filter")
|
||||||
@@ -83,11 +93,24 @@ func main() {
|
|||||||
log.Fatalf("Could not connect to Docker Engine: %v", err)
|
log.Fatalf("Could not connect to Docker Engine: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if username != "" || password != "" {
|
||||||
|
if username == "" || password == "" {
|
||||||
|
log.Fatalf("Username AND password are required for authentication")
|
||||||
|
}
|
||||||
|
|
||||||
|
if key == "" {
|
||||||
|
log.Fatalf("Key is required for authentication")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
config := web.Config{
|
config := web.Config{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Base: base,
|
Base: base,
|
||||||
Version: version,
|
Version: version,
|
||||||
TailSize: tailSize,
|
TailSize: tailSize,
|
||||||
|
Key: key,
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
}
|
}
|
||||||
|
|
||||||
static, err := fs.Sub(content, "static")
|
static, err := fs.Sub(content, "static")
|
||||||
|
|||||||
118
web/auth.go
Normal file
118
web/auth.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var secured = false
|
||||||
|
var store *sessions.CookieStore
|
||||||
|
|
||||||
|
const authorityKey = "AUTH_TIMESTAMP"
|
||||||
|
const sessionName = "session"
|
||||||
|
|
||||||
|
func initializeAuth(h *handler) {
|
||||||
|
if h.config.Username != "" && h.config.Password != "" {
|
||||||
|
store = sessions.NewCookieStore([]byte(h.config.Key))
|
||||||
|
store.Options.HttpOnly = true
|
||||||
|
store.Options.SameSite = http.SameSiteLaxMode
|
||||||
|
store.Options.MaxAge = 0
|
||||||
|
secured = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizationRequired(f http.HandlerFunc) http.Handler {
|
||||||
|
if secured {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, _ := store.Get(r, sessionName)
|
||||||
|
if session.IsNew {
|
||||||
|
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
f(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return http.HandlerFunc(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) isAuthorized(r *http.Request) bool {
|
||||||
|
if !secured {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
session, _ := store.Get(r, sessionName)
|
||||||
|
if session.IsNew {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := session.Values[authorityKey]; ok {
|
||||||
|
// TODO check for timestamp
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) isAuthorizationNeeded(r *http.Request) bool {
|
||||||
|
return secured && !h.isAuthorized(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) validateCredentials(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !secured {
|
||||||
|
log.Panic("Validating credentials with secured=false should not happen")
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method != "POST" {
|
||||||
|
log.Fatal("Expecting method to be POST")
|
||||||
|
http.Error(w, http.StatusText(http.StatusNotAcceptable), http.StatusNotAcceptable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseMultipartForm(4 * 1024); err != nil {
|
||||||
|
log.Fatalf("Error while parsing form data: %v", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := r.PostFormValue("username")
|
||||||
|
pass := r.PostFormValue("password")
|
||||||
|
session, _ := store.Get(r, sessionName)
|
||||||
|
|
||||||
|
if user == h.config.Username && pass == h.config.Password {
|
||||||
|
session.Values[authorityKey] = time.Now().Unix()
|
||||||
|
|
||||||
|
if err := session.Save(r, w); err != nil {
|
||||||
|
log.Fatalf("Error while parsing saving session: %v", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(http.StatusText(http.StatusOK)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) clearSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !secured {
|
||||||
|
log.Panic("Validating credentials with secured=false should not happen")
|
||||||
|
}
|
||||||
|
|
||||||
|
session, _ := store.Get(r, sessionName)
|
||||||
|
delete(session.Values, authorityKey)
|
||||||
|
|
||||||
|
if err := session.Save(r, w); err != nil {
|
||||||
|
log.Fatalf("Error while parsing saving session: %v", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, h.config.Base, http.StatusTemporaryRedirect)
|
||||||
|
}
|
||||||
12
web/csp.go
Normal file
12
web/csp.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cspHeaders(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com; require-trusted-types-for 'script'")
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
110
web/events.go
Normal file
110
web/events.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/amir20/dozzle/docker"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *handler) streamEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
events, err := h.client.Events(ctx)
|
||||||
|
stats := make(chan docker.ContainerStat)
|
||||||
|
|
||||||
|
if containers, err := h.client.ListContainers(); err == nil {
|
||||||
|
for _, c := range containers {
|
||||||
|
if c.State == "running" {
|
||||||
|
if err := h.client.ContainerStats(ctx, c.ID, stats); err != nil {
|
||||||
|
log.Errorf("error while streaming container stats: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sendContainersJSON(h.client, w); err != nil {
|
||||||
|
log.Errorf("error while encoding containers to stream: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Flush()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case stat := <-stats:
|
||||||
|
bytes, _ := json.Marshal(stat)
|
||||||
|
if _, err := fmt.Fprintf(w, "event: container-stat\ndata: %s\n\n", string(bytes)); err != nil {
|
||||||
|
log.Errorf("error writing stat to event stream: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.Flush()
|
||||||
|
case event, ok := <-events:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch event.Name {
|
||||||
|
case "start", "die":
|
||||||
|
log.Debugf("triggering docker event: %v", event.Name)
|
||||||
|
if event.Name == "start" {
|
||||||
|
log.Debugf("found new container with id: %v", event.ActorID)
|
||||||
|
if err := h.client.ContainerStats(ctx, event.ActorID, stats); err != nil {
|
||||||
|
log.Errorf("error when streaming new container stats: %v", err)
|
||||||
|
}
|
||||||
|
if err := sendContainersJSON(h.client, w); err != nil {
|
||||||
|
log.Errorf("error encoding containers to stream: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes, _ := json.Marshal(event)
|
||||||
|
if _, err := fmt.Fprintf(w, "event: container-%s\ndata: %s\n\n", event.Name, string(bytes)); err != nil {
|
||||||
|
log.Errorf("error writing event to event stream: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Flush()
|
||||||
|
default:
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-err:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendContainersJSON(client docker.Client, w http.ResponseWriter) error {
|
||||||
|
containers, err := client.ListContainers()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fmt.Fprint(w, "event: containers-changed\ndata: "); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(containers); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fmt.Fprint(w, "\n\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
138
web/logs.go
Normal file
138
web/logs.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
|
||||||
|
|
||||||
|
from, _ := time.Parse(time.RFC3339, r.URL.Query().Get("from"))
|
||||||
|
to, _ := time.Parse(time.RFC3339, r.URL.Query().Get("to"))
|
||||||
|
id := r.URL.Query().Get("id")
|
||||||
|
|
||||||
|
reader, err := h.client.ContainerLogsBetweenDates(r.Context(), id, from, to)
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
io.Copy(w, reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.URL.Query().Get("id")
|
||||||
|
container, err := h.client.FindContainer(id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
from := time.Unix(container.Created, 0)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%v.log.gz", container.ID))
|
||||||
|
w.Header().Set("Content-Type", "application/gzip")
|
||||||
|
zw := gzip.NewWriter(w)
|
||||||
|
defer zw.Close()
|
||||||
|
zw.Name = fmt.Sprintf("%v.log", container.ID)
|
||||||
|
zw.Comment = "Logs generated by Dozzle"
|
||||||
|
zw.ModTime = now
|
||||||
|
|
||||||
|
reader, err := h.client.ContainerLogsBetweenDates(r.Context(), container.ID, from, now)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
io.Copy(zw, reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) streamLogs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.URL.Query().Get("id")
|
||||||
|
if id == "" {
|
||||||
|
http.Error(w, "id is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
container, err := h.client.FindContainer(id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
|
reader, err := h.client.ContainerLogs(r.Context(), container.ID, h.config.TailSize, r.Header.Get("Last-Event-ID"))
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
fmt.Fprintf(w, "event: container-stopped\ndata: end of stream\n\n")
|
||||||
|
f.Flush()
|
||||||
|
} else {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(reader)
|
||||||
|
for scanner.Scan() {
|
||||||
|
message := scanner.Text()
|
||||||
|
fmt.Fprintf(w, "data: %s\n", message)
|
||||||
|
if index := strings.IndexAny(message, " "); index != -1 {
|
||||||
|
id := message[:index]
|
||||||
|
if _, err := time.Parse(time.RFC3339Nano, id); err == nil {
|
||||||
|
fmt.Fprintf(w, "id: %s\n", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "\n")
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("streaming stopped: %v", container.ID)
|
||||||
|
|
||||||
|
if scanner.Err() == nil {
|
||||||
|
log.Debugf("container stopped: %v", container.ID)
|
||||||
|
fmt.Fprintf(w, "event: container-stopped\ndata: end of stream\n\n")
|
||||||
|
f.Flush()
|
||||||
|
} else if scanner.Err() != context.Canceled {
|
||||||
|
log.Errorf("unknown error while streaming %v", scanner.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
log.WithField("routines", runtime.NumGoroutine()).Debug("runtime goroutine stats")
|
||||||
|
|
||||||
|
if log.IsLevelEnabled(log.DebugLevel) {
|
||||||
|
var m runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&m)
|
||||||
|
// For info on each, see: https://golang.org/pkg/runtime/#MemStats
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"allocated": humanize.Bytes(m.Alloc),
|
||||||
|
"totalAllocated": humanize.Bytes(m.TotalAlloc),
|
||||||
|
"system": humanize.Bytes(m.Sys),
|
||||||
|
}).Debug("runtime mem stats")
|
||||||
|
}
|
||||||
|
}
|
||||||
289
web/routes.go
289
web/routes.go
@@ -1,25 +1,13 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"compress/gzip"
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
_ "net/http/pprof"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/amir20/dozzle/docker"
|
"github.com/amir20/dozzle/docker"
|
||||||
"github.com/dustin/go-humanize"
|
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -31,13 +19,15 @@ type Config struct {
|
|||||||
Addr string
|
Addr string
|
||||||
Version string
|
Version string
|
||||||
TailSize int
|
TailSize int
|
||||||
|
Key string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
}
|
}
|
||||||
|
|
||||||
type handler struct {
|
type handler struct {
|
||||||
client docker.Client
|
client docker.Client
|
||||||
content fs.FS
|
content fs.FS
|
||||||
config *Config
|
config *Config
|
||||||
fileServer http.Handler
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateServer creates a service for http handler
|
// CreateServer creates a service for http handler
|
||||||
@@ -50,21 +40,27 @@ func CreateServer(c docker.Client, content fs.FS, config Config) *http.Server {
|
|||||||
return &http.Server{Addr: config.Addr, Handler: createRouter(handler)}
|
return &http.Server{Addr: config.Addr, Handler: createRouter(handler)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var fileServer http.Handler
|
||||||
|
|
||||||
func createRouter(h *handler) *mux.Router {
|
func createRouter(h *handler) *mux.Router {
|
||||||
|
initializeAuth(h)
|
||||||
|
|
||||||
base := h.config.Base
|
base := h.config.Base
|
||||||
r := mux.NewRouter()
|
r := mux.NewRouter()
|
||||||
r.Use(setCSPHeaders)
|
r.Use(cspHeaders)
|
||||||
if base != "/" {
|
if base != "/" {
|
||||||
r.HandleFunc(base, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
r.HandleFunc(base, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
http.Redirect(w, req, base+"/", http.StatusMovedPermanently)
|
http.Redirect(w, req, base+"/", http.StatusMovedPermanently)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
s := r.PathPrefix(base).Subrouter()
|
s := r.PathPrefix(base).Subrouter()
|
||||||
s.HandleFunc("/api/logs/stream", h.streamLogs)
|
s.Handle("/api/logs/stream", authorizationRequired(h.streamLogs))
|
||||||
s.HandleFunc("/api/logs/download", h.downloadLogs)
|
s.Handle("/api/logs/download", authorizationRequired(h.downloadLogs))
|
||||||
s.HandleFunc("/api/logs", h.fetchLogsBetweenDates)
|
s.Handle("/api/logs", authorizationRequired(h.fetchLogsBetweenDates))
|
||||||
s.HandleFunc("/api/events/stream", h.streamEvents)
|
s.Handle("/api/events/stream", authorizationRequired(h.streamEvents))
|
||||||
s.HandleFunc("/version", h.version)
|
s.HandleFunc("/api/validateCredentials", h.validateCredentials)
|
||||||
|
s.Handle("/logout", authorizationRequired(h.clearSession))
|
||||||
|
s.Handle("/version", authorizationRequired(h.version))
|
||||||
|
|
||||||
if log.IsLevelEnabled(log.DebugLevel) {
|
if log.IsLevelEnabled(log.DebugLevel) {
|
||||||
s.PathPrefix("/debug/pprof/").Handler(http.DefaultServeMux)
|
s.PathPrefix("/debug/pprof/").Handler(http.DefaultServeMux)
|
||||||
@@ -76,34 +72,36 @@ func createRouter(h *handler) *mux.Router {
|
|||||||
s.PathPrefix("/").Handler(http.StripPrefix(base, http.HandlerFunc(h.index)))
|
s.PathPrefix("/").Handler(http.StripPrefix(base, http.HandlerFunc(h.index)))
|
||||||
}
|
}
|
||||||
|
|
||||||
h.fileServer = http.FileServer(http.FS(h.content))
|
fileServer = http.FileServer(http.FS(h.content))
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func setCSPHeaders(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; manifest-src 'self'; connect-src 'self' api.github.com; require-trusted-types-for 'script'")
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *handler) index(w http.ResponseWriter, req *http.Request) {
|
func (h *handler) index(w http.ResponseWriter, req *http.Request) {
|
||||||
_, err := h.content.Open(req.URL.Path)
|
_, err := h.content.Open(req.URL.Path)
|
||||||
if err == nil && req.URL.Path != "" && req.URL.Path != "/" {
|
if err == nil && req.URL.Path != "" && req.URL.Path != "/" {
|
||||||
h.fileServer.ServeHTTP(w, req)
|
fileServer.ServeHTTP(w, req)
|
||||||
} else {
|
} else {
|
||||||
|
if !h.isAuthorized(req) && req.URL.Path != "login" {
|
||||||
|
http.Redirect(w, req, h.config.Base+"login", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.executeTemplate(w, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) executeTemplate(w http.ResponseWriter, req *http.Request) {
|
||||||
file, err := h.content.Open("index.html")
|
file, err := h.content.Open("index.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
log.Panic(err)
|
||||||
}
|
}
|
||||||
bytes, err := ioutil.ReadAll(file)
|
bytes, err := ioutil.ReadAll(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
log.Panic(err)
|
||||||
}
|
}
|
||||||
tmpl, err := template.New("index.html").Parse(string(bytes))
|
tmpl, err := template.New("index.html").Parse(string(bytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
log.Panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
path := ""
|
path := ""
|
||||||
@@ -114,232 +112,21 @@ func (h *handler) index(w http.ResponseWriter, req *http.Request) {
|
|||||||
data := struct {
|
data := struct {
|
||||||
Base string
|
Base string
|
||||||
Version string
|
Version string
|
||||||
}{path, h.config.Version}
|
AuthorizationNeeded bool
|
||||||
|
Secured bool
|
||||||
|
}{
|
||||||
|
path,
|
||||||
|
h.config.Version,
|
||||||
|
h.isAuthorizationNeeded(req),
|
||||||
|
secured,
|
||||||
|
}
|
||||||
err = tmpl.Execute(w, data)
|
err = tmpl.Execute(w, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Panic(err)
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
|
|
||||||
|
|
||||||
from, _ := time.Parse(time.RFC3339, r.URL.Query().Get("from"))
|
|
||||||
to, _ := time.Parse(time.RFC3339, r.URL.Query().Get("to"))
|
|
||||||
id := r.URL.Query().Get("id")
|
|
||||||
|
|
||||||
reader, err := h.client.ContainerLogsBetweenDates(r.Context(), id, from, to)
|
|
||||||
defer reader.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
io.Copy(w, reader)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := r.URL.Query().Get("id")
|
|
||||||
container, err := h.client.FindContainer(id)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
from := time.Unix(container.Created, 0)
|
|
||||||
|
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%v.log.gz", container.ID))
|
|
||||||
w.Header().Set("Content-Type", "application/gzip")
|
|
||||||
zw := gzip.NewWriter(w)
|
|
||||||
defer zw.Close()
|
|
||||||
zw.Name = fmt.Sprintf("%v.log", container.ID)
|
|
||||||
zw.Comment = "Logs generated by Dozzle"
|
|
||||||
zw.ModTime = now
|
|
||||||
|
|
||||||
reader, err := h.client.ContainerLogsBetweenDates(r.Context(), container.ID, from, now)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
io.Copy(zw, reader)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *handler) streamLogs(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := r.URL.Query().Get("id")
|
|
||||||
if id == "" {
|
|
||||||
http.Error(w, "id is required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
f, ok := w.(http.Flusher)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
container, err := h.client.FindContainer(id)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.Header().Set("Connection", "keep-alive")
|
|
||||||
w.Header().Set("X-Accel-Buffering", "no")
|
|
||||||
|
|
||||||
reader, err := h.client.ContainerLogs(r.Context(), container.ID, h.config.TailSize, r.Header.Get("Last-Event-ID"))
|
|
||||||
if err != nil {
|
|
||||||
if err == io.EOF {
|
|
||||||
fmt.Fprintf(w, "event: container-stopped\ndata: end of stream\n\n")
|
|
||||||
f.Flush()
|
|
||||||
} else {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer reader.Close()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(reader)
|
|
||||||
for scanner.Scan() {
|
|
||||||
message := scanner.Text()
|
|
||||||
fmt.Fprintf(w, "data: %s\n", message)
|
|
||||||
if index := strings.IndexAny(message, " "); index != -1 {
|
|
||||||
id := message[:index]
|
|
||||||
if _, err := time.Parse(time.RFC3339Nano, id); err == nil {
|
|
||||||
fmt.Fprintf(w, "id: %s\n", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "\n")
|
|
||||||
f.Flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debugf("streaming stopped: %v", container.ID)
|
|
||||||
|
|
||||||
if scanner.Err() == nil {
|
|
||||||
log.Debugf("container stopped: %v", container.ID)
|
|
||||||
fmt.Fprintf(w, "event: container-stopped\ndata: end of stream\n\n")
|
|
||||||
f.Flush()
|
|
||||||
} else if scanner.Err() != context.Canceled {
|
|
||||||
log.Errorf("unknown error while streaming %v", scanner.Err())
|
|
||||||
}
|
|
||||||
|
|
||||||
log.WithField("routines", runtime.NumGoroutine()).Debug("runtime goroutine stats")
|
|
||||||
|
|
||||||
if log.IsLevelEnabled(log.DebugLevel) {
|
|
||||||
var m runtime.MemStats
|
|
||||||
runtime.ReadMemStats(&m)
|
|
||||||
// For info on each, see: https://golang.org/pkg/runtime/#MemStats
|
|
||||||
log.WithFields(log.Fields{
|
|
||||||
"allocated": humanize.Bytes(m.Alloc),
|
|
||||||
"totalAllocated": humanize.Bytes(m.TotalAlloc),
|
|
||||||
"system": humanize.Bytes(m.Sys),
|
|
||||||
}).Debug("runtime mem stats")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *handler) streamEvents(w http.ResponseWriter, r *http.Request) {
|
|
||||||
f, ok := w.(http.Flusher)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.Header().Set("Connection", "keep-alive")
|
|
||||||
w.Header().Set("X-Accel-Buffering", "no")
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
events, err := h.client.Events(ctx)
|
|
||||||
stats := make(chan docker.ContainerStat)
|
|
||||||
|
|
||||||
if containers, err := h.client.ListContainers(); err == nil {
|
|
||||||
for _, c := range containers {
|
|
||||||
if c.State == "running" {
|
|
||||||
if err := h.client.ContainerStats(ctx, c.ID, stats); err != nil {
|
|
||||||
log.Errorf("error while streaming container stats: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := sendContainersJSON(h.client, w); err != nil {
|
|
||||||
log.Errorf("error while encoding containers to stream: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
f.Flush()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case stat := <-stats:
|
|
||||||
bytes, _ := json.Marshal(stat)
|
|
||||||
if _, err := fmt.Fprintf(w, "event: container-stat\ndata: %s\n\n", string(bytes)); err != nil {
|
|
||||||
log.Errorf("error writing stat to event stream: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
f.Flush()
|
|
||||||
case event, ok := <-events:
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch event.Name {
|
|
||||||
case "start", "die":
|
|
||||||
log.Debugf("triggering docker event: %v", event.Name)
|
|
||||||
if event.Name == "start" {
|
|
||||||
log.Debugf("found new container with id: %v", event.ActorID)
|
|
||||||
if err := h.client.ContainerStats(ctx, event.ActorID, stats); err != nil {
|
|
||||||
log.Errorf("error when streaming new container stats: %v", err)
|
|
||||||
}
|
|
||||||
if err := sendContainersJSON(h.client, w); err != nil {
|
|
||||||
log.Errorf("error encoding containers to stream: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bytes, _ := json.Marshal(event)
|
|
||||||
if _, err := fmt.Fprintf(w, "event: container-%s\ndata: %s\n\n", event.Name, string(bytes)); err != nil {
|
|
||||||
log.Errorf("error writing event to event stream: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
f.Flush()
|
|
||||||
default:
|
|
||||||
// do nothing
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-err:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *handler) version(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) version(w http.ResponseWriter, r *http.Request) {
|
||||||
fmt.Fprintf(w, "%v", h.config.Version)
|
fmt.Fprintf(w, "%v", h.config.Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendContainersJSON(client docker.Client, w http.ResponseWriter) error {
|
|
||||||
containers, err := client.ListContainers()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := fmt.Fprint(w, "event: containers-changed\ndata: "); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.NewEncoder(w).Encode(containers); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := fmt.Fprint(w, "\n\n"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -300,7 +300,6 @@ func Test_createRoutes_foobar_file(t *testing.T) {
|
|||||||
client: mockedClient,
|
client: mockedClient,
|
||||||
content: afero.NewIOFS(fs),
|
content: afero.NewIOFS(fs),
|
||||||
config: &Config{Base: "/foobar"},
|
config: &Config{Base: "/foobar"},
|
||||||
fileServer: nil,
|
|
||||||
})
|
})
|
||||||
req, err := http.NewRequest("GET", "/foobar/test", nil)
|
req, err := http.NewRequest("GET", "/foobar/test", nil)
|
||||||
require.NoError(t, err, "NewRequest should not return an error.")
|
require.NoError(t, err, "NewRequest should not return an error.")
|
||||||
|
|||||||
Reference in New Issue
Block a user