Cloudron makes it easy to run web apps like WordPress, Nextcloud, GitLab on your server. Find out more or install now.


Skip to content
  • Announcements regarding Cloudron

    296 3k
    296 Topics
    3k Posts
    girishG
    Matrix client document support is done in the wellknown UI. The next version also has support for multiple postgres databases. This will allow us to support Element X/Element ESS in the next version. [image: image.jpeg]
  • Get help for your Cloudron

    4k 27k
    4k Topics
    27k Posts
    girishG
    Whoops, good catch. Fixed.
  • Feedback, suggestions, anything else Cloudron related

    1k 11k
    1k Topics
    11k Posts
    G
    @girish Thanks for the quick clarification, that is great news!
  • New ideas, Feature Requests

    923 7k
    923 Topics
    7k Posts
    marcusquinnM
    Apologies for having AI write it, but probably writes better than me, now: Hi @girish @nebulon, Thanks for VPN protection, and for the new allowlist. I'm maintaining the NetBird community package (self-hosted WireGuard mesh). Several of its users would like to protect Cloudron apps with NetBird instead of running OpenVPN alongside it. I read through platform/box master (c36346e). The only OpenVPN-specific part is how the VPN app is selected. The rest is already provider-neutral: reverseproxy.js allows the selected app's container IPv4/IPv6, then deny all app-bridge.js /vpn-access-config serves split-DNS data to whichever app is selected apptask.js clears the policy when that app is uninstalled Selection is checked against io.cloudron.openvpn in four places: network.js:setVpnAccessConfig, apps.js:setVpnAccess, VpnAccess.vue and Access.vue. Proposal: accept any app with the net_admin capability as the VPN app. Any app that routes VPN traffic needs that capability anyway, and the admin still has to pick it explicitly in Network > VPN access. That is the same trust decision as picking OpenVPN today. The patch also blocks only the selected VPN app (rather than every OpenVPN app) from requiring VPN access, and refuses to select an app that is itself protected. It needs no change to manifest-format. If you'd rather have an explicit opt-in, a new vpn capability in @cloudron/manifest-format would work just as well with the same box changes. I'm happy to rework it either way. On the NetBird side, the plan is a separate small "routing peer" app (net_admin, masquerades mesh traffic to the Cloudron host). It would not be the management server, because that container also runs public services and must not be trusted as VPN ingress. NetBird policies would then decide which user groups can reach the protected apps. They act on the whole set, though, because every protected app shares the host's IP. Testing: I added cases to src/test/network-test.js. ESLint is clean on the changed files, apart from an existing no-use-before-define at apps.js:520, and the dashboard builds. I couldn't run the mocha suite because its setup needs your Linux sudo scripts, so please treat the tests as unrun. diff --git a/dashboard/src/components/app/Access.vue b/dashboard/src/components/app/Access.vue index d04b9bf..506dc19 100644 --- a/dashboard/src/components/app/Access.vue +++ b/dashboard/src/components/app/Access.vue @@ -14,8 +14,6 @@ import UsersModel from '../../models/UsersModel.js'; import GroupsModel from '../../models/GroupsModel.js'; import { requestError, selectOptionsFromUsers } from '../../utils.js'; -const OPENVPN_APP_APPSTORE_ID = 'io.cloudron.openvpn'; - const props = defineProps([ 'app', 'refreshApp' ]); const features = inject('features'); @@ -35,8 +33,10 @@ const accessRestrictionAcl = ref({ users: [], groups: [] }); const operatorAcl = ref({ users: [], groups: [] }); const requiresVpn = ref(false); const vpnProtectionEnabled = ref(false); +const selectedVpnAppId = ref(null); -const showRequireVpn = computed(() => props.app.manifest?.id !== OPENVPN_APP_APPSTORE_ID && features.value.vpnProtection); +// the selected VPN app is the trusted ingress, so it cannot itself be VPN protected +const showRequireVpn = computed(() => features.value.vpnProtection && props.app.id !== selectedVpnAppId.value); function allowlistHasEntry(text) { return (text || '').split('\n').some((line) => line.length !== 0 && line[0] !== '#'); @@ -113,9 +113,10 @@ onMounted(async () => { requiresVpn.value = !!props.app.requiresVpn; - if (showRequireVpn.value) { + if (features.value.vpnProtection) { [error, result] = await networkModel.getVpnAccessConfig(); if (error) return console.error(error); + selectedVpnAppId.value = result.vpnAppId || null; vpnProtectionEnabled.value = !!result.vpnAppId || allowlistHasEntry(result.allowlist); } }); diff --git a/dashboard/src/components/network/VpnAccess.vue b/dashboard/src/components/network/VpnAccess.vue index f0079bf..b845ecd 100644 --- a/dashboard/src/components/network/VpnAccess.vue +++ b/dashboard/src/components/network/VpnAccess.vue @@ -13,6 +13,11 @@ const { t } = useI18n(); const OPENVPN_APP_APPSTORE_ID = 'io.cloudron.openvpn'; +// keep in sync with apps.isVpnProvider() in the box code +function isVpnProvider(manifest) { + return manifest?.id === OPENVPN_APP_APPSTORE_ID || (manifest?.capabilities || []).includes('net_admin'); +} + // UI presets map onto the two-mode wire model: full | custom(+cidrs) | custom(no cidrs) const ROUTING_FULL = 'full'; const ROUTING_APPS_ONLY = 'apps-only'; @@ -107,7 +112,7 @@ async function loadData() { installedApps.value = apps; vpnApps.value = apps - .filter((app) => app.manifest?.id === OPENVPN_APP_APPSTORE_ID) + .filter((app) => isVpnProvider(app.manifest) && !app.requiresVpn) .map((app) => ({ id: app.id, label: app.label || app.fqdn || app.id })); [error, apps] = await networkModel.getVpnAccessConfig(); diff --git a/src/apps.js b/src/apps.js index d2f0b4e..a042f55 100644 --- a/src/apps.js +++ b/src/apps.js @@ -1150,15 +1150,22 @@ async function setOperators(app, operators, auditSource) { await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, operators }); } +// an app can be selected as the VPN app if it can route traffic (OpenVPN, WireGuard, NetBird, ...). +// the admin still has to select it explicitly, after which its container IPs are trusted as VPN ingress +function isVpnProvider(manifest) { + assert.strictEqual(typeof manifest, 'object'); + + return manifest.id === constants.OPENVPN_APP_APPSTORE_ID || (manifest.capabilities || []).includes('net_admin'); +} + async function setVpnAccess(app, enable, auditSource) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof enable, 'boolean'); assert.strictEqual(typeof auditSource, 'object'); - if (app.manifest.id === constants.OPENVPN_APP_APPSTORE_ID) throw new BoxError(BoxError.BAD_FIELD, 'VPN app cannot require VPN access'); - if (enable) { const config = await settings.getJson(settings.VPN_ACCESS_CONFIG_KEY); + if (config?.vpnAppId === app.id) throw new BoxError(BoxError.BAD_FIELD, 'VPN app cannot require VPN access'); const allowlist = network.parseVpnAccessAllowlist(await network.getVpnAccessAllowlist()); if (!config?.vpnAppId && allowlist.length === 0) throw new BoxError(BoxError.BAD_FIELD, 'VPN app is not configured'); } @@ -3126,6 +3133,7 @@ export default { setAccessRestriction, setOperators, + isVpnProvider, setVpnAccess, setCrontab, setUpstreamUri, diff --git a/src/network.js b/src/network.js index 2d1d1e9..0ebb6d7 100644 --- a/src/network.js +++ b/src/network.js @@ -185,7 +185,8 @@ async function setVpnAccessConfig(policy, auditSource, allowlist) { const vpnApp = await apps.get(vpnAppId); if (!vpnApp) throw new BoxError(BoxError.BAD_FIELD, 'vpnAppId not found'); - if (vpnApp.manifest.id !== constants.OPENVPN_APP_APPSTORE_ID) throw new BoxError(BoxError.BAD_FIELD, 'vpnAppId must be an OpenVPN app'); + if (!apps.isVpnProvider(vpnApp.manifest)) throw new BoxError(BoxError.BAD_FIELD, 'vpnAppId must be a VPN app (OpenVPN or an app with the net_admin capability)'); + if (vpnApp.requiresVpn) throw new BoxError(BoxError.BAD_FIELD, 'VPN app cannot itself require VPN access'); validateTunnelRouting(tunnelRouting); } diff --git a/src/test/network-test.js b/src/test/network-test.js index ea34bd2..d7e6d18 100644 --- a/src/test/network-test.js +++ b/src/test/network-test.js @@ -210,11 +210,53 @@ describe('Network', function () { assert.equal(error.reason, BoxError.BAD_FIELD); }); - it('rejects non-openvpn app', async function () { + it('rejects an app that cannot route traffic', async function () { const [error] = await safe(network.setVpnAccessConfig({ vpnAppId: common.app.id, tunnelRouting: { mode: 'full', customCidrs: [] } }, auditSource)); assert.equal(error.reason, BoxError.BAD_FIELD); }); + describe('with a net_admin VPN app', function () { + const meshAppId = 'network-mesh-vpn-app-id'; + + before(async function () { + const meshManifest = { ...common.manifest, id: 'io.example.meshvpn', capabilities: [ 'net_admin' ] }; + const meshData = { + ...common.app, + id: meshAppId, + appStoreId: '', + installationState: constants.APP_ISTATE_INSTALLED, + subdomain: 'network-mesh-vpn', + domain: common.domain.domain, + manifest: meshManifest, + portBindings: {}, + secondaryDomains: [], + redirectDomains: [], + aliasDomains: [], + containerId: 'network-mesh-vpn-container-id', + containerIp: '172.18.88.97' + }; + await apps.add(meshAppId, meshData.appStoreId, '', meshManifest, meshData.subdomain, meshData.domain, {}, meshData); + }); + + after(async function () { + await network.setVpnAccessConfig(null, auditSource); + await apps.del(meshAppId); + }); + + it('can select it as the VPN app', async function () { + await network.setVpnAccessConfig({ vpnAppId: meshAppId, tunnelRouting: { mode: 'custom', customCidrs: [] } }, auditSource); + assert.equal((await network.getVpnAccessConfig()).vpnAppId, meshAppId); + }); + + it('cannot select it while it requires VPN access itself', async function () { + await network.setVpnAccessConfig(null, auditSource); + await apps.update(meshAppId, { requiresVpn: true }); + const [error] = await safe(network.setVpnAccessConfig({ vpnAppId: meshAppId, tunnelRouting: { mode: 'full', customCidrs: [] } }, auditSource)); + assert.equal(error.reason, BoxError.BAD_FIELD); + await apps.update(meshAppId, { requiresVpn: false }); + }); + }); + it('rejects invalid tunnel routing', async function () { const [error] = await safe(network.setVpnAccessConfig({ vpnAppId, tunnelRouting: { mode: 'full' } }, auditSource)); assert.equal(error.reason, BoxError.BAD_FIELD); Would you consider this, or something like it? Thanks!
  • 373 Topics
    3k Posts
    T
    Hi, I’d like to use Cubby to make a Cloudron volume available to a project team. The volume is mounted over SSHFS from external storage (a Hetzner Storage Box). Use case One Cubby instance, with app access restricted to the project’s Cloudron group. One group folder whose members are that Cloudron group, kept in sync through SCIM. The group folder’s contents are the mounted volume, not a directory in /app/data. Access would then be managed in one place: Cloudron groups. Users browse the files in Cubby. Automation accounts reach the same files over WebDAV with an app password. Nobody has to keep a separate permission list inside a file app, which is what makes Cubby so appealing compared to heavier options like Nextcloud. Current situation The group folder dialog only asks for a name and a slug. The folder is always created as /app/data/groups/<slug>, so there’s no way to point it at a volume attached under the app’s Mounts. The workaround I’m planning is to replace the empty directory with a symlink from the web terminal: rmdir /app/data/groups/<slug> ln -s /media/<volume> /app/data/groups/<slug> Feature request When creating or editing a group folder, offer an optional storage location chosen from the volumes mounted into the app (/media/*). If one is set, the group folder serves that path instead of /app/data/groups/<slug>.
  • Packages

    Questions about packages in the App Store

    6k 57k
    6k Topics
    57k Posts
    Package UpdatesP
    [2.65.0] Update kimai to 2.72.0 Full Changelog Search invoice history by invoice number (#6287) - thanks @Deniiz23 Highlight unpaid invoices in listings Run the search on Enter instead of saving the filter as default (invoice & export screen) (#6291) - thanks @dgesteves Updated translations (#6292) (#6306) Fix twig deprecations: missing default parameter in macros Improved invoice overdue calculation (internal API bug, no visible changes) Keep the calendar month in the URL on reload (#6324) - thanks @shu-chen-dot-ca Use consistent currency aware monetary rounding half-up, mainly for invoices (#6317) Memory safe export preview (#6295) Disable login route if SAML is active and user disabled form login (#6311) - thanks @mrtxmrtx
  • 90 Topics
    527 Posts
    L
    0.2.0 is out: please update, and rotate your keys if you ran 0.1.0. Package 0.2.0 moves HarnessRouter Community Edition from 0.37.13 to 0.41.16. It is a security release. 0.1.0 was affected by upstream advisories fixed in that span: GHSA-2464-fhr4-w9xw and GHSA-2xp9-vwfh-vxw4: remote code execution through the console's image optimiser, with no sign-in needed. The optimiser is now off. The critical fix in upstream 0.37.17: anyone holding an API key could read any file as root, including the internal key, the secret key, provider keys and the databases. If you installed 0.1.0, upstream asks you to rotate: the secret key (HR_SECRET_KEY; in this package /app/data/.secrets/secret-key), the console password, every provider key and every API key, MCP server tokens, if untrusted agents ran. If auto-update is on, your install has already moved or will at the next check. If it is off, update from the app's Updates tab. Cloudron takes a backup first; keep it, because rolling back to 0.1.0 is not clean (some stored tokens move to a form 0.37.13 cannot read). The first start reinstalls the agent tools once, so allow several minutes before the console answers. Also in 0.2.0: downloads at first start and environment builds run as firewalled unprivileged users, root is firewalled from Cloudron's internal network (an MCP server must be reachable at a public address or through this Cloudron's HTTPS front door), and the listing now says plainly that this package is unofficial and not supported by the HarnessRouter project. Full notes: https://github.com/OrcVole/harnessrouter-cloudron/blob/main/CHANGELOG.md
  • Propose and vote for apps to be packaged

    2k 17k
    2k Topics
    17k Posts
    L
    @Teiluj Thanks for posting this. I am definitely interested in packaging it but am vv busy. I shall try, if nobody else is interested. Will need a week or so.
  • Package development & help

    320 3k
    320 Topics
    3k Posts
    girishG
    Thanks, I have removed it.
  • Anything else not related to Cloudron

    380 3k
    380 Topics
    3k Posts
    L
    @james Thanks, James! I should have fact that myself, but the way things seem to be going in the UK now, I was quite prepared to believe it. Sorry about that, everybody! My bad.