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


Skip to content
  • Categories
  • Recent
  • Tags
  • Popular
  • Bookmarks
  • Search
Skins
  • Light
  • Brite
  • Cerulean
  • Cosmo
  • Flatly
  • Journal
  • Litera
  • Lumen
  • Lux
  • Materia
  • Minty
  • Morph
  • Pulse
  • Sandstone
  • Simplex
  • Sketchy
  • Spacelab
  • United
  • Yeti
  • Zephyr
  • Dark
  • Cyborg
  • Darkly
  • Quartz
  • Slate
  • Solar
  • Superhero
  • Vapor

  • Default (No Skin)
  • No Skin
Collapse
Brand Logo

Cloudron Forum

Offical apps | Community apps | Demo | Docs | Install
  1. Cloudron Forum
  2. Feature Requests
  3. VPN protection: allow other VPN apps (WireGuard, NetBird, ...) as the VPN app (small patch)

VPN protection: allow other VPN apps (WireGuard, NetBird, ...) as the VPN app (small patch)

Scheduled Pinned Locked Moved Feature Requests
1 Posts 1 Posters 11 Views 1 Watching
  • Oldest to Newest
  • Newest to Oldest
  • Most Votes
Reply
  • Reply as topic
Log in to reply
This topic has been deleted. Only users with topic management privileges can see it.
  • marcusquinnM
    marcusquinnM
    marcusquinn
    CA Maintainer Super Fan
    wrote last edited by
    #1

    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!

    Web Design & Development: https://www.evergreen.je
    Technology & Apps: https://www.marcusquinn.com

    1 Reply Last reply
    0

    Hello! It looks like you're interested in this conversation, but you don't have an account yet.

    Getting fed up of having to scroll through the same posts each visit? When you register for an account, you'll always come back to exactly where you were before, and choose to be notified of new replies (either via email, or push notification). You'll also be able to save bookmarks and upvote posts to show your appreciation to other community members.

    With your input, this post could be even better 💗

    Register Login
    Reply
    • Reply as topic
    Log in to reply
    • Oldest to Newest
    • Newest to Oldest
    • Most Votes


    • Login

    • Don't have an account? Register

    • Login or register to search.
    • First post
      Last post
    0
    • Categories
    • Recent
    • Tags
    • Popular
    • Bookmarks
    • Search