From f2a55b5b8ba4c8d5c609e1fe518eea5b54556d65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Horvat?= Date: Thu, 30 Apr 2026 10:28:31 +0200 Subject: [PATCH 1/2] feat(docker-pull): support pulling private images via ~/.docker/config.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dockerode sends unauthenticated API calls to the Docker Engine, so community app store apps that use private registry images (e.g. ghcr.io) fail with 401 Unauthorized even when the host has valid credentials. Add getAuthConfig() which reads /root/.docker/config.json (umbreld runs as root) and extracts the authconfig for the target registry. The config is passed to docker.pull() so private images are pulled with the same credentials that `docker login` stored on the host. Falls back to unauthenticated pull (existing behaviour) if: - /root/.docker/config.json does not exist - No entry found for the target registry - Any error occurs during config read Tested with private ghcr.io images on UmbrelOS 1.x. fs-extra is already a dependency of umbreld — no new packages required. --- .../source/modules/utilities/docker-pull.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/umbreld/source/modules/utilities/docker-pull.ts b/packages/umbreld/source/modules/utilities/docker-pull.ts index 975e206dc2..415e92014c 100644 --- a/packages/umbreld/source/modules/utilities/docker-pull.ts +++ b/packages/umbreld/source/modules/utilities/docker-pull.ts @@ -1,17 +1,40 @@ import Dockerode from 'dockerode' +import fse from 'fs-extra' const docker = new Dockerode() const DOWNLOADING_PERCENT = 0.75 const EXTRACTING_PERCENT = 0.25 +async function getAuthConfig(image: string): Promise { + try { + const config = await fse.readJson('/root/.docker/config.json') + const firstSegment = image.split('/')[0] + const registry = firstSegment.includes('.') || firstSegment.includes(':') + ? firstSegment + : 'https://index.docker.io/v1/' + const entry = config?.auths?.[registry] + if (entry?.auth) { + const decoded = Buffer.from(entry.auth, 'base64').toString() + const colonIndex = decoded.indexOf(':') + const username = decoded.slice(0, colonIndex) + const password = decoded.slice(colonIndex + 1) + const serveraddress = registry.startsWith('http') ? registry : `https://${registry}` + return {username, password, serveraddress} + } + } catch {} + return undefined +} + export async function pull( image: string, updateProgress: (progress: number) => void, handleAlreadyDownloaded: () => void, ) { + const authconfig = await getAuthConfig(image) + return new Promise((resolve, reject) => { - docker.pull(image, (error: Error, stream: NodeJS.ReadableStream) => { + docker.pull(image, {authconfig}, (error: Error, stream: NodeJS.ReadableStream) => { if (error) return reject(error) const layerProgress: Record = {} From 6db4c83b609bccac12fad08d3408ba493679026f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Horvat?= Date: Fri, 1 May 2026 10:36:51 +0200 Subject: [PATCH 2/2] Changes to UI (AppStore private registry credentials) --- packages/ui/public/locales/en.json | 10 +- .../app-store-preferences-content.tsx | 162 +++++++++++------- .../_components/settings-content-mobile.tsx | 5 +- .../settings/_components/settings-content.tsx | 5 +- packages/umbreld/package-lock.json | 4 +- .../umbreld/source/modules/apps/routes.ts | 51 ++++++ 6 files changed, 171 insertions(+), 66 deletions(-) diff --git a/packages/ui/public/locales/en.json b/packages/ui/public/locales/en.json index ea1bd50d4d..3fb6fee73a 100644 --- a/packages/ui/public/locales/en.json +++ b/packages/ui/public/locales/en.json @@ -47,7 +47,7 @@ "app-store.category.media": "Media", "app-store.category.networking": "Networking", "app-store.category.social": "Social", - "app-store.description": "Your app update settings", + "app-store.description": "Registries, preferences", "app-store.discover.temporarily-unavailable-description": "Browse categories above or search to find apps", "app-store.discover.temporarily-unavailable-title": "Featured content temporarily unavailable", "app-store.menu.community-app-stores": "Community App Stores", @@ -961,6 +961,14 @@ "search": "Search", "settings": "Settings", "settings.app-store-preferences.title": "App Store Preferences", + "settings.app-store-preferences.registry-credentials.title": "Private Registry Credentials", + "settings.app-store-preferences.registry-credentials.description": "Login credentials for private container registries (e.g. ghcr.io). Used when installing community apps that require private images.", + "settings.app-store-preferences.registry-credentials.add-button": "Add registry", + "settings.app-store-preferences.registry-credentials.registry-placeholder": "Registry host (e.g. ghcr.io)", + "settings.app-store-preferences.registry-credentials.username-placeholder": "Username", + "settings.app-store-preferences.registry-credentials.token-placeholder": "Token / Password", + "settings.app-store-preferences.registry-credentials.save": "Save", + "settings.app-store-preferences.registry-credentials.remove": "Remove", "settings.contact-support": "Need help? Contact support.", "settings.file-sharing": "File sharing", "settings.file-sharing.add-folder": "Add", diff --git a/packages/ui/src/routes/settings/_components/app-store-preferences-content.tsx b/packages/ui/src/routes/settings/_components/app-store-preferences-content.tsx index 84e0183c3a..532633c5a7 100644 --- a/packages/ui/src/routes/settings/_components/app-store-preferences-content.tsx +++ b/packages/ui/src/routes/settings/_components/app-store-preferences-content.tsx @@ -1,71 +1,115 @@ import {useState} from 'react' -import {TbChevronRight} from 'react-icons/tb' +import {useTranslation} from 'react-i18next' +import {TbLock, TbPlus, TbTrash} from 'react-icons/tb' -import {ChevronDown} from '@/components/chevron-down' import {Button} from '@/components/ui/button' -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' +import {Input, PasswordInput} from '@/components/ui/input' import {listClass, listItemClass} from '@/components/ui/list' -import {SegmentedControl} from '@/components/ui/segmented-control' -import {Switch} from '@/components/ui/switch' +import {trpcReact} from '@/trpc/trpc' export function AppStorePreferencesContent() { - const tabs = [ - {id: 'auto-update', label: 'Auto-update'}, - {id: 'notifications', label: 'Notifications'}, - {id: 'uninstall', label: 'Uninstall'}, - ] - const [activeTab, setActiveTab] = useState(tabs[0].id) + const {t} = useTranslation() + const utils = trpcReact.useUtils() + + const credentialsQ = trpcReact.appStore.getRegistryCredentials.useQuery() + const setMut = trpcReact.appStore.setRegistryCredential.useMutation({ + onSuccess: () => utils.appStore.getRegistryCredentials.invalidate(), + }) + const removeMut = trpcReact.appStore.removeRegistryCredential.useMutation({ + onSuccess: () => utils.appStore.getRegistryCredentials.invalidate(), + }) + + const [showForm, setShowForm] = useState(false) + const [registry, setRegistry] = useState('') + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + + const resetForm = () => { + setRegistry('') + setUsername('') + setPassword('') + setShowForm(false) + } + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault() + await setMut.mutateAsync({registry: registry.trim(), username: username.trim(), password}) + resetForm() + } + + const credentials = credentialsQ.data ?? [] return ( - <> -
- -
- Single value selector - - - - - - English - French - - -
- +
+
+

+ + {t('settings.app-store-preferences.registry-credentials.title')} +

+

+ {t('settings.app-store-preferences.registry-credentials.description')} +

- -
- -
-
-
- Lighting node - -
-
- Lighting node - + + {credentials.length > 0 && ( +
+ {credentials.map(({registry: reg}) => ( +
+ {reg} + +
+ ))}
-
- + )} + + {!showForm && ( + + )} + + {showForm && ( +
+ + + +
+ + +
+ + )} +
) } diff --git a/packages/ui/src/routes/settings/_components/settings-content-mobile.tsx b/packages/ui/src/routes/settings/_components/settings-content-mobile.tsx index 1ca68e36dc..9805944e66 100644 --- a/packages/ui/src/routes/settings/_components/settings-content-mobile.tsx +++ b/packages/ui/src/routes/settings/_components/settings-content-mobile.tsx @@ -10,6 +10,7 @@ import { TbServer, TbSettingsMinus, TbShare, + TbShoppingBag, TbTool, TbUser, TbWifi, @@ -210,12 +211,12 @@ export function SettingsContentMobile() { description={t('backups-description')} onClick={() => navigate('backups')} /> - {/* navigate(linkToDialog('app-store-preferences'))} - /> */} + />
- {/* + navigate(linkToDialog('app-store-preferences'))}> {t('preferences')} - */} + navigate('troubleshoot')}> {t('troubleshoot')} diff --git a/packages/umbreld/package-lock.json b/packages/umbreld/package-lock.json index eda46097aa..ff453eef3a 100644 --- a/packages/umbreld/package-lock.json +++ b/packages/umbreld/package-lock.json @@ -1,12 +1,12 @@ { "name": "umbreld", - "version": "1.5.0", + "version": "1.7.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "umbreld", - "version": "1.5.0", + "version": "1.7.1", "license": "PolyForm Noncommercial License 1.0.0", "dependencies": { "@homebridge/dbus-native": "github:getumbrel/dbus-native#types", diff --git a/packages/umbreld/source/modules/apps/routes.ts b/packages/umbreld/source/modules/apps/routes.ts index 613d8f2b7f..dd7ec0b271 100644 --- a/packages/umbreld/source/modules/apps/routes.ts +++ b/packages/umbreld/source/modules/apps/routes.ts @@ -1,7 +1,10 @@ import z from 'zod' +import fse from 'fs-extra' import {router, privateProcedure} from '../server/trpc/trpc.js' +const DOCKER_CONFIG_PATH = '/root/.docker/config.json' + export const appStore = router({ // Returns the app store registry registry: privateProcedure.query(async ({ctx}) => ctx.appStore.registry()), @@ -23,6 +26,54 @@ export const appStore = router({ }), ) .mutation(async ({ctx, input}) => ctx.appStore.removeRepository(input.url)), + + // List saved private registry hostnames (no passwords returned) + getRegistryCredentials: privateProcedure.query(async () => { + try { + const config = await fse.readJson(DOCKER_CONFIG_PATH) + const auths: Record = config?.auths ?? {} + return Object.keys(auths) + .filter((registry) => !!auths[registry]?.auth) + .map((registry) => ({registry})) + } catch { + return [] + } + }), + + // Save (add or update) credentials for a private registry + setRegistryCredential: privateProcedure + .input( + z.object({ + registry: z.string().min(1), + username: z.string().min(1), + password: z.string().min(1), + }), + ) + .mutation(async ({input}) => { + const config = (await fse.pathExists(DOCKER_CONFIG_PATH)) + ? await fse.readJson(DOCKER_CONFIG_PATH) + : {} + config.auths = config.auths ?? {} + config.auths[input.registry] = { + auth: Buffer.from(`${input.username}:${input.password}`).toString('base64'), + } + await fse.outputJson(DOCKER_CONFIG_PATH, config, {spaces: 2}) + return true + }), + + // Remove credentials for a registry + removeRegistryCredential: privateProcedure + .input(z.object({registry: z.string().min(1)})) + .mutation(async ({input}) => { + try { + const config = await fse.readJson(DOCKER_CONFIG_PATH) + if (config?.auths?.[input.registry]) { + delete config.auths[input.registry] + await fse.outputJson(DOCKER_CONFIG_PATH, config, {spaces: 2}) + } + } catch {} + return true + }), }) export const apps = router({