coolify haproxy 集成简单说明


coolify 以前介绍过,是一个开源heroku 以及netlify的替换方案,对于服务的访问层集成了haproxy 进行处理
细节上使用了haproxy 的dataplaneapi 进行处理

api 调用部分

核心是利用了模版引擎mustache 以及直接调用的dataplaneapi

  • 初始化部分
    主要是获取数据库数据,进行初始化
 
import { dev } from '$app/env';
import got from 'got';
import mustache from 'mustache';
import crypto from 'crypto';
 
import * as db from '$lib/database';
import { checkContainer, checkHAProxy } from '.';
import { asyncExecShell, getDomain, getEngine } from '$lib/common';
import { supportedServiceTypesAndVersions } from '$lib/components/common';
 
const url = dev ? 'http://localhost:5555' : 'http://coolify-haproxy:5555';
 
let template = `program api 
  command /usr/bin/dataplaneapi -f /usr/local/etc/haproxy/dataplaneapi.hcl --userlist haproxy-dataplaneapi
  no option start-on-reload
 
global
  stats socket /var/run/api.sock user haproxy group haproxy mode 660 level admin expose-fd listeners
  log stdout format raw local0 debug
 
defaults 
  mode http
  log global
  timeout http-request 60s
  timeout connect 10s
  timeout client 60s
  timeout server 60s
 
userlist haproxy-dataplaneapi 
  user admin insecure-password "\${HAPROXY_PASSWORD}"
 
frontend http
  mode http
  bind :80
  bind :443 ssl crt /usr/local/etc/haproxy/ssl/ alpn h2,http/1.1
  acl is_certbot path_beg /.well-known/acme-challenge/
 
  {{#applications}}
  {{#isHttps}}
  http-request redirect scheme https code ${
    dev ? 302 : 301
  } if { hdr(host) -i {{domain}} } !{ ssl_fc }
  {{/isHttps}}
  http-request redirect location {{{redirectValue}}} code ${
    dev ? 302 : 301
  } if { req.hdr(host) -i {{redirectTo}} }
  {{/applications}}
 
  {{#services}}
  {{#isHttps}}
  http-request redirect scheme https code ${
    dev ? 302 : 301
  } if { hdr(host) -i {{domain}} } !{ ssl_fc }
  {{/isHttps}}
  http-request redirect location {{{redirectValue}}} code ${
    dev ? 302 : 301
  } if { req.hdr(host) -i {{redirectTo}} }
  {{/services}}
 
  {{#coolify}}
  {{#isHttps}}
  http-request redirect scheme https code ${
    dev ? 302 : 301
  } if { hdr(host) -i {{domain}} } !{ ssl_fc }
  {{/isHttps}}
  http-request redirect location {{{redirectValue}}} code ${
    dev ? 302 : 301
  } if { req.hdr(host) -i {{redirectTo}} }
  {{/coolify}}
 
  use_backend backend-certbot if is_certbot
  use_backend %[req.hdr(host),lower]
 
frontend stats 
  bind *:8404
  stats enable
  stats uri /
  stats admin if TRUE
  stats auth "\${HAPROXY_USERNAME}:\${HAPROXY_PASSWORD}"
 
backend backend-certbot 
  mode http
  server certbot host.docker.internal:9080
 
{{#applications}}
{{#isRunning}}
# updatedAt={{updatedAt}}
backend {{domain}}
  option forwardfor
  {{#isHttps}}
  http-request add-header X-Forwarded-Proto https
  {{/isHttps}}
  {{^isHttps}}
  http-request add-header X-Forwarded-Proto http
  {{/isHttps}}
  http-request add-header X-Forwarded-Host %[req.hdr(host),lower]
  server {{id}} {{id}}:{{port}}
{{/isRunning}}
{{/applications}}
 
{{#services}}
{{#isRunning}}
# updatedAt={{updatedAt}}
backend {{domain}}
  option forwardfor
  {{#isHttps}}
  http-request add-header X-Forwarded-Proto https
  {{/isHttps}}
  {{^isHttps}}
  http-request add-header X-Forwarded-Proto http
  {{/isHttps}}
  http-request add-header X-Forwarded-Host %[req.hdr(host),lower]
  server {{id}} {{id}}:{{port}}
{{/isRunning}}
{{/services}}
 
{{#coolify}}
backend {{domain}}
  option forwardfor
  option httpchk GET /undead.json
  {{#isHttps}}
  http-request add-header X-Forwarded-Proto https
  {{/isHttps}}
  {{^isHttps}}
  http-request add-header X-Forwarded-Proto http
  {{/isHttps}}
  http-request add-header X-Forwarded-Host %[req.hdr(host),lower]
  server {{id}} {{id}}:{{port}} check fall 10
{{/coolify}}
`;
export async function haproxyInstance() {
  const { proxyPassword } = await db.listSettings();
  return got.extend({
    prefixUrl: url,
    username: 'admin',
    password: proxyPassword
  });
}
 
