diff --git a/.env b/.env index 612a0937af..7200bc095c 100644 --- a/.env +++ b/.env @@ -27,6 +27,17 @@ # The path to the user data directory # USER_DATA_DIR=user-data +# Enable HTTP basic auth to protect your *.yml config files +# ENABLE_HTTP_AUTH=true + +# Enable basic HTTP auth to protect your *.yml config files +# BASIC_AUTH_USERNAME +# BASIC_AUTH_PASSWORD + +# If you'd like frontend to automatically authenticate when basic auth enabled, set credentials here too +# VUE_APP_BASIC_AUTH_USERNAME +# VUE_APP_BASIC_AUTH_PASSWORD + # Override where the path to the configuration file is, can be a remote URL # VUE_APP_CONFIG_PATH=/conf.yml @@ -52,7 +63,7 @@ # VUE_APP_VERSION=2.0.0 # Directory for conf.yml backups -# BACKUP_DIR=./user-data/ +# BACKUP_DIR=./user-data/config-backups # Setup any other user defined vars by prepending VUE_APP_ to the var name # VUE_APP_pihole_ip=http://your.pihole.ip diff --git a/.github/workflows/new-issues-check.yml b/.github/workflows/new-issues-check.yml deleted file mode 100644 index e90314bf59..0000000000 --- a/.github/workflows/new-issues-check.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: ⭐ Hello non-Stargazers -on: - issues: - types: [opened, reopened] -jobs: - check-user: - if: > - ${{ - ! contains( github.event.issue.labels.*.name, '📌 Keep Open') && - ! contains( github.event.issue.labels.*.name, '🌈 Feedback') && - ! contains( github.event.issue.labels.*.name, '💯 Showcase') && - github.event.comment.author_association != 'CONTRIBUTOR' - }} - runs-on: ubuntu-latest - name: Add comment to issues opened by non-stargazers - steps: - - name: comment - uses: qxip/please-star-light@v4 - with: - token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - autoclose: false - message: "If you're enjoying Dashy, consider dropping us a ⭐
_🤖 I'm a bot, and this message was automated_" diff --git a/Dockerfile b/Dockerfile index cba3f347fb..5ac548a997 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,7 +42,7 @@ RUN apk add --no-cache tzdata COPY --from=BUILD_IMAGE /app ./ # Finally, run start command to serve up the built application -CMD [ "yarn", "build-and-start" ] +CMD [ "yarn", "start" ] # Expose the port EXPOSE ${PORT} diff --git a/README.md b/README.md index 94c1218524..da37aa0fec 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,14 @@ User Showcase | Live Demo | Getting Started | Documentation | GitHub

+

+
+Dashy is kindly sponsored by Umbrel - the personal home cloud and OS for self-hosting
+ + + +

+ > [!NOTE] > Version [3.0.0](https://github.com/Lissy93/dashy/releases/tag/3.0.0) has been released, and requires some changes to your setup, see [#1529](https://github.com/Lissy93/dashy/discussions/1529) for details. diff --git a/docker-compose.yml b/docker-compose.yml index 9eb391eb03..9573c6668e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,7 @@ services: # - /path/to/my-config.yml:/app/user-data/conf.yml # - /path/to/item-icons:/app/user-data/item-icons/ - # Set port that web service will be served on. Keep container port as 80 + # Set port that web service will be served on. Keep container port as 8080 ports: - 4000:8080 diff --git a/docs/authentication.md b/docs/authentication.md index c7e74f8a77..4430d9b067 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -6,7 +6,10 @@ - [Logging In and Out](#logging-in-and-out) - [Guest Access](#enabling-guest-access) - [Per-User Access](#granular-access) + - [Using Environment Variables for Passwords](#using-environment-variables-for-passwords) + - [Adding HTTP Auth to Configuration](#adding-http-auth-to-configuration) - [Security Considerations](#security) +- [HTTP Auth](#http-auth) - [Keycloak Auth](#keycloak) - [Deploying Keycloak](#1-deploy-keycloak) - [Setting up Keycloak](#2-setup-keycloak-users) @@ -115,6 +118,27 @@ You can also prevent any user from writing changes to disk, using `preventWriteT To disable all UI config features, including View Config, set `disableConfiguration`. Alternatively you can disable UI config features for all non admin users by setting `disableConfigurationForNonAdmin` to true. +### Using Environment Variables for Passwords + +If you don't want to hash your password, you can instead leave out the `hash` attribute, and replace it with `password` which should have the value of an environmental variable name you wish to use. + +Note that env var must begin with `VUE_APP_`, and you must set this variable before building the app. + +For example: + +```yaml + auth: + users: + - user: bob + password: VUE_APP_BOB +``` + +Just be sure to set `VUE_APP_BOB='my super secret password'` before build-time. + +### Adding HTTP Auth to Configuration + +If you'd also like to prevent direct visit access to your configuration file, you can set the `ENABLE_HTTP_AUTH` environmental variable. + ### Security With basic auth, all logic is happening on the client-side, which could mean a skilled user could manipulate the code to view parts of your configuration, including the hash. If the SHA-256 hash is of a common password, it may be possible to determine it, using a lookup table, in order to find the original password. Which can be used to manually generate the auth token, that can then be inserted into session storage, to become a valid logged in user. Therefore, you should always use a long, strong and unique password, and if you instance contains security-critical info and/ or is exposed directly to the internet, and alternative authentication method may be better. The purpose of the login page is merely to prevent immediate unauthorized access to your homepage. @@ -123,6 +147,16 @@ With basic auth, all logic is happening on the client-side, which could mean a s --- +## HTTP Auth + +If you'd like to protect all your config files from direct access, you can set the `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` environmental variables. You'll then be prompted to enter these credentials when visiting Dashy. + +Then, if you'd like your frontend to automatically log you in, without prompting you for credentials, then also specify `VUE_APP_BASIC_AUTH_USERNAME` and `VUE_APP_BASIC_AUTH_PASSWORD`. This is useful for when you're hosting Dashy on a private server, and you want to prevent unauthorized access to your config files, while still allowing the frontend to access them. Note that a rebuild is required for these changes to take effect. + +**[⬆️ Back to Top](#authentication)** + +--- + ## Keycloak Dashy also supports using a [Keycloak](https://www.keycloak.org/) authentication server. The setup for this is a bit more involved, but it gives you greater security overall, useful for if your instance is exposed to the internet. diff --git a/docs/quick-start.md b/docs/quick-start.md index 1fa0060e3c..6d0d550ddf 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -32,7 +32,32 @@ Your dashboard should now be up and running at `http://localhost:8080` (or your --- -## 3. Configure +## 3. User Data Directory + +Your config file should be placed inside `user-data/` (in Docker, that's `/app/user-data/`). + +This directory can also contain some optional assets you wish to use within your dashboard, like icons, fonts, styles, scripts, etc. + +Any files placed here will be served up to the root of the domain, and override the contents of `public/`. +For example, if you had `user-data/favicon.ico` this would be accessible at `http://my-dashy-instance.local/favicon.ico` + +Example Files in `user-data`: +- `conf.yml` - This is the only file that is compulsary, it's your main Dashy config +- `**.yml` - Include more config files, if you'd like to have multiple pages, see [Multi-page support](/docs/pages-and-sections.md#multi-page-support) for docs +- `favicon.ico` - The default favicon, shown in the browser's tab title +- `initialization.html` - Static HTML page displayed before the app has finished compiling, see [`public/initialization.html`](https://github.com/Lissy93/dashy/blob/master/public/initialization.html) +- `robots.txt` - Search engine crawl rules, override this if you want your dashboard to be indexable +- `manifest.json` - PWA configuration file, for installing Dashy on mobile devices +- `index.html` - The main index page which initializes the client-side app, copy it from [`/public/index.html`](https://github.com/Lissy93/dashy/blob/master/public/index.html) +- `**.html` - Write your own HTML pages, and access them at `http://my-dashy-instance.local/my-page.html` +- `fonts/` - Custom fonts (be sure to include the ones already in [`public/fonts`](https://github.com/Lissy93/dashy/tree/master/public/fonts) +- `item-icons/` - To use your own icons for items on your dashboard, see [Icons --> Local Icons](/docs/icons.md#local-icons) +- `web-icons/` - Override Dashy logo +- `widget-resources/` - Fonts, icons and assets for custom widgets + +--- + +## 4. Configure Now that you've got Dashy running, you are going to want to set it up with your own content. Config is written in [YAML Format](https://yaml.org/), and saved in [`/user-data/conf.yml`](https://github.com/Lissy93/dashy/blob/master/user-data/conf.yml). @@ -41,6 +66,7 @@ The format on the config file is pretty straight forward. There are three root a - [`pageInfo`](https://github.com/Lissy93/dashy/blob/master/docs/configuring.md#pageinfo) - Dashboard meta data, like title, description, nav bar links and footer text - [`appConfig`](https://github.com/Lissy93/dashy/blob/master/docs/configuring.md#appconfig-optional) - Dashboard settings, like themes, authentication, language and customization - [`sections`](https://github.com/Lissy93/dashy/blob/master/docs/configuring.md#section) - An array of sections, each including an array of items +- [`pages`](https://github.com/Lissy93/dashy/blob/master/docs/configuring.md#pages-optional) - Have multiples pages in your dashboard You can view a full list of all available config options in the [Configuring Docs](https://github.com/Lissy93/dashy/blob/master/docs/configuring.md). @@ -76,11 +102,11 @@ Notes: - It's also possible to edit your config directly through the UI, and changes will be saved in this file - Check your config against Dashy's schema, with `docker exec -it [container-id] yarn validate-config` - You might find it helpful to look at some examples, a collection of which can be [found here](https://gist.github.com/Lissy93/000f712a5ce98f212817d20bc16bab10) -- After editing your config, the app will rebuild in the background, which may take a minute +- It's also possible to load a remote config, e.g. from a GitHub Gist --- -## 4. Further Customisation +## 5. Further Customisation Once you've got Dashy setup, you'll want to ensure the container is properly healthy, secured, backed up and kept up-to-date. All this is covered in the [Management Docs](https://github.com/Lissy93/dashy/blob/master/docs/management.md). @@ -97,7 +123,7 @@ You might also want to check out the docs for specific features you'd like to us --- -## 5. Final Note +## 6. Final Note If you need any help or support in getting Dashy running, head over to the [Discussions](https://github.com/Lissy93/dashy/discussions) page. If you think you've found a bug, please do [raise it](https://github.com/Lissy93/dashy/issues/new/choose) so it can be fixed. For contact options, see the [Support Page](https://github.com/Lissy93/dashy/blob/master/.github/SUPPORT.md). @@ -118,7 +144,7 @@ yarn build # Build the app yarn start # Start the app ``` -Then edit `./user-data/conf.yml` and rebuild the app with `yarn build` +Then edit `./user-data/conf.yml` --- diff --git a/package.json b/package.json index 27573a1909..7acf0678c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dashy", - "version": "3.0.0", + "version": "3.0.1", "license": "MIT", "main": "server", "author": "Alicia Sykes (https://aliciasykes.com)", @@ -26,6 +26,7 @@ "connect-history-api-fallback": "^1.6.0", "crypto-js": "^4.2.0", "express": "^4.17.2", + "express-basic-auth": "^1.2.1", "frappe-charts": "^1.6.2", "js-yaml": "^4.1.0", "keycloak-js": "^20.0.3", diff --git a/public/img/icons/android-chrome-512x512.png b/public/img/icons/android-chrome-512x512.png deleted file mode 100644 index 8e070b2da8..0000000000 Binary files a/public/img/icons/android-chrome-512x512.png and /dev/null differ diff --git a/public/img/icons/apple-touch-icon-152x152.png b/public/img/icons/apple-touch-icon-152x152.png deleted file mode 100644 index 8e070b2da8..0000000000 Binary files a/public/img/icons/apple-touch-icon-152x152.png and /dev/null differ diff --git a/public/img/icons/favicon-16x16.png b/public/img/icons/favicon-16x16.png deleted file mode 100644 index 629a36871e..0000000000 Binary files a/public/img/icons/favicon-16x16.png and /dev/null differ diff --git a/public/initialization.html b/public/initialization.html index 49d446c5f3..15e4756aec 100644 --- a/public/initialization.html +++ b/public/initialization.html @@ -50,6 +50,14 @@

Initializing

This may take a minute or two

+
+

Why are you seeing this screen?

+

+ The app's built files aren't yet present in the /dist directory, + so this page is displayed while we compile the source. +

+
+ - \ No newline at end of file + diff --git a/server.js b/server.js index b0a8335f4a..d7f5567feb 100644 --- a/server.js +++ b/server.js @@ -6,14 +6,20 @@ * */ /* Import built-in Node server modules */ +const fs = require('fs'); +const os = require('os'); +const dns = require('dns'); const http = require('http'); const path = require('path'); const util = require('util'); -const dns = require('dns'); -const os = require('os'); +const crypto = require('crypto'); + +/* Import NPM dependencies */ +const yaml = require('js-yaml'); /* Import Express + middleware functions */ const express = require('express'); +const basicAuth = require('express-basic-auth'); const history = require('connect-history-api-fallback'); /* Kick of some basic checks */ @@ -61,7 +67,7 @@ const printWelcomeMessage = () => { console.log(printMessage(ip, port, isDocker)); // eslint-disable-line no-console }); } catch (e) { - // Fetching info for welcome message failed, print simple msg instead + // No clue what could of gone wrong here, but print fallback message if above failed console.log(`Dashy server has started (${port})`); // eslint-disable-line no-console } }; @@ -71,6 +77,64 @@ const printWarning = (msg, error) => { console.warn(`\x1b[103m\x1b[34m${msg}\x1b[0m\n`, error || ''); // eslint-disable-line no-console }; +/* Load appConfig.auth.users from config (if present) for authorization purposes */ +function loadUserConfig() { + try { + const filePath = path.join(__dirname, process.env.USER_DATA_DIR || 'user-data', 'conf.yml'); + const fileContents = fs.readFileSync(filePath, 'utf8'); + const data = yaml.load(fileContents); + return data?.appConfig?.auth?.users || null; + } catch (e) { + return []; + } +} + +/* If HTTP auth is enabled, and no username/password are pre-set, then check passed credentials */ +function customAuthorizer(username, password) { + const sha256 = (input) => crypto.createHash('sha256').update(input).digest('hex').toUpperCase(); + const generateUserToken = (user) => { + if (!user.user || (!user.hash && !user.password)) return ''; + const strAndUpper = (input) => input.toString().toUpperCase(); + const passwordHash = user.hash || sha256(process.env[user.password]); + const sha = sha256(strAndUpper(user.user) + strAndUpper(passwordHash)); + return strAndUpper(sha); + }; + if (password.startsWith('Bearer ')) { + const token = password.slice('Bearer '.length); + const users = loadUserConfig(); + return users.some(user => generateUserToken(user) === token); + } else { + const users = loadUserConfig(); + const userHash = sha256(password); + return users.some(user => ( + user.user.toLowerCase() === username.toLowerCase() && user.hash.toUpperCase() === userHash + )); + } +} + +/* If a username and password are set, setup auth for config access, otherwise skip */ +function getBasicAuthMiddleware() { + const configUsers = process.env.ENABLE_HTTP_AUTH ? loadUserConfig() : null; + const { BASIC_AUTH_USERNAME, BASIC_AUTH_PASSWORD } = process.env; + if (BASIC_AUTH_USERNAME && BASIC_AUTH_PASSWORD) { + return basicAuth({ + users: { [BASIC_AUTH_USERNAME]: BASIC_AUTH_PASSWORD }, + challenge: true, + unauthorizedResponse: () => 'Unauthorized - Incorrect username or password', + }); + } else if ((configUsers && configUsers.length > 0)) { + return basicAuth({ + authorizer: customAuthorizer, + challenge: true, + unauthorizedResponse: () => 'Unauthorized - Incorrect token', + }); + } else { + return (req, res, next) => next(); + } +} + +const protectConfig = getBasicAuthMiddleware(); + /* A middleware function for Connect, that filters requests based on method type */ const method = (m, mw) => (req, res, next) => (req.method === m ? mw(req, res, next) : next()); @@ -134,6 +198,11 @@ const app = express() res.end(JSON.stringify({ success: false, message: e })); } }) + // Middleware to serve any .yml files in USER_DATA_DIR with optional protection + .get('/*.yml', protectConfig, (req, res) => { + const ymlFile = req.path.split('/').pop(); + res.sendFile(path.join(__dirname, process.env.USER_DATA_DIR || 'user-data', ymlFile)); + }) // Serves up static files .use(express.static(path.join(__dirname, process.env.USER_DATA_DIR || 'user-data'))) .use(express.static(path.join(__dirname, 'dist'))) diff --git a/services/config-validator.js b/services/config-validator.js index f7786e0ec2..372f432aff 100644 --- a/services/config-validator.js +++ b/services/config-validator.js @@ -11,7 +11,7 @@ const schema = require('../src/utils/ConfigSchema.json'); /* Tell AJV to use strict mode, and report all errors */ const validatorOptions = { - strict: true, + strict: false, allowUnionTypes: true, allErrors: true, }; diff --git a/services/get-user.js b/services/get-user.js index b9c9e45795..4d10ccdd5a 100644 --- a/services/get-user.js +++ b/services/get-user.js @@ -1,15 +1,15 @@ module.exports = (config, req) => { try { - if ( config.appConfig.auth.enableHeaderAuth ) { - const userHeader = config.appConfig.auth.headerAuth.userHeader; - const proxyWhitelist = config.appConfig.auth.headerAuth.proxyWhitelist; - if ( proxyWhitelist.includes(req.socket.remoteAddress) ) { - return { "success": true, "user": req.headers[userHeader.toLowerCase()] }; + if (config.appConfig.auth.enableHeaderAuth) { + const { userHeader } = config.appConfig.auth.headerAuth; + const { proxyWhitelist } = config.appConfig.auth.headerAuth; + if (proxyWhitelist.includes(req.socket.remoteAddress)) { + return { success: true, user: req.headers[userHeader.toLowerCase()] }; } } return {}; } catch (e) { - console.warn("Error get-user: ", e); - return { 'success': false }; + console.warn('Error get-user: ', e); + return { success: false }; } -}; \ No newline at end of file +}; diff --git a/services/save-config.js b/services/save-config.js index f946be54ae..1665c3c435 100644 --- a/services/save-config.js +++ b/services/save-config.js @@ -14,18 +14,23 @@ module.exports = async (newConfig, render) => { return configObj.filename.replaceAll('/', '').replaceAll('..', ''); }; + // Path to config file (with navigational characters stripped) const usersFileName = makeSafeFileName(newConfig); + // Path to user data directory + const userDataDirectory = process.env.USER_DATA_DIR || './user-data/'; + // Define constants for the config file const settings = { - defaultLocation: process.env.USER_DATA_DIR || './user-data/', + defaultLocation: userDataDirectory, + backupLocation: process.env.BACKUP_DIR || path.join(userDataDirectory, 'config-backups'), defaultFile: 'conf.yml', filename: 'conf', backupDenominator: '.backup.yml', }; // Make the full file name and path to save the backup config file - const backupFilePath = `${path.normalize(process.env.BACKUP_DIR || settings.defaultLocation) + const backupFilePath = `${path.normalize(settings.backupLocation) }/${usersFileName || settings.filename}-` + `${Math.round(new Date() / 1000)}${settings.backupDenominator}`; @@ -45,15 +50,20 @@ module.exports = async (newConfig, render) => { message: !success ? errorMsg : getSuccessMessage(), }); - // Makes a backup of the existing config file + // Create a backup of current config, and if backup dir doesn't yet exist, create it await fsPromises - .copyFile(defaultFilePath, backupFilePath) - .catch((error) => render(getRenderMessage(false, `Unable to backup ${settings.defaultFile}: ${error}`))); + .mkdir(settings.backupLocation, { recursive: true }) + .then(() => fsPromises.copyFile(defaultFilePath, backupFilePath)) + .catch((error) => render( + getRenderMessage(false, `Unable to backup ${settings.defaultFile}: ${error}`), + )); // Writes the new content to the conf.yml file await fsPromises .writeFile(defaultFilePath, newConfig.config.toString(), writeFileOptions) - .catch((error) => render(getRenderMessage(false, `Unable to write to ${settings.defaultFile}: ${error}`))); + .catch((error) => render( + getRenderMessage(false, `Unable to write to ${settings.defaultFile}: ${error}`), + )); // If successful, then render hasn't yet been called- call it await render(getRenderMessage(true)); diff --git a/src/App.vue b/src/App.vue index 4cfd5d5db2..a752977607 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,6 +4,7 @@
+