diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index cda2741..d3f1da8 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ github: [glanceapp] +patreon: glanceapp diff --git a/Dockerfile b/Dockerfile index 4d8cd87..c751565 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23.6-alpine3.21 AS builder +FROM golang:1.24.4-alpine3.21 AS builder WORKDIR /app COPY . /app @@ -9,8 +9,5 @@ FROM alpine:3.21 WORKDIR /app COPY --from=builder /app/glance . -HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \ - CMD wget --spider -q http://localhost:8080/api/healthz - EXPOSE 8080/tcp ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"] diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser index bbfa8ad..244b177 100644 --- a/Dockerfile.goreleaser +++ b/Dockerfile.goreleaser @@ -3,8 +3,5 @@ FROM alpine:3.21 WORKDIR /app COPY glance . -HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \ - CMD wget --spider -q http://localhost:8080/api/healthz - EXPOSE 8080/tcp ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"] diff --git a/README.md b/README.md index 127a7d4..7207c37 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@
What if you could see everything at a...
Install • Configuration • Preconfigured pages • Themes • Discord
+Install • Configuration • Discord • Sponsor
+Community widgets • Preconfigured pages • Themes
 @@ -16,7 +17,7 @@ * Docker containers status * Server stats * Custom widgets -* [and many more...](docs/configuration.md) +* [and many more...](docs/configuration.md#configuring-glance) ### Fast and lightweight * Low memory usage @@ -45,8 +46,7 @@ Easily create your own theme by tweaking a few numbers or choose from one of thePowered by Glance
logo-url: /assets/logo.png favicon-url: /assets/logo.png + app-name: "My Dashboard" + app-icon-url: "/assets/app-icon.png" + app-background-color: "#151519" ``` ### Properties @@ -279,6 +379,9 @@ branding: | logo-text | string | no | G | | logo-url | string | no | | | favicon-url | string | no | | +| app-name | string | no | Glance | +| app-icon-url | string | no | Glance's default icon | +| app-background-color | string | no | Glance's default background color | #### `hide-footer` Hides the footer when set to `true`. @@ -295,6 +398,15 @@ Specify a URL to a custom image to use instead of the "G" found in the navigatio #### `favicon-url` Specify a URL to a custom image to use for the favicon. +#### `app-name` +Specify the name of the web app shown in browser tab and PWA. + +#### `app-icon-url` +Specify URL for PWA and browser tab icon (512x512 PNG). + +#### `app-background-color` +Specify background color for PWA. Must be a valid CSS color. + ## Theme Theming is done through a top level `theme` property. Values for the colors are in [HSL](https://giggster.com/guide/basics/hue-saturation-lightness/) (hue, saturation, lightness) format. You can use a color picker [like this one](https://hslpicker.com/) to convert colors from other formats to HSL. The values are separated by a space and `%` is not required for any of the numbers. @@ -302,9 +414,24 @@ Example: ```yaml theme: + # This will be the default theme background-color: 100 20 10 primary-color: 40 90 40 contrast-multiplier: 1.1 + + disable-picker: false + presets: + gruvbox-dark: + background-color: 0 0 16 + primary-color: 43 59 81 + positive-color: 61 66 44 + negative-color: 6 96 59 + + zebra: + light: true + background-color: 0 0 95 + primary-color: 0 0 10 + negative-color: 0 90 50 ``` ### Available themes @@ -321,6 +448,8 @@ If you don't want to spend time configuring your own theme, there are [several a | contrast-multiplier | number | no | 1 | | text-saturation-multiplier | number | no | 1 | | custom-css-file | string | no | | +| disable-picker | bool | false | | +| presets | object | no | | #### `light` Whether the scheme is light or dark. This does not change the background color, it inverts the text colors so that they look appropriately on a light background. @@ -365,6 +494,33 @@ theme: > > In addition, you can also use the `css-class` property which is available on every widget to set custom class names for individual widgets. +#### `disable-picker` +When set to `true` hides the theme picker and disables the abiltity to switch between themes. All users who previously picked a non-default theme will be switched over to the default theme. + +#### `presets` +Define additional theme presets that can be selected from the theme picker on the page. For each preset, you can specify the same properties as for the default theme, such as `background-color`, `primary-color`, `positive-color`, `negative-color`, `contrast-multiplier`, etc., except for the `custom-css-file` property. + +Example: + +```yaml +theme: + presets: + my-custom-dark-theme: + background-color: 229 19 23 + contrast-multiplier: 1.2 + primary-color: 222 74 74 + positive-color: 96 44 68 + negative-color: 359 68 71 + my-custom-light-theme: + light: true + background-color: 220 23 95 + contrast-multiplier: 1.1 + primary-color: 220 91 54 + positive-color: 109 58 40 + negative-color: 347 87 44 +``` + +To override the default dark and light themes, use the key names `default-dark` and `default-light`. ## Pages & Columns  @@ -395,8 +551,8 @@ pages: | desktop-navigation-width | string | no | | | center-vertically | boolean | no | false | | hide-desktop-navigation | boolean | no | false | -| expand-mobile-page-navigation | boolean | no | false | | show-mobile-header | boolean | no | false | +| head-widgets | array | no | | | columns | array | yes | | #### `name` @@ -427,9 +583,6 @@ When set to `true`, vertically centers the content on the page. Has no effect if #### `hide-desktop-navigation` Whether to show the navigation links at the top of the page on desktop. -#### `expand-mobile-page-navigation` -Whether the mobile page navigation should be expanded by default. - #### `show-mobile-header` Whether to show a header displaying the name of the page on mobile. The header purposefully has a lot of vertical whitespace in order to push the content down and make it easier to reach on tall devices. @@ -437,6 +590,43 @@ Preview:  +#### `head-widgets` + +Head widgets will be shown at the top of the page, above the columns, and take up the combined width of all columns. You can specify any widget, though some will look better than others, such as the markets, RSS feed with `horizontal-cards` style, and videos widgets. Example: + + + +```yaml +pages: + - name: Home + head-widgets: + - type: markets + hide-header: true + markets: + - symbol: SPY + name: S&P 500 + - symbol: BTC-USD + name: Bitcoin + - symbol: NVDA + name: NVIDIA + - symbol: AAPL + name: Apple + - symbol: MSFT + name: Microsoft + + columns: + - size: small + widgets: + - type: calendar + - size: full + widgets: + - type: hacker-news + - size: small + widgets: + - type: weather + location: London, United Kingdom +``` + ### Columns Columns are defined for each page using a `columns` property. There are two types of columns - `full` and `small`, which refers to their width. A small column takes up a fixed amount of width (300px) and a full column takes up the all of the remaining width. You can have up to 3 columns per page and you must have either 1 or 2 full columns. Example: @@ -515,6 +705,7 @@ pages: | type | string | yes | | title | string | no | | title-url | string | no | +| hide-header | boolean | no | false | | cache | string | no | | css-class | string | no | @@ -527,6 +718,13 @@ The title of the widget. If left blank it will be defined by the widget. #### `title-url` The URL to go to when clicking on the widget's title. If left blank it will be defined by the widget (if available). +#### `hide-header` +When set to `true`, the header (title) of the widget will be hidden. You cannot hide the header of the group widget. + +> [!NOTE] +> +> If a widget fails to update, a red dot or circle is shown next to the title of that widget indicating that the it is not working. You will not be able to see this if you hide the header. + #### `cache` How long to keep the fetched data in memory. The value is a string and must be a number followed by one of s, m, h, d. Examples: @@ -672,6 +870,7 @@ Preview: | playlists | array | no | | | limit | integer | no | 25 | | style | string | no | horizontal-cards | +| sort-by | string | no | posted | | collapse-after | integer | no | 7 | | collapse-after-rows | integer | no | 4 | | include-shorts | boolean | no | false | @@ -699,9 +898,18 @@ A list of playlist IDs: - PL8mG-RkN2uTxTK4m_Vl2dYR9yE41kRdBg ``` +The playlist ID can be found in its link which is in the form of +``` +https://www.youtube.com...&list={ID}&... +``` + ##### `limit` The maximum number of videos to show. +##### `sort-by` +Used to specify the order in which the videos should get returned. Possible values are `none`, `updated`, and `posted`. +Default value is `posted`. + ##### `collapse-after` Specify the number of videos to show when using the `vertical-list` style before the "SHOW MORE" button appears. @@ -828,7 +1036,10 @@ Display a list of posts from a specific subreddit. > [!WARNING] > -> Reddit does not allow unauthorized API access from VPS IPs, if you're hosting Glance on a VPS you will get a 403 response. As a workaround you can route the traffic from Glance through a VPN or your own HTTP proxy using the `request-url-template` property. +> Reddit does not allow unauthorized API access from VPS IPs, if you're hosting Glance on a VPS you will get a 403 +> response. As a workaround you can either [register an app on Reddit](https://ssl.reddit.com/prefs/apps/) and use the +> generated ID and secret in the widget configuration to authenticate your requests (see `app-auth` property), use a proxy +> (see `proxy` property) or route the traffic from Glance through a VPN. Example: @@ -853,6 +1064,7 @@ Example: | top-period | string | no | day | | search | string | no | | | extra-sort-by | string | no | | +| app-auth | object | no | | ##### `subreddit` The subreddit for which to fetch the posts from. @@ -960,6 +1172,19 @@ Can be used to specify an additional sort which will be applied on top of the al The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts. +##### `app-auth` +```yaml +widgets: + - type: reddit + subreddit: technology + app-auth: + name: ${REDDIT_APP_NAME} + id: ${REDDIT_APP_CLIENT_ID} + secret: ${REDDIT_APP_SECRET} +``` + +To register an app on Reddit, go to [this page](https://ssl.reddit.com/prefs/apps/). + ### Search Widget Display a search bar that can be used to search for specific terms on various search engines. @@ -997,6 +1222,7 @@ Preview: | search-engine | string | no | duckduckgo | | new-tab | boolean | no | false | | autofocus | boolean | no | false | +| target | string | no | _blank | | placeholder | string | no | Type here to search… | | bangs | array | no | | @@ -1018,6 +1244,9 @@ When set to `true`, swaps the shortcuts for showing results in the same or new t ##### `autofocus` When set to `true`, automatically focuses the search input on page load. +##### `target` +The target to use when opening the search results in a new tab. Possible values are `_blank`, `_self`, `_parent` and `_top`. + ##### `placeholder` When set, modifies the text displayed in the input field before typing. @@ -1333,7 +1562,7 @@ Examples: #### Properties | Name | Type | Required | Default | | ---- | ---- | -------- | ------- | -| url | string | yes | | +| url | string | no | | | headers | key (string) & value (string) | no | | | method | string | no | GET | | body-type | string | no | json | @@ -1342,6 +1571,7 @@ Examples: | allow-insecure | boolean | no | false | | skip-json-validation | boolean | no | false | | template | string | yes | | +| options | map | no | | | parameters | key (string) & value (string|array) | no | | | subrequests | map of requests | no | | @@ -1394,6 +1624,95 @@ When set to `true`, skips the JSON validation step. This is useful when the API ##### `template` The template that will be used to display the data. It relies on Go's `html/template` package so it's recommended to go through [its documentation](https://pkg.go.dev/text/template) to understand how to do basic things such as conditionals, loops, etc. In addition, it also uses [tidwall's gjson](https://github.com/tidwall/gjson) package to parse the JSON data so it's worth going through its documentation if you want to use more advanced JSON selectors. You can view additional examples with explanations and function definitions [here](custom-api.md). +##### `options` +A map of options that will be passed to the template and can be used to modify the behavior of the widget. + +John
``` +Failed to fetch data: {{ $events.Response.Status }}
+ {{ end }} +``` + +*Note that you need to manually check for the correct status code.* + ## Functions The following functions are available on the `JSON` object: @@ -368,6 +446,15 @@ The following functions are available on the `JSON` object: - `Bool(key string) bool`: Returns the value of the key as a boolean. - `Array(key string) []JSON`: Returns the value of the key as an array of `JSON` objects. - `Exists(key string) bool`: Returns true if the key exists in the JSON object. +- `Entries(key string)`: Returns an iterator that allows you to loop through each property of the object. Example: `{{ range $key, $value := .JSON.Entries "user" }}`. This will yield pairs of key and value, where `$key` is a string and `$value` is a `JSON` object. + +The following functions are available on the `Options` object: + +- `StringOr(key string, default string) string`: Returns the value of the key as a string, or the default value if the key does not exist. +- `IntOr(key string, default int) int`: Returns the value of the key as an integer, or the default value if the key does not exist. +- `FloatOr(key string, default float) float`: Returns the value of the key as a float, or the default value if the key does not exist. +- `BoolOr(key string, default bool) bool`: Returns the value of the key as a boolean, or the default value if the key does not exist. +- `JSON(key string) JSON`: Returns the value of the key as a stringified `JSON` object, or throws an error if the key does not exist. The following helper functions provided by Glance are available: @@ -378,17 +465,21 @@ The following helper functions provided by Glance are available: - `offsetNow(offset string) time.Time`: Returns the current time with an offset. The offset can be positive or negative and must be in the format "3h" "-1h" or "2h30m10s". - `duration(str string) time.Duration`: Parses a string such as `1h`, `24h`, `5h30m`, etc into a `time.Duration`. - `parseTime(layout string, s string) time.Time`: Parses a string into time.Time. The layout must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). You can alternatively use these values instead of the literal format: "unix", "RFC3339", "RFC3339Nano", "DateTime", "DateOnly". +- `formatTime(layout string, s string) time.Time`: Formats a `time.Time` into a string. The layout uses the same format as `parseTime`. +- `parseLocalTime(layout string, s string) time.Time`: Same as the above, except it will automatically convert the time to the server's timezone and in the absence of a timezone, it will use the local timezone instead of UTC. - `parseRelativeTime(layout string, s string) time.Time`: A shorthand for `{{ .String "date" | parseTime "rfc3339" | toRelativeTime }}`. - `add(a, b float) float`: Adds two numbers. - `sub(a, b float) float`: Subtracts two numbers. - `mul(a, b float) float`: Multiplies two numbers. - `div(a, b float) float`: Divides two numbers. +- `mod(a, b int) int`: Remainder after dividing a by b (a % b). - `formatApproxNumber(n int) string`: Formats a number to be more human-readable, e.g. 1000 -> 1k. - `formatNumber(n float|int) string`: Formats a number with commas, e.g. 1000 -> 1,000. - `trimPrefix(prefix string, str string) string`: Trims the prefix from a string. - `trimSuffix(suffix string, str string) string`: Trims the suffix from a string. - `trimSpace(str string) string`: Trims whitespace from a string on both ends. - `replaceAll(old string, new string, str string) string`: Replaces all occurrences of a string in a string. +- `replaceMatches(pattern string, replacement string, str string) string`: Replaces all occurrences of a regular expression in a string. - `findMatch(pattern string, str string) string`: Finds the first match of a regular expression in a string. - `findSubmatch(pattern string, str string) string`: Finds the first submatch of a regular expression in a string. - `sortByString(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a string key in either ascending or descending order. @@ -396,17 +487,21 @@ The following helper functions provided by Glance are available: - `sortByFloat(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a float key in either ascending or descending order. - `sortByTime(key string, layout string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a time key in either ascending or descending order. The format must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). - `concat(strings ...string) string`: Concatenates multiple strings together. +- `unique(key string, arr []JSON) []JSON`: Returns a unique array of JSON objects based on the given key. +- `percentChange(current float, previous float) float`: Calculates the percentage change between two numbers. +- `startOfDay(t time.Time) time.Time`: Returns the start of the day for a given time. +- `endOfDay(t time.Time) time.Time`: Returns the end of the day for a given time. The following helper functions provided by Go's `text/template` are available: - `eq(a, b any) bool`: Compares two values for equality. - `ne(a, b any) bool`: Compares two values for inequality. - `lt(a, b any) bool`: Compares two values for less than. -- `lte(a, b any) bool`: Compares two values for less than or equal to. +- `le(a, b any) bool`: Compares two values for less than or equal to. - `gt(a, b any) bool`: Compares two values for greater than. -- `gte(a, b any) bool`: Compares two values for greater than or equal to. -- `and(a, b bool) bool`: Returns true if both values are true. -- `or(a, b bool) bool`: Returns true if either value is true. +- `ge(a, b any) bool`: Compares two values for greater than or equal to. +- `and(args ...bool) bool`: Returns true if **all** arguments are true; accepts two or more boolean values. +- `or(args ...bool) bool`: Returns true if **any** argument is true; accepts two or more boolean values. - `not(a bool) bool`: Returns the opposite of the value. - `index(a any, b int) any`: Returns the value at the specified index of an array. - `len(a any) int`: Returns the length of an array. diff --git a/docs/glance.yml b/docs/glance.yml index 35dc7cb..b5c68c4 100644 --- a/docs/glance.yml +++ b/docs/glance.yml @@ -27,7 +27,7 @@ pages: channels: - theprimeagen - j_blow - - piratesoftware + - giantwaffle - cohhcarnage - christitustech - EJ_SA diff --git a/docs/images/head-widgets-preview.png b/docs/images/head-widgets-preview.png new file mode 100644 index 0000000..3de52fc Binary files /dev/null and b/docs/images/head-widgets-preview.png differ diff --git a/docs/images/reorder-todo-tasks-prevew.gif b/docs/images/reorder-todo-tasks-prevew.gif new file mode 100644 index 0000000..99c6169 Binary files /dev/null and b/docs/images/reorder-todo-tasks-prevew.gif differ diff --git a/docs/images/themes/dracula.png b/docs/images/themes/dracula.png new file mode 100644 index 0000000..8dba452 Binary files /dev/null and b/docs/images/themes/dracula.png differ diff --git a/docs/images/todo-widget-preview.png b/docs/images/todo-widget-preview.png new file mode 100644 index 0000000..5d7a724 Binary files /dev/null and b/docs/images/todo-widget-preview.png differ diff --git a/docs/logo.png b/docs/logo.png new file mode 100644 index 0000000..7dbc2cc Binary files /dev/null and b/docs/logo.png differ diff --git a/docs/themes.md b/docs/themes.md index 285b032..fdc10b2 100644 --- a/docs/themes.md +++ b/docs/themes.md @@ -82,6 +82,17 @@ theme: negative-color: 209 88 54 ``` +### Dracula + +```yaml +theme: + background-color: 231 15 21 + primary-color: 265 89 79 + contrast-multiplier: 1.2 + positive-color: 135 94 66 + negative-color: 0 100 67 +``` + ## Light ### Catppuccin Latte diff --git a/go.mod b/go.mod index 0ded337..f0d15ee 100644 --- a/go.mod +++ b/go.mod @@ -1,32 +1,33 @@ module github.com/glanceapp/glance -go 1.23.6 +go 1.24.4 require ( - github.com/fsnotify/fsnotify v1.8.0 + github.com/fsnotify/fsnotify v1.9.0 github.com/mmcdole/gofeed v1.3.0 - github.com/shirou/gopsutil/v4 v4.25.1 + github.com/shirou/gopsutil/v4 v4.25.5 github.com/tidwall/gjson v1.18.0 - golang.org/x/text v0.22.0 + golang.org/x/crypto v0.39.0 + golang.org/x/text v0.26.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/PuerkitoBio/goquery v1.10.1 // indirect + github.com/PuerkitoBio/goquery v1.10.3 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect - github.com/ebitengine/purego v0.8.2 // indirect + github.com/ebitengine/purego v0.8.4 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect + github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect github.com/mmcdole/goxpp v1.1.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - github.com/tklauser/go-sysconf v0.3.14 // indirect - github.com/tklauser/numcpus v0.9.0 // indirect + github.com/tklauser/go-sysconf v0.3.15 // indirect + github.com/tklauser/numcpus v0.10.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.30.0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/sys v0.33.0 // indirect ) diff --git a/go.sum b/go.sum index 97af31d..ee3bde9 100644 --- a/go.sum +++ b/go.sum @@ -1,24 +1,25 @@ -github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU= -github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY= +github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= +github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= -github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0= -github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= +github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= +github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8= @@ -32,8 +33,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs= -github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI= +github.com/shirou/gopsutil/v4 v4.25.4 h1:cdtFO363VEOOFrUCjZRh4XVJkb548lyF0q0uTeMqYPw= +github.com/shirou/gopsutil/v4 v4.25.4/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA= +github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc= +github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= @@ -45,10 +48,10 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= -github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= -github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo= -github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI= +github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= +github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= +github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= +github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= @@ -58,6 +61,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -72,8 +79,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -95,8 +104,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -115,8 +124,10 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/glance/auth.go b/internal/glance/auth.go new file mode 100644 index 0000000..e6497a1 --- /dev/null +++ b/internal/glance/auth.go @@ -0,0 +1,343 @@ +package glance + +import ( + "bytes" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "log" + mathrand "math/rand/v2" + "net/http" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +const AUTH_SESSION_COOKIE_NAME = "session_token" +const AUTH_RATE_LIMIT_WINDOW = 5 * time.Minute +const AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5 + +const AUTH_TOKEN_SECRET_LENGTH = 32 +const AUTH_USERNAME_HASH_LENGTH = 32 +const AUTH_SECRET_KEY_LENGTH = AUTH_TOKEN_SECRET_LENGTH + AUTH_USERNAME_HASH_LENGTH +const AUTH_TIMESTAMP_LENGTH = 4 // uint32 +const AUTH_TOKEN_DATA_LENGTH = AUTH_USERNAME_HASH_LENGTH + AUTH_TIMESTAMP_LENGTH + +// How long the token will be valid for +const AUTH_TOKEN_VALID_PERIOD = 14 * 24 * time.Hour // 14 days +// How long the token has left before it should be regenerated +const AUTH_TOKEN_REGEN_BEFORE = 7 * 24 * time.Hour // 7 days + +var loginPageTemplate = mustParseTemplate("login.html", "document.html", "footer.html") + +type doWhenUnauthorized int + +const ( + redirectToLogin doWhenUnauthorized = iota + showUnauthorizedJSON +) + +type failedAuthAttempt struct { + attempts int + first time.Time +} + +func generateSessionToken(username string, secret []byte, now time.Time) (string, error) { + if len(secret) != AUTH_SECRET_KEY_LENGTH { + return "", fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH) + } + + usernameHash, err := computeUsernameHash(username, secret) + if err != nil { + return "", err + } + + data := make([]byte, AUTH_TOKEN_DATA_LENGTH) + copy(data, usernameHash) + expires := now.Add(AUTH_TOKEN_VALID_PERIOD).Unix() + binary.LittleEndian.PutUint32(data[AUTH_USERNAME_HASH_LENGTH:], uint32(expires)) + + h := hmac.New(sha256.New, secret[0:AUTH_TOKEN_SECRET_LENGTH]) + h.Write(data) + + signature := h.Sum(nil) + encodedToken := base64.StdEncoding.EncodeToString(append(data, signature...)) + // encodedToken ends up being (hashed username + expiration timestamp + signature) encoded as base64 + + return encodedToken, nil +} + +func computeUsernameHash(username string, secret []byte) ([]byte, error) { + if len(secret) != AUTH_SECRET_KEY_LENGTH { + return nil, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH) + } + + h := hmac.New(sha256.New, secret[AUTH_TOKEN_SECRET_LENGTH:]) + h.Write([]byte(username)) + + return h.Sum(nil), nil +} + +func verifySessionToken(token string, secretBytes []byte, now time.Time) ([]byte, bool, error) { + tokenBytes, err := base64.StdEncoding.DecodeString(token) + if err != nil { + return nil, false, err + } + + if len(tokenBytes) != AUTH_TOKEN_DATA_LENGTH+32 { + return nil, false, fmt.Errorf("token length is invalid") + } + + if len(secretBytes) != AUTH_SECRET_KEY_LENGTH { + return nil, false, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH) + } + + usernameHashBytes := tokenBytes[0:AUTH_USERNAME_HASH_LENGTH] + timestampBytes := tokenBytes[AUTH_USERNAME_HASH_LENGTH : AUTH_USERNAME_HASH_LENGTH+AUTH_TIMESTAMP_LENGTH] + providedSignatureBytes := tokenBytes[AUTH_TOKEN_DATA_LENGTH:] + + h := hmac.New(sha256.New, secretBytes[0:32]) + h.Write(tokenBytes[0:AUTH_TOKEN_DATA_LENGTH]) + expectedSignatureBytes := h.Sum(nil) + + if !hmac.Equal(expectedSignatureBytes, providedSignatureBytes) { + return nil, false, fmt.Errorf("signature does not match") + } + + expiresTimestamp := int64(binary.LittleEndian.Uint32(timestampBytes)) + if now.Unix() > expiresTimestamp { + return nil, false, fmt.Errorf("token has expired") + } + + return usernameHashBytes, + // True if the token should be regenerated + time.Unix(expiresTimestamp, 0).Add(-AUTH_TOKEN_REGEN_BEFORE).Before(now), + nil +} + +func makeAuthSecretKey(length int) (string, error) { + key := make([]byte, length) + _, err := rand.Read(key) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(key), nil +} + +func (a *application) handleAuthenticationAttempt(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Type") != "application/json" { + w.WriteHeader(http.StatusBadRequest) + return + } + + waitOnFailure := 1*time.Second - time.Duration(mathrand.IntN(500))*time.Millisecond + + ip := a.addressOfRequest(r) + + a.authAttemptsMu.Lock() + exceededRateLimit, retryAfter := func() (bool, int) { + attempt, exists := a.failedAuthAttempts[ip] + if !exists { + a.failedAuthAttempts[ip] = &failedAuthAttempt{ + attempts: 1, + first: time.Now(), + } + + return false, 0 + } + + elapsed := time.Since(attempt.first) + if elapsed < AUTH_RATE_LIMIT_WINDOW && attempt.attempts >= AUTH_RATE_LIMIT_MAX_ATTEMPTS { + return true, max(1, int(AUTH_RATE_LIMIT_WINDOW.Seconds()-elapsed.Seconds())) + } + + attempt.attempts++ + return false, 0 + }() + + if exceededRateLimit { + a.authAttemptsMu.Unlock() + time.Sleep(waitOnFailure) + w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) + w.WriteHeader(http.StatusTooManyRequests) + return + } else { + // Clean up old failed attempts + for ipOfAttempt := range a.failedAuthAttempts { + if time.Since(a.failedAuthAttempts[ipOfAttempt].first) > AUTH_RATE_LIMIT_WINDOW { + delete(a.failedAuthAttempts, ipOfAttempt) + } + } + a.authAttemptsMu.Unlock() + } + + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + var creds struct { + Username string `json:"username"` + Password string `json:"password"` + } + + err = json.Unmarshal(body, &creds) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + logAuthFailure := func() { + log.Printf( + "Failed login attempt for user '%s' from %s", + creds.Username, ip, + ) + } + + if len(creds.Username) == 0 || len(creds.Password) == 0 { + time.Sleep(waitOnFailure) + w.WriteHeader(http.StatusUnauthorized) + return + } + + if len(creds.Username) > 50 || len(creds.Password) > 100 { + logAuthFailure() + time.Sleep(waitOnFailure) + w.WriteHeader(http.StatusUnauthorized) + return + } + + u, exists := a.Config.Auth.Users[creds.Username] + if !exists { + logAuthFailure() + time.Sleep(waitOnFailure) + w.WriteHeader(http.StatusUnauthorized) + return + } + + if err := bcrypt.CompareHashAndPassword(u.PasswordHash, []byte(creds.Password)); err != nil { + logAuthFailure() + time.Sleep(waitOnFailure) + w.WriteHeader(http.StatusUnauthorized) + return + } + + token, err := generateSessionToken(creds.Username, a.authSecretKey, time.Now()) + if err != nil { + log.Printf("Could not compute session token during login attempt: %v", err) + time.Sleep(waitOnFailure) + w.WriteHeader(http.StatusUnauthorized) + return + } + + a.setAuthSessionCookie(w, r, token, time.Now().Add(AUTH_TOKEN_VALID_PERIOD)) + + a.authAttemptsMu.Lock() + delete(a.failedAuthAttempts, ip) + a.authAttemptsMu.Unlock() + + w.WriteHeader(http.StatusOK) +} + +func (a *application) isAuthorized(w http.ResponseWriter, r *http.Request) bool { + if !a.RequiresAuth { + return true + } + + token, err := r.Cookie(AUTH_SESSION_COOKIE_NAME) + if err != nil || token.Value == "" { + return false + } + + usernameHash, shouldRegenerate, err := verifySessionToken(token.Value, a.authSecretKey, time.Now()) + if err != nil { + return false + } + + username, exists := a.usernameHashToUsername[string(usernameHash)] + if !exists { + return false + } + + _, exists = a.Config.Auth.Users[username] + if !exists { + return false + } + + if shouldRegenerate { + newToken, err := generateSessionToken(username, a.authSecretKey, time.Now()) + if err != nil { + log.Printf("Could not compute session token during regeneration: %v", err) + return false + } + + a.setAuthSessionCookie(w, r, newToken, time.Now().Add(AUTH_TOKEN_VALID_PERIOD)) + } + + return true +} + +// Handles sending the appropriate response for an unauthorized request and returns true if the request was unauthorized +func (a *application) handleUnauthorizedResponse(w http.ResponseWriter, r *http.Request, fallback doWhenUnauthorized) bool { + if a.isAuthorized(w, r) { + return false + } + + switch fallback { + case redirectToLogin: + http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther) + case showUnauthorizedJSON: + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error": "Unauthorized"}`)) + } + + return true +} + +// Maybe this should be a POST request instead? +func (a *application) handleLogoutRequest(w http.ResponseWriter, r *http.Request) { + a.setAuthSessionCookie(w, r, "", time.Now().Add(-1*time.Hour)) + http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther) +} + +func (a *application) setAuthSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: AUTH_SESSION_COOKIE_NAME, + Value: token, + Expires: expires, + Secure: strings.ToLower(r.Header.Get("X-Forwarded-Proto")) == "https", + Path: a.Config.Server.BaseURL + "/", + SameSite: http.SameSiteLaxMode, + HttpOnly: true, + }) +} + +func (a *application) handleLoginPageRequest(w http.ResponseWriter, r *http.Request) { + if a.isAuthorized(w, r) { + http.Redirect(w, r, a.Config.Server.BaseURL+"/", http.StatusSeeOther) + return + } + + data := &templateData{ + App: a, + } + a.populateTemplateRequestData(&data.Request, r) + + var responseBytes bytes.Buffer + err := loginPageTemplate.Execute(&responseBytes, data) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + + w.Write(responseBytes.Bytes()) +} diff --git a/internal/glance/auth_test.go b/internal/glance/auth_test.go new file mode 100644 index 0000000..97e6bc9 --- /dev/null +++ b/internal/glance/auth_test.go @@ -0,0 +1,85 @@ +package glance + +import ( + "bytes" + "encoding/base64" + "testing" + "time" +) + +func TestAuthTokenGenerationAndVerification(t *testing.T) { + secret, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH) + if err != nil { + t.Fatalf("Failed to generate secret key: %v", err) + } + + secretBytes, err := base64.StdEncoding.DecodeString(secret) + if err != nil { + t.Fatalf("Failed to decode secret key: %v", err) + } + + if len(secretBytes) != AUTH_SECRET_KEY_LENGTH { + t.Fatalf("Secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH) + } + + now := time.Now() + username := "admin" + + token, err := generateSessionToken(username, secretBytes, now) + if err != nil { + t.Fatalf("Failed to generate session token: %v", err) + } + + usernameHashBytes, shouldRegen, err := verifySessionToken(token, secretBytes, now) + if err != nil { + t.Fatalf("Failed to verify session token: %v", err) + } + + if shouldRegen { + t.Fatal("Token should not need to be regenerated immediately after generation") + } + + computedUsernameHash, err := computeUsernameHash(username, secretBytes) + if err != nil { + t.Fatalf("Failed to compute username hash: %v", err) + } + + if !bytes.Equal(usernameHashBytes, computedUsernameHash) { + t.Fatal("Username hash does not match the expected value") + } + + // Test token regeneration + timeRightAfterRegenPeriod := now.Add(AUTH_TOKEN_VALID_PERIOD - AUTH_TOKEN_REGEN_BEFORE + 2*time.Second) + _, shouldRegen, err = verifySessionToken(token, secretBytes, timeRightAfterRegenPeriod) + if err != nil { + t.Fatalf("Token verification should not fail during regeneration period, err: %v", err) + } + + if !shouldRegen { + t.Fatal("Token should have been marked for regeneration") + } + + // Test token expiration + _, _, err = verifySessionToken(token, secretBytes, now.Add(AUTH_TOKEN_VALID_PERIOD+2*time.Second)) + if err == nil { + t.Fatal("Expected token verification to fail after token expiration") + } + + // Test tampered token + decodedToken, err := base64.StdEncoding.DecodeString(token) + if err != nil { + t.Fatalf("Failed to decode token: %v", err) + } + + // If any of the bytes are off by 1, the token should be considered invalid + for i := range len(decodedToken) { + tampered := make([]byte, len(decodedToken)) + copy(tampered, decodedToken) + tampered[i] += 1 + + _, _, err = verifySessionToken(base64.StdEncoding.EncodeToString(tampered), secretBytes, now) + if err == nil { + t.Fatalf("Expected token verification to fail for tampered token at index %d", i) + } + } +} diff --git a/internal/glance/cli.go b/internal/glance/cli.go index e231706..5544b8b 100644 --- a/internal/glance/cli.go +++ b/internal/glance/cli.go @@ -5,23 +5,41 @@ import ( "fmt" "os" "strings" + + "github.com/shirou/gopsutil/v4/disk" + "github.com/shirou/gopsutil/v4/sensors" ) type cliIntent uint8 const ( - cliIntentServe cliIntent = iota - cliIntentConfigValidate = iota - cliIntentConfigPrint = iota - cliIntentDiagnose = iota + cliIntentVersionPrint cliIntent = iota + cliIntentServe + cliIntentConfigValidate + cliIntentConfigPrint + cliIntentDiagnose + cliIntentSensorsPrint + cliIntentMountpointInfo + cliIntentSecretMake + cliIntentPasswordHash ) type cliOptions struct { intent cliIntent configPath string + args []string } func parseCliOptions() (*cliOptions, error) { + var args []string + + args = os.Args[1:] + if len(args) == 1 && (args[0] == "--version" || args[0] == "-v" || args[0] == "version") { + return &cliOptions{ + intent: cliIntentVersionPrint, + }, nil + } + flags := flag.NewFlagSet("", flag.ExitOnError) flags.Usage = func() { fmt.Println("Usage: glance [options] command") @@ -30,10 +48,15 @@ func parseCliOptions() (*cliOptions, error) { flags.PrintDefaults() fmt.Println("\nCommands:") - fmt.Println(" config:validate Validate the config file") - fmt.Println(" config:print Print the parsed config file with embedded includes") - fmt.Println(" diagnose Run diagnostic checks") + fmt.Println(" config:validate Validate the config file") + fmt.Println(" config:print Print the parsed config file with embedded includes") + fmt.Println(" password:hash{{ .Title }}
+ + {{- if .Description }} +