export async function configureHAProxy() {
  const haproxy = await haproxyInstance();
  await checkHAProxy(haproxy);
 
  try {
    const data = {
      applications: [],
      services: [],
      coolify: []
    };
    const applications = await db.prisma.application.findMany({
      include: { destinationDocker: true, settings: true }
    });
    for (const application of applications) {
      const {
        fqdn,
        id,
        port,
        destinationDocker,
        destinationDockerId,
        settings: { previews },
        updatedAt
      } = application;
      if (destinationDockerId) {
        const { engine, network } = destinationDocker;
        const isRunning = await checkContainer(engine, id);
        if (fqdn) {
          const domain = getDomain(fqdn);
          const isHttps = fqdn.startsWith('https://');
          const isWWW = fqdn.includes('www.');
          const redirectValue = `${isHttps ? 'https://' : 'http://'}${domain}%[capture.req.uri]`;
          if (isRunning) {
            data.applications.push({
              id,
              port: port || 3000,
              domain,
              isRunning,
              isHttps,
              redirectValue,
              redirectTo: isWWW ? domain.replace('www.', '') : 'www.' + domain,
              updatedAt: updatedAt.getTime()
            });
          }
          if (previews) {
            const host = getEngine(engine);
            const { stdout } = await asyncExecShell(
              `DOCKER_HOST=${host} docker container ls --filter="status=running" --filter="network=${network}" --filter="name=${id}-" --format="{{json .Names}}"`
            );
            const containers = stdout
              .trim()
              .split('\n')
              .filter((a) => a)
              .map((c) => c.replace(/"/g, ''));
            if (containers.length > 0) {
              for (const container of containers) {
                let previewDomain = `${container.split('-')[1]}.${domain}`;
                data.applications.push({
                  id: container,
                  port: port || 3000,
                  domain: previewDomain,
                  isRunning,
                  isHttps,
                  redirectValue,
                  redirectTo: isWWW ? previewDomain.replace('www.', '') : 'www.' + previewDomain,
                  updatedAt: updatedAt.getTime()
                });
              }
            }
          }
        }
      }
    }
    const services = await db.prisma.service.findMany({
      include: {
        destinationDocker: true,
        minio: true,
        plausibleAnalytics: true,
        vscodeserver: true,
        wordpress: true,
        ghost: true
      }
    });
 
    for (const service of services) {
      const { fqdn, id, type, destinationDocker, destinationDockerId, updatedAt } = service;
      if (destinationDockerId) {
        const { engine } = destinationDocker;
        const found = supportedServiceTypesAndVersions.find((a) => a.name === type);
        if (found) {
          const port = found.ports.main;
          const publicPort = service[type]?.publicPort;
          const isRunning = await checkContainer(engine, id);
          if (fqdn) {
            const domain = getDomain(fqdn);
            const isHttps = fqdn.startsWith('https://');
            const isWWW = fqdn.includes('www.');
            const redirectValue = `${isHttps ? 'https://' : 'http://'}${domain}%[capture.req.uri]`;
            if (isRunning) {
              data.services.push({
                id,
                port,
                publicPort,
                domain,
                isRunning,
                isHttps,
                redirectValue,
                redirectTo: isWWW ? domain.replace('www.', '') : 'www.' + domain,
                updatedAt: updatedAt.getTime()
              });
            }
          }
        }
      }
    }
    const { fqdn } = await db.prisma.setting.findFirst();
    if (fqdn) {
      const domain = getDomain(fqdn);
      const isHttps = fqdn.startsWith('https://');
      const isWWW = fqdn.includes('www.');
      const redirectValue = `${isHttps ? 'https://' : 'http://'}${domain}%[capture.req.uri]`;
      data.coolify.push({
        id: dev ? 'host.docker.internal' : 'coolify',
        port: 3000,
        domain,
        isHttps,
        redirectValue,
        redirectTo: isWWW ? domain.replace('www.', '') : 'www.' + domain
      });
    }
    const output = mustache.render(template, data);
    const newHash = crypto.createHash('md5').update(output).digest('hex');
    const { proxyHash, id } = await db.listSettings();
    if (proxyHash !== newHash) {
      await db.prisma.setting.update({ where: { id }, data: { proxyHash: newHash } });
      await haproxy.post(`v2/services/haproxy/configuration/raw`, {
        searchParams: {
          skip_version: true
        },
        body: output,
        headers: {
          'Content-Type': 'text/plain'
        }
      });
    }
  } catch (error) {
    throw error;
  }
}
  • dataplaneapi 调用
    难度不大, 就是调用api,对于api 调用部分,我以前也写过文章可以参考
 
import { dev } from '$app/env';
import { asyncExecShell, getEngine } from '$lib/common';
import got from 'got';
import * as db from '$lib/database';
 
const url = dev ? 'http://localhost:5555' : 'http://coolify-haproxy:5555';
 
export const defaultProxyImage = `coolify-haproxy-alpine:latest`;
export const defaultProxyImageTcp = `coolify-haproxy-tcp-alpine:latest`;
export const defaultProxyImageHttp = `coolify-haproxy-http-alpine:latest`;
 
export async function haproxyInstance() {
  const { proxyPassword } = await db.listSettings();
  return got.extend({
    prefixUrl: url,
    username: 'admin',
    password: proxyPassword
  });
}
export async function getRawConfiguration(): Promise<RawHaproxyConfiguration> {
  return await (await haproxyInstance()).get(`v2/services/haproxy/configuration/raw`).json();
}
 
export async function getNextTransactionVersion(): Promise<number> {
  const raw = await getRawConfiguration();
  if (raw?._version) {
    return raw._version;
  }
  return 1;
}
 
export async function getNextTransactionId(): Promise<string> {
  const version = await getNextTransactionVersion();
  const newTransaction: NewTransaction = await (
    await haproxyInstance()
  )
    .post('v2/services/haproxy/transactions', {
      searchParams: {
        version
      }
    })
    .json();
  return newTransaction.id;
}
 
export async function completeTransaction(transactionId) {
  const haproxy = await haproxyInstance();
  return await haproxy.put(`v2/services/haproxy/transactions/${transactionId}`);
}
export async function deleteProxy({ id }) {
  const haproxy = await haproxyInstance();
  await checkHAProxy(haproxy);
 
  let transactionId;
  try {
    await haproxy.get(`v2/services/haproxy/configuration/backends/${id}`).json();
    transactionId = await getNextTransactionId();
    await haproxy
      .delete(`v2/services/haproxy/configuration/backends/${id}`, {
        searchParams: {
          transaction_id: transactionId
        }
      })
      .json();
    await haproxy.get(`v2/services/haproxy/configuration/frontends/${id}`).json();
    await haproxy
      .delete(`v2/services/haproxy/configuration/frontends/${id}`, {
        searchParams: {
          transaction_id: transactionId
        }
      })
      .json();
  } catch (error) {
    console.log(error.response?.body || error);
  } finally {
    if (transactionId) await completeTransaction(transactionId);
  }
}
 
export async function reloadHaproxy(engine) {
  const host = getEngine(engine);
  return await asyncExecShell(`DOCKER_HOST=${host} docker exec coolify-haproxy kill -HUP 1`);
}
export async function checkHAProxy(haproxy?: any) {
  if (!haproxy) haproxy = await haproxyInstance();
  try {
    await haproxy.get('v2/info');
  } catch (error) {
    throw {
      message:
        'Coolify Proxy is not running, but it should be!

Start it in the "Destinations" menu.'
    };
  }
}
 
export async function stopTcpHttpProxy(destinationDocker, publicPort) {
  const { engine } = destinationDocker;
  const host = getEngine(engine);
  const containerName = `haproxy-for-${publicPort}`;
  const found = await checkContainer(engine, containerName);
  try {
    if (found) {
      return await asyncExecShell(
        `DOCKER_HOST=${host} docker stop -t 0 ${containerName} && docker rm ${containerName}`
      );
    }
  } catch (error) {
    return error;
  }
}
export async function startTcpProxy(destinationDocker, id, publicPort, privatePort, volume = null) {
  const { network, engine } = destinationDocker;
  const host = getEngine(engine);
 
  const containerName = `haproxy-for-${publicPort}`;
  const found = await checkContainer(engine, containerName);
  const foundDB = await checkContainer(engine, id);
 
  try {
    if (foundDB && !found) {
      const { stdout: Config } = await asyncExecShell(
        `DOCKER_HOST="${host}" docker network inspect bridge --format '{{json .IPAM.Config }}'`
      );
      const ip = JSON.parse(Config)[0].Gateway;
      return await asyncExecShell(
        `DOCKER_HOST=${host} docker run --restart always -e PORT=${publicPort} -e APP=${id} -e PRIVATE_PORT=${privatePort} --add-host 'host.docker.internal:host-gateway' --add-host 'host.docker.internal:${ip}' --network ${network} -p ${publicPort}:${publicPort} --name ${containerName} ${
          volume ? `-v ${volume}` : ''
        } -d coollabsio/${defaultProxyImageTcp}`
      );
    }
  } catch (error) {
    return error;
  }
}
export async function startHttpProxy(destinationDocker, id, publicPort, privatePort) {
  const { network, engine } = destinationDocker;
  const host = getEngine(engine);
 
  const containerName = `haproxy-for-${publicPort}`;
  const found = await checkContainer(engine, containerName);
  const foundDB = await checkContainer(engine, id);
 
  try {
    if (foundDB && !found) {
      const { stdout: Config } = await asyncExecShell(
        `DOCKER_HOST="${host}" docker network inspect bridge --format '{{json .IPAM.Config }}'`
      );
      const ip = JSON.parse(Config)[0].Gateway;
      return await asyncExecShell(
        `DOCKER_HOST=${host} docker run --restart always -e PORT=${publicPort} -e APP=${id} -e PRIVATE_PORT=${privatePort} --add-host 'host.docker.internal:host-gateway' --add-host 'host.docker.internal:${ip}' --network ${network} -p ${publicPort}:${publicPort} --name ${containerName} -d coollabsio/${defaultProxyImageHttp}`
      );
    }
  } catch (error) {
    return error;
  }
}
export async function startCoolifyProxy(engine) {
  const host = getEngine(engine);
  const found = await checkContainer(engine, 'coolify-haproxy');
  const { proxyPassword, proxyUser, id } = await db.listSettings();
  if (!found) {
    const { stdout: Config } = await asyncExecShell(
      `DOCKER_HOST="${host}" docker network inspect bridge --format '{{json .IPAM.Config }}'`
    );
    const ip = JSON.parse(Config)[0].Gateway;
    await asyncExecShell(
      `DOCKER_HOST="${host}" docker run -e HAPROXY_USERNAME=${proxyUser} -e HAPROXY_PASSWORD=${proxyPassword} --restart always --add-host 'host.docker.internal:host-gateway' --add-host 'host.docker.internal:${ip}' -v coolify-ssl-certs:/usr/local/etc/haproxy/ssl --network coolify-infra -p "80:80" -p "443:443" -p "8404:8404" -p "5555:5555" -p "5000:5000" --name coolify-haproxy -d coollabsio/${defaultProxyImage}`
    );
    await db.prisma.setting.update({ where: { id }, data: { proxyHash: null } });
  }
  await configureNetworkCoolifyProxy(engine);
}
export async function checkContainer(engine, container) {
  const host = getEngine(engine);
  let containerFound = false;
 
  try {
    const { stdout } = await asyncExecShell(
      `DOCKER_HOST="${host}" docker inspect --format '{{json .State}}' ${container}`
    );
    const parsedStdout = JSON.parse(stdout);
    const status = parsedStdout.Status;
    const isRunning = status === 'running' ? true : false;
    if (status === 'exited' || status === 'created') {
      await asyncExecShell(`DOCKER_HOST="${host}" docker rm ${container}`);
    }
    if (isRunning) {
      containerFound = true;
    }
  } catch (err) {
    // Container not found
  }
  return containerFound;
}
 
export async function stopCoolifyProxy(engine) {
  const host = getEngine(engine);
  const found = await checkContainer(engine, 'coolify-haproxy');
  await db.setDestinationSettings({ engine, isCoolifyProxyUsed: false });
  const { id } = await db.prisma.setting.findFirst({});
  await db.prisma.setting.update({ where: { id }, data: { proxyHash: null } });
  try {
    if (found) {
      await asyncExecShell(
        `DOCKER_HOST="${host}" docker stop -t 0 coolify-haproxy && docker rm coolify-haproxy`
      );
    }
  } catch (error) {
    return error;
  }
}
 
export async function configureNetworkCoolifyProxy(engine) {
  const host = getEngine(engine);
  const destinations = await db.prisma.destinationDocker.findMany({ where: { engine } });
  destinations.forEach(async (destination) => {
    try {
      await asyncExecShell(
        `DOCKER_HOST="${host}" docker network connect ${destination.network} coolify-haproxy`
      );
    } catch (err) {
      // TODO: handle error
    }
  });
}

说明

实际上官方的处理部分并不是很好,比如获取事务版本的,是直接读取整个配置,实际上有更好的获取版本api

参考资料

https://github.com/haproxytech/dataplaneapi
https://www.haproxy.com/documentation/dataplaneapi/