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


Finishing the VPN Client app's functionality



  • So, I put a function in docker.js:

    function getVPNContainerName() {
        var containerIdVPN;
        gConnection.getNetwork('cloudron').inspect(function (error, bridge) {
            for (var id in bridge.Containers) {
                let v = inspect (id, function (error, container) {
                    if(typeof container === "object") {
                        var environment = safe.query(container, 'Config.Env', null);
                        if (JSON.stringify(environment).includes("openvpnclient")) {
                            containerIdVPN = container.Name.substring(1);
                            debug ("INSIDE INSIDE FUNCTION:" + containerIdVPN);
                            return containerIdVPN;
                            //if (!containerIdVPN) return callback(new BoxError(BoxError.DOCKER_ERROR, 'No VPN Client app to connect to'));
                        }
                    }
                });                 
            }
        });
    }
    

    And then I changed one line (https://git.cloudron.io/cloudron/box/-/blob/master/src/docker.js#L323) to run my function containerOptions.HostConfig.NetworkMode = 'container:' + getVPNContainerName();

    And because Node is asynchronous, It NEVER actually finishes the function (thus the "container" is undefined). I've tried putting the function all kinds of places. But it still runs after it's needed. Like, I need the VPN container name to attach it to the NetworkMode but it runs after the function is written? I tried using Promises but that didn't work. I know box is written in all these callback methodologies but I've only written in synchronous languages so I don't know how to get the VPN name before it's needed, using that function (which works perfectly).



  • Totally figured it out. In the absolutely wrong way (sure @girish would hate it) but it works!

    Right now, it's just hardcoded to connect to the VPN Client if another app has "vpnconnect" anywhere in their domain name. But this is just a proof of concept. It's horribly ineffecient. I don't know enough about box and how asynchronousity works, see, I can't even spell it. 😂

    function createSubcontainer(app, name, cmd, options, callback) {
        assert.strictEqual(typeof app, 'object');
        assert.strictEqual(typeof name, 'string');
        assert(!cmd || util.isArray(cmd));
        assert.strictEqual(typeof options, 'object');
        assert.strictEqual(typeof callback, 'function');
    
        let isAppContainer = !cmd; // non app-containers are like scheduler
    
        var manifest = app.manifest;
        var exposedPorts = {}, dockerPortBindings = {};
        var domain = app.fqdn;
    
        const envPrefix = manifest.manifestVersion <= 1 ? '' : 'CLOUDRON_';
    
        let stdEnv = [
            'CLOUDRON=1',
            'CLOUDRON_PROXY_IP=172.18.0.1',
            `CLOUDRON_APP_HOSTNAME=${app.id}`,
            `${envPrefix}WEBADMIN_ORIGIN=${settings.adminOrigin()}`,
            `${envPrefix}API_ORIGIN=${settings.adminOrigin()}`,
            `${envPrefix}APP_ORIGIN=https://${domain}`,
            `${envPrefix}APP_DOMAIN=${domain}`
        ];
    
        // docker portBindings requires ports to be exposed
        exposedPorts[manifest.httpPort + '/tcp'] = {};
    
        dockerPortBindings[manifest.httpPort + '/tcp'] = [{ HostIp: '127.0.0.1', HostPort: app.httpPort + '' }];
    
        var portEnv = [];
        for (let portName in app.portBindings) {
            const hostPort = app.portBindings[portName];
            const portType = (manifest.tcpPorts && portName in manifest.tcpPorts) ? 'tcp' : 'udp';
            const ports = portType == 'tcp' ? manifest.tcpPorts : manifest.udpPorts;
    
            var containerPort = ports[portName].containerPort || hostPort;
    
    
            exposedPorts[`${containerPort}/${portType}`] = {};
            portEnv.push(`${portName}=${hostPort}`);
    
            dockerPortBindings[`${containerPort}/${portType}`] = [{ HostIp: '0.0.0.0', HostPort: hostPort + '' }];
        }
    
        let appEnv = [];
        Object.keys(app.env).forEach(function(name) { appEnv.push(`${name}=${app.env[name]}`); });
    
        // first check db record, then manifest
        var memoryLimit = app.memoryLimit || manifest.memoryLimit || 0;
    
        if (memoryLimit === -1) { // unrestricted
            memoryLimit = 0;
        } else if (memoryLimit === 0 || memoryLimit < constants.DEFAULT_MEMORY_LIMIT) { // ensure we never go below minimum (in case we change the default)
            memoryLimit = constants.DEFAULT_MEMORY_LIMIT;
        }
    
        // give scheduler tasks twice the memory limit since background jobs take more memory
        // if required, we can make this a manifest and runtime argument later
        if (!isAppContainer) memoryLimit *= 2;
    
        addons.getEnvironment(app, function(error, addonEnv) {
            if (error) return callback(error);
    
    
            var containerOptions = {
                name: name, // for referencing containers
                Tty: isAppContainer,
                Image: app.manifest.dockerImage,
                Cmd: (isAppContainer && app.debugMode && app.debugMode.cmd) ? app.debugMode.cmd : cmd,
                Env: stdEnv.concat(addonEnv).concat(portEnv).concat(appEnv),
                ExposedPorts: isAppContainer ? exposedPorts : {},
                Volumes: { // see also ReadonlyRootfs
                    '/tmp': {},
                    '/run': {}
                },
                Labels: {
                    'fqdn': app.fqdn,
                    'appId': app.id,
                    'isSubcontainer': String(!isAppContainer),
                    'isCloudronManaged': String(true)
                },
                HostConfig: {
                    Mounts: addons.getMountsSync(app, app.manifest.addons),
                    Binds: getBindsSync(app), // ideally, we have to use 'Mounts' but we have to create volumes then
                    LogConfig: {
                        Type: 'syslog',
                        Config: {
                            'tag': app.id,
                            'syslog-address': 'udp://127.0.0.1:2514', // see apps.js:validatePortBindings()
                            'syslog-format': 'rfc5424'
                        }
                    },
                    Memory: memoryLimit / 2,
                    MemorySwap: memoryLimit, // Memory + Swap
                    PortBindings: isAppContainer ? dockerPortBindings : {},
                    PublishAllPorts: false,
                    ReadonlyRootfs: app.debugMode ? !!app.debugMode.readonlyRootfs : true,
                    RestartPolicy: {
                        'Name': isAppContainer ? 'unless-stopped' : 'no',
                        'MaximumRetryCount': 0
                    },
                    CpuShares: app.cpuShares,
                    VolumesFrom: isAppContainer ? null : [app.containerId + ':rw'],
                    SecurityOpt: ['apparmor=docker-cloudron-app'],
                    CapAdd: [],
                    CapDrop: []
                }
            };
    
            // do no set hostname of containers to location as it might conflict with addons names. for example, an app installed in mail
            // location may not reach mail container anymore by DNS. We cannot set hostname to fqdn either as that sets up the dns
            // name to look up the internal docker ip. this makes curl from within container fail
            // Note that Hostname has no effect on DNS. We have to use the --net-alias for dns.
            // This is done to prevent lots of up/down events and iptables locking
            if (isAppContainer) {
                if (app.fqdn.includes('vpnconnect')) {
                    getVPNContainerName(function(err, val) {
                        if (err) {
    
                            callback(new BoxError(BoxError.ALREADY_EXISTS, error));
                        } else {
                                containerOptions.name = app.id;
                                containerOptions.Hostname = '';
                                containerOptions.ExposedPorts = {};
                                containerOptions.HostConfig.PortBindings = {};
                                containerOptions.HostConfig.NetworkMode = "container:" + val;
                                finish();
                        }
                    });
                }
                else {
                    containerOptions.Hostname = app.id;
                    containerOptions.HostConfig.NetworkMode = 'cloudron'; // user defined bridge network
                    containerOptions.HostConfig.Dns = ['172.18.0.1']; // use internal dns
                    containerOptions.HostConfig.DnsSearch = ['.']; // use internal dns
                    containerOptions.NetworkingConfig = {
                            EndpointsConfig: {
                                    cloudron: {
                                            Aliases: [name] // adds hostname entry with container name
                                    }
                            }
                    };
                    debug ("Create container" + app.id);
                    finish();
                }
            } else {
                    containerOptions.Hostname = app.id;
                    containerOptions.HostConfig.NetworkMode = 'container:${app.containerId}'; // container defined bridge network
                    containerOptions.HostConfig.Dns = ['172.18.0.1']; // use internal dns
                    containerOptions.HostConfig.DnsSearch = ['.']; // use internal dns
                    debug ("Create container" + app.id);
                    finish();
            }
            
            // asynchronous function, calls callback(err, val) when it's complete
            function getVPNContainerName(callback) {
                var containerIdVPN;
                var done = false;
                gConnection.getNetwork('cloudron').inspect(function(error, bridge) {
                    if (error) {
                        callback(error);
                        return;
                    }
                    for (var id in bridge.Containers) {
                        // problem with sequencing of this async operation inside of for loop
                        let v = inspect(id, function(error, container) {
            // asynchronous function, calls callback(err, val) when it's complete
            function getVPNContainerName(callback) {
                var containerIdVPN;
                var done = false;
                gConnection.getNetwork('cloudron').inspect(function(error, bridge) {
                    if (error) {
                        callback(error);
                        return;
                    }
                    for (var id in bridge.Containers) {
                        // problem with sequencing of this async operation inside of for loop
                        let v = inspect(id, function(error, container) {
                            if (error) {
                                // log error and skip
                                debug(error);
                                return;
                            }
                            if (typeof container === "object") {
                                var environment = safe.query(container, 'Config.Env', null);
                                if (JSON.stringify(environment).includes("openvpnclient")) {
                                    containerIdVPN = container.Name.substring(1);
                                    debug("INSIDE INSIDE FUNCTION:" + containerIdVPN);
                                    if (!done) {
                                        done = true;
                                        callback(null, containerIdVPN);
                                    }
                                }
                            }
                        });
                    }
                    if (!done) {
                        // use default value
                    }
                });
            }
    
            function finish() {
    
                var capabilities = manifest.capabilities || [];
    
                // https://docs-stage.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities
                if (capabilities.includes('net_admin')) containerOptions.HostConfig.CapAdd.push('NET_ADMIN', 'NET_RAW');
                if (capabilities.includes('mlock')) containerOptions.HostConfig.CapAdd.push('IPC_LOCK'); // mlock prevents swapping
                if (!capabilities.includes('ping')) containerOptions.HostConfig.CapDrop.push('NET_RAW'); // NET_RAW is included by default by Docker
    
                if (capabilities.includes('vaapi') && safe.fs.existsSync('/dev/dri')) {
                    containerOptions.HostConfig.Devices = [
                        { PathOnHost: '/dev/dri', PathInContainer: '/dev/dri', CgroupPermissions: 'rwm' }
                    ];
                }
    
                containerOptions = _.extend(containerOptions, options);
    
                gConnection.createContainer(containerOptions, function(error, container) {
                    if (error && error.statusCode === 409) return callback(new BoxError(BoxError.ALREADY_EXISTS, error));
                    if (error) return callback(new BoxError(BoxError.DOCKER_ERROR, error));
    
                    callback(null, container);
                });
            }
        });
    }
    


  • I could just lock the files (docker.js, and reverseproxy.js I had to make changes to) from updates so I can keep my VPN connection between apps in tact, but that seems a bit harsh. I just feel like I don't know enough about node or box to code this "right".


  • Staff

    Since, async functions can get some time to get used to, what you can do as a "hack" for now is to just set a property in appdb. For example, add a line https://git.cloudron.io/cloudron/box/-/blob/master/src/appdb.js#L59 like result.networkId = container:vpncontainerappid. Then you have this properly available in the docker.js code.

    I think the final code will anyway read the networkId from the app table, so your code won't require much change if you do something like above.



  • @girish said in Finishing the VPN Client app's functionality:

    Since, async functions can get some time to get used to, what you can do as a "hack" for now is to just set a property in appdb. For example, add a line https://git.cloudron.io/cloudron/box/-/blob/master/src/appdb.js#L59 like result.networkId = container:vpncontainerappid. Then you have this properly available in the docker.js code.

    I think the final code will anyway read the networkId from the app table, so your code won't require much change if you do something like above.

    That’s perfect. I hated how hacky my solution was so I’m going to to recode it in the way you described. Doing it the way you described also allows me to add a comma-delimited list of all VPN containers thus supporting more than one VPN client running simultaneously on Cloudron (my hacky code only allows for a single OpenVPN client).

    I need to add the container name to the DB and the internal IP of the running vpn client container. Both are essential.

    Then when any user chooses to connect to the VPN then that specific app will restart and be configured to route all outgoing traffic to it.

    The only caveat in all of this is that though technically an unlimited amount of web apps can connect to the vpn client, a limitation is that the web apps can’t share the same exposed port as any of the others. This is gotten around in Docker by binding randomized ports to the exposed port. But when you connect to a VPN Client, you have to be running on the same IP so you lose the ability to Docker “bind.”

    There may be a way around this using Docker Connect -link argument. But I think the same caveat would apply and that would only solve the problem of having the app not have to do a quick restart to connect. But I’ll make sure. ☺


Log in to reply