diff --git a/README.md b/README.md index 10b28eb..4f61e6e 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,10 @@ auth: path: /etc/docker/registry/htpasswd ``` +## Standalone Application +If you do not want to install the docker-registry-ui on your server, you may +check out the [Electron](electron/README.md) standalone application. + ## All examples - [Use docker-registry-ui as a proxy (use REGISTRY_URL)](https://github.com/Joxit/docker-registry-ui/tree/master/examples/ui-as-proxy) diff --git a/electron/.gitignore b/electron/.gitignore new file mode 100644 index 0000000..ed0e1c8 --- /dev/null +++ b/electron/.gitignore @@ -0,0 +1,8 @@ +# NPM renames .gitignore to .npmignore +# In order to prevent that, we remove the initial "." +# And the CLI then renames it + +dist/ +node_modules/ +Registry* +.cache diff --git a/electron/README.md b/electron/README.md new file mode 100644 index 0000000..65759b4 --- /dev/null +++ b/electron/README.md @@ -0,0 +1,57 @@ +# Standalone Application + +## Overview + +This standalone application is based on Electron which encapsulates the whole +docker-registry-ui in a single executable, that can be run on your local +computer. + +## Building +* Check out or download the repository, open a terminal at the checkout + directory, download the dependencies and build the web app: + ```bash + npm install + npm run build + ``` +* After building the web application, navigate to the ```electron``` directory + and execute following commands to build the executable: + ```bash + cd electron + npm install + npm run dist + ``` +If you encounter any issues, please check the troubleshooting below. + + +## Password Protected Registries +If you want to interact with password protected Docker Registries, this +application will use the keystore of your system to gather the credentials for +accessing the Registry. + +This is accomplished with the [keytar](https://www.npmjs.com/package/keytar) +package. In concjunction with keytar, the integrated credential +helper supports you with managing the credentials to the Registries. + +![alt Authentication on macOS](./doc/assets/authentication.gif) + + +## Troubleshooting +* Problem: The application does not start with ```npm start``` and exits with following message: + ``` + [7742:0509/001117.199224:FATAL:setuid_sandbox_host.cc(157)] The SUID sandbox helper binary was found, but is not configured correctly. Rather than run without sandboxing I'm aborting now. You need to make sure that ./node_modules/electron dist/chrome-sandbox is owned by root and has mode 4755. + ``` + + Solution: Add proper rights to the chrome-sanbox + ```bash + sudo chown root ./node_modules/electron/dist/chrome-sandbox + sudo chmod 4755 ./node_modules/electron/dist/chrome-sandbox + ``` + +* Problem: I am on Linux and to not have any password wallet for keytar. + + Solution: Install following dependencies according to the official [setup instructions](https://atom.github.io/node-keytar/) for keytar on Linux: + * Debian/Ubuntu: ```sudo apt-get install libsecret-1-dev``` + * Red Hat-based: ```sudo yum install libsecret-devel``` + * Arch Linux: ```sudo pacman -S libsecret``` + + diff --git a/electron/authentication/index.html b/electron/authentication/index.html new file mode 100644 index 0000000..194af0a --- /dev/null +++ b/electron/authentication/index.html @@ -0,0 +1,8 @@ + + + +
+ + + + \ No newline at end of file diff --git a/electron/authentication/index.tsx b/electron/authentication/index.tsx new file mode 100644 index 0000000..6790ec1 --- /dev/null +++ b/electron/authentication/index.tsx @@ -0,0 +1,211 @@ +import * as React from "react"; +import {useEffect, useState} from "react"; +import {render} from "react-dom"; +import * as keytar from 'keytar'; +import {ipcRenderer} from 'electron'; +import { + Button, + createMuiTheme, + CssBaseline, + IconButton, + LinearProgress, + makeStyles, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + ThemeProvider, + useMediaQuery +} from "@material-ui/core"; +import {Alert, AlertTitle} from '@material-ui/lab'; +import {blue} from "@material-ui/core/colors"; +import {Add as AddIcon, Delete as DeleteIcon, Save as SaveIcon} from "@material-ui/icons"; + +const mainStyle = makeStyles((theme) => ({ + root: { + padding: theme.spacing(2), + display: "flex", + flexDirection: 'column', + width: '100%', + height: '100%', + }, + main: { + flexGrow: 1, + paddingTop: theme.spacing(2), + paddingBottom: theme.spacing(2), + }, + input: { + width: '100%', + }, +})); + +function CredentialRow({credential, index, onDelete, onUpdate}) { + const [account, setAccount] = useState(credential?.account || ''); + const [password, setPassword] = useState(credential?.password || ''); + + const style = mainStyle(); + return ( + + { + setAccount(e.target.value) + }}/> + + + { + setPassword(e.target.value) + }}/> + + + await onUpdate(credential, index, {account, password})}> + + + await onDelete(credential, index,)}> + + + + ); +} + + +function CredentialsTable({onError}) { + const [credentials, setCredentials] = useState(null); + + async function loadItems() { + try { + const credentials = await keytar.findCredentials('docker-registry-ui'); + for (const credential of credentials) { + // fix for windows + credential.password = credential.password.replace(/\000+/g, ''); + } + setCredentials(credentials); + } catch (e) { + onError(e.toString()); + } + } + + async function handleDelete(item, index) { + // delete an item that has not been stored yet + if (!item) { + const newCredentials = [...credentials]; + newCredentials.splice(index, 1); + setCredentials(newCredentials); + return; + } + + try { + await keytar.deletePassword('docker-registry-ui', item.account); + await loadItems(); + } catch (e) { + onError(e.toString()); + } + } + + async function handleUpdate(oldCredentials, index, newCredentials) { + try { + await handleDelete(oldCredentials, index); + await keytar.setPassword('docker-registry-ui', newCredentials.account, newCredentials.password); + await loadItems(); + } catch (e) { + console.error("Error while updating key: ", e); + onError(e.toString()); + } + } + + useEffect(() => { + const load = async () => { + await loadItems(); + }; + + load(); + return; + }, []); + + if (credentials === null) { + return + } + + return ( + + + + + Host of the registry including username + Password + + { + setCredentials([...credentials, null]) + }} disabled={credentials.includes(null)}> + + + + + + + {credentials.map((credential, index) => )} + +
+
+ ); +} + +function App() { + const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); + const [error, setError] = useState(); + const classes = mainStyle(); + + const theme = React.useMemo( + () => + createMuiTheme({ + palette: { + type: prefersDarkMode ? 'dark' : 'light', + primary: blue, + }, + }), + [prefersDarkMode], + ); + + return ( + + +
+ {error && { + setError(null) + }}> + Error + {error} + } +
+ +
+
+
+ ); +} + +render(, document.getElementById("root")); + +// @ts-ignore +if (module.hot) { + // @ts-ignore + module.hot.accept(); +} + diff --git a/electron/doc/assets/authentication.gif b/electron/doc/assets/authentication.gif new file mode 100644 index 0000000..1e4e2bd Binary files /dev/null and b/electron/doc/assets/authentication.gif differ diff --git a/electron/index.js b/electron/index.js new file mode 100644 index 0000000..d656869 --- /dev/null +++ b/electron/index.js @@ -0,0 +1,229 @@ +const {app, BrowserWindow, globalShortcut, Menu} = require('electron'); +const isDevMode = require('electron-is-dev'); +const keytar = require('keytar'); +const url = require('url'); + +const isMac = process.platform === 'darwin' + +// Place holders for our windows so they don't get garbage collected. +let mainWindow = null; + +// Credentials that are fetched from the Keychain +let credentials = []; + +// Credentials helper window +let credentialsWindow; + +const template = [ + // { role: 'appMenu' } + ...(isMac ? [{ + label: app.name, + submenu: [ + {role: 'about'}, + {type: 'separator'}, + { + label: 'Preferences', accelerator: 'CmdorCtrl+,', click: () => { + credentialsWindow.show(); + } + }, + {type: 'separator'}, + {role: 'hide'}, + {role: 'hideothers'}, + {role: 'unhide'}, + {type: 'separator'}, + {role: 'quit'} + ] + }] : []), + // { role: 'fileMenu' } + { + label: 'File', + submenu: [ + ...(isMac ? [] : [{role: 'quit'}]), + { + label: 'Preferences', accelerator: 'CmdorCtrl+,', click: () => { + credentialsWindow.show(); + } + }, + ] + }, + // { role: 'editMenu' } + { + label: 'Edit', + submenu: [ + {role: 'undo'}, + {role: 'redo'}, + {type: 'separator'}, + {role: 'cut'}, + {role: 'copy'}, + {role: 'paste'}, + ...(isMac ? [ + {role: 'pasteAndMatchStyle'}, + {role: 'delete'}, + {role: 'selectAll'}, + {type: 'separator'}, + { + label: 'Speech', + submenu: [ + {role: 'startspeaking'}, + {role: 'stopspeaking'} + ] + } + ] : [ + {role: 'delete'}, + {type: 'separator'}, + {role: 'selectAll'} + ]) + ] + }, + // { role: 'viewMenu' } + { + label: 'View', + submenu: [ + {role: 'reload'}, + {role: 'forcereload'}, + {role: 'toggledevtools'}, + {type: 'separator'}, + {role: 'resetzoom'}, + {role: 'zoomin'}, + {role: 'zoomout'}, + {type: 'separator'}, + {role: 'togglefullscreen'}, + {type: 'separator'}, + { + label: 'Credentials Helper', accelerator: 'CmdorCtrl+k', click: () => { + credentialsWindow.show(); + } + }, + ] + }, + // { role: 'windowMenu' } + { + label: 'Window', + submenu: [ + {role: 'minimize'}, + {role: 'zoom'}, + ...(isMac ? [ + {type: 'separator'}, + {role: 'front'}, + {type: 'separator'}, + {role: 'window'} + ] : [ + {role: 'close'} + ]) + ] + }, + { + role: 'help', + submenu: [ + { + label: 'Learn More', + click: async () => { + const {shell} = require('electron') + await shell.openExternal('https://joxit.dev/docker-registry-ui/') + } + } + ] + } +]; + +const menu = Menu.buildFromTemplate(template); +if (isMac) { + Menu.setApplicationMenu(menu); +} + +async function loadCredentials() { + try { + credentials = await keytar.findCredentials('docker-registry-ui'); + for (const credential of credentials) { + // fix for windows + credential.password = credential.password.replace(/\000+/g, ''); + } + } catch (e) { + console.log(e); + credentials = []; + } +} + +async function createWindow() { + return new Promise((resolve, reject) => { + mainWindow = new BrowserWindow({ + height: 920, + width: 1600, + show: false, + webPreferences: { + nodeIntegration: false, + } + }); + + if (isDevMode) { + mainWindow.webContents.openDevTools(); + } + + if (!isMac) { + mainWindow.setMenu(menu); + } + + mainWindow.loadURL(`file://${__dirname}/dist/index.html`); + mainWindow.webContents.on('dom-ready', () => { + console.log("Main Window DOM ready"); + resolve(); + }); + }); +} + +async function createCredentialsWindow() { + return new Promise((resolve) => { + credentialsWindow = new BrowserWindow({ + width: 1000, + height: 400, + show: false, + title: 'Credential Manager', + parent: mainWindow, + webPreferences: { + nodeIntegration: true, + } + }); + + if (isDevMode) { + credentialsWindow.openDevTools(); + } + + if (!isMac) { + credentialsWindow.setMenu(null); + } + + credentialsWindow.loadURL(`file://${__dirname}/dist/authentication/index.html`); + credentialsWindow.webContents.on('dom-ready', () => { + console.log('Credentials Window DOM is ready'); + resolve(); + }); + + credentialsWindow.on('close', async (e) => { + console.log("Closed credential window"); + credentialsWindow.hide(); + e.preventDefault(); + await loadCredentials(); + mainWindow.reload(); + }); + }); +} + +app.on('ready', async () => { + await Promise.all([ + loadCredentials(), + createWindow(), + createCredentialsWindow(), + ]); + mainWindow.show(); +}); + + +app.on("login", (event, contents, authencation, info, callback) => { + for (const credential of credentials) { + const parsedUrl = url.parse(credential.account); + if (parsedUrl.hostname === info.host) { + return callback(parsedUrl.auth, credential.password); + } + } + callback(); +}); diff --git a/electron/package.json b/electron/package.json new file mode 100644 index 0000000..8564b03 --- /dev/null +++ b/electron/package.json @@ -0,0 +1,39 @@ +{ + "name": "docker-registry-ui", + "version": "1.4.8", + "productName": "Registry UI", + "description": "Electron Application for Docker Registry UI", + "main": "index.js", + "scripts": { + "start": "electron ./", + "start:dev": "parcel serve -d dist/authentication -t electron --public-url ./ authentication/index.html", + "build": "parcel build -d dist/authentication -t electron --public-url ./ authentication/index.html", + "rebuild": "electron-rebuild -f -w keytar", + "package": "electron-packager --overwrite .", + "sync": "copyfiles ../dist/* ../dist/**/* out", + "dist": "npm run rebuild && npm run sync && npm run build && npm run package" + }, + "dependencies": { + "@material-ui/core": "^4.9.13", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "^4.0.0-alpha.52", + "electron-is-dev": "^1.1.0", + "keytar": "^5.6.0", + "react": "^16.13.1", + "react-dom": "^16.13.1" + }, + "devDependencies": { + "copyfiles": "^2.2.0", + "electron": "^8.0.0", + "electron-builder": "^22.6.0", + "electron-packager": "^14.2.1", + "electron-rebuild": "^1.10.1", + "parcel-bundler": "^1.12.4", + "typescript": "^3.8.3" + }, + "keywords": [ + "electron" + ], + "author": "", + "license": "AGPL-3.0" +} diff --git a/package.json b/package.json index 9bea700..c339131 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "name": "docker-registry-ui", "version": "1.4.8", "scripts": { - "build": "./node_modules/gulp/bin/gulp.js build" + "build": "./node_modules/gulp/bin/gulp.js build", + "build:electron": "npm run build && cd electron && npm install && npm run dist" }, "repository": { "type": "git",