feat: Wordpress on-demand SFTP

This commit is contained in:
Andras Bacsai 2022-04-05 15:56:25 +02:00
parent ca705bbf89
commit 8e9e6607e5
10 changed files with 238 additions and 24 deletions

View File

@ -332,6 +332,12 @@ model Wordpress {
mysqlRootUserPassword String mysqlRootUserPassword String
mysqlDatabase String? mysqlDatabase String?
mysqlPublicPort Int? mysqlPublicPort Int?
ftpEnabled Boolean @default(false)
ftpUser String?
ftpPassword String?
ftpPublicPort Int?
ftpHostKey String?
ftpHostKeyPrivate String?
serviceId String @unique serviceId String @unique
service Service @relation(fields: [serviceId], references: [id]) service Service @relation(fields: [serviceId], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@ -7,6 +7,7 @@
export let isCenter = true; export let isCenter = true;
export let disabled = false; export let disabled = false;
export let dataTooltip = null; export let dataTooltip = null;
export let loading = false;
</script> </script>
<div class="flex items-center py-4 pr-8"> <div class="flex items-center py-4 pr-8">
@ -26,7 +27,7 @@
on:click on:click
aria-pressed="false" aria-pressed="false"
class="relative mx-20 inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out" class="relative mx-20 inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out"
class:opacity-50={disabled} class:opacity-50={disabled || loading}
class:bg-green-600={setting} class:bg-green-600={setting}
class:bg-stone-700={!setting} class:bg-stone-700={!setting}
> >
@ -40,6 +41,7 @@
class=" absolute inset-0 flex h-full w-full items-center justify-center transition-opacity duration-200 ease-in" class=" absolute inset-0 flex h-full w-full items-center justify-center transition-opacity duration-200 ease-in"
class:opacity-0={setting} class:opacity-0={setting}
class:opacity-100={!setting} class:opacity-100={!setting}
class:animate-spin={loading}
aria-hidden="true" aria-hidden="true"
> >
<svg class="h-3 w-3 bg-white text-red-600" fill="none" viewBox="0 0 12 12"> <svg class="h-3 w-3 bg-white text-red-600" fill="none" viewBox="0 0 12 12">
@ -57,6 +59,7 @@
aria-hidden="true" aria-hidden="true"
class:opacity-100={setting} class:opacity-100={setting}
class:opacity-0={!setting} class:opacity-0={!setting}
class:animate-spin={loading}
> >
<svg class="h-3 w-3 bg-white text-green-600" fill="currentColor" viewBox="0 0 12 12"> <svg class="h-3 w-3 bg-white text-green-600" fill="currentColor" viewBox="0 0 12 12">
<path <path

View File

@ -59,8 +59,12 @@ export async function getService({ id, teamId }) {
return s; return s;
}); });
} }
if (body.wordpress?.ftpPassword) {
body.wordpress.ftpPassword = decrypt(body.wordpress.ftpPassword);
}
const settings = await prisma.setting.findFirst();
return { ...body }; return { ...body, settings };
} }
export async function configureServiceType({ id, type }) { export async function configureServiceType({ id, type }) {

View File

@ -108,7 +108,7 @@ export async function stopTcpHttpProxy(destinationDocker, publicPort) {
return error; return error;
} }
} }
export async function startTcpProxy(destinationDocker, id, publicPort, privatePort) { export async function startTcpProxy(destinationDocker, id, publicPort, privatePort, volume = null) {
const { network, engine } = destinationDocker; const { network, engine } = destinationDocker;
const host = getEngine(engine); const host = getEngine(engine);
@ -123,7 +123,9 @@ export async function startTcpProxy(destinationDocker, id, publicPort, privatePo
); );
const ip = JSON.parse(Config)[0].Gateway; const ip = JSON.parse(Config)[0].Gateway;
return await asyncExecShell( 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/${defaultProxyImageTcp}` `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) { } catch (error) {

View File

@ -2,6 +2,7 @@
export let service; export let service;
export let isRunning; export let isRunning;
export let readOnly; export let readOnly;
export let settings;
import { page, session } from '$app/stores'; import { page, session } from '$app/stores';
import { post } from '$lib/api'; import { post } from '$lib/api';
@ -143,7 +144,7 @@
{:else if service.type === 'vscodeserver'} {:else if service.type === 'vscodeserver'}
<VsCodeServer {service} /> <VsCodeServer {service} />
{:else if service.type === 'wordpress'} {:else if service.type === 'wordpress'}
<Wordpress bind:service {isRunning} {readOnly} /> <Wordpress bind:service {isRunning} {readOnly} {settings} />
{:else if service.type === 'ghost'} {:else if service.type === 'ghost'}
<Ghost bind:service {readOnly} /> <Ghost bind:service {readOnly} />
{:else if service.type === 'meilisearch'} {:else if service.type === 'meilisearch'}
@ -151,17 +152,4 @@
{/if} {/if}
</div> </div>
</form> </form>
<!-- <div class="font-bold flex space-x-1 pb-5">
<div class="text-xl tracking-tight mr-4">Features</div>
</div>
<div class="px-4 sm:px-6 pb-10">
<ul class="mt-2 divide-y divide-stone-800">
<Setting
bind:setting={isPublic}
on:click={() => changeSettings('isPublic')}
title="Set it public"
description="Your database will be reachable over the internet. <br>Take security seriously in this case!"
/>
</ul>
</div> -->
</div> </div>

View File

@ -1,9 +1,55 @@
<script lang="ts"> <script lang="ts">
import { post } from '$lib/api';
import { page } from '$app/stores';
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte'; import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
import Setting from '$lib/components/Setting.svelte';
import { errorNotification } from '$lib/form';
import { browser } from '$app/env';
import { getDomain } from '$lib/components/common';
export let service; export let service;
export let isRunning; export let isRunning;
export let readOnly; export let readOnly;
export let settings;
const { id } = $page.params;
let ftpUrl = generateUrl(service.wordpress.ftpPublicPort);
let ftpUser = service.wordpress.ftpUser;
let ftpPassword = service.wordpress.ftpPassword;
let ftpLoading = false;
function generateUrl(publicPort) {
return browser
? `sftp://${
settings.fqdn ? getDomain(settings.fqdn) : window.location.hostname
}:${publicPort}`
: 'Loading...';
}
async function changeSettings(name) {
ftpLoading = true;
let ftpEnabled = service.wordpress.ftpEnabled;
if (name === 'ftpEnabled') {
ftpEnabled = !ftpEnabled;
}
try {
const {
publicPort,
ftpUser: user,
ftpPassword: password
} = await post(`/services/${id}/wordpress/settings.json`, {
ftpEnabled
});
ftpUrl = generateUrl(publicPort);
ftpUser = user;
ftpPassword = password;
service.wordpress.ftpEnabled = ftpEnabled;
} catch ({ error }) {
return errorNotification(error);
} finally {
ftpLoading = false;
}
}
</script> </script>
<div class="flex space-x-1 py-5 font-bold"> <div class="flex space-x-1 py-5 font-bold">
@ -28,6 +74,29 @@ define('SUBDOMAIN_INSTALL', false);`
: 'N/A'}>{service.wordpress.extraConfig}</textarea : 'N/A'}>{service.wordpress.extraConfig}</textarea
> >
</div> </div>
<div class="grid grid-cols-2 items-center px-10">
<Setting
bind:setting={service.wordpress.ftpEnabled}
loading={ftpLoading}
on:click={() => changeSettings('ftpEnabled')}
title="Enable sFTP connection to WordPress data"
description="Enables an on-demand sFTP connection to the WordPress data directory. This is useful if you want to use sFTP to upload files."
/>
</div>
{#if service.wordpress.ftpEnabled}
<div class="grid grid-cols-2 items-center px-10">
<label for="ftpUrl">sFTP Connection URI</label>
<CopyPasswordField id="ftpUrl" readonly disabled name="ftpUrl" value={ftpUrl} />
</div>
<div class="grid grid-cols-2 items-center px-10">
<label for="ftpUser">User</label>
<CopyPasswordField id="ftpUser" readonly disabled name="ftpUser" value={ftpUser} />
</div>
<div class="grid grid-cols-2 items-center px-10">
<label for="ftpPassword">Password</label>
<CopyPasswordField id="ftpPassword" readonly disabled name="ftpPassword" value={ftpPassword} />
</div>
{/if}
<div class="flex space-x-1 py-5 font-bold"> <div class="flex space-x-1 py-5 font-bold">
<div class="title">MySQL</div> <div class="title">MySQL</div>
</div> </div>

View File

@ -16,7 +16,7 @@
const endpoint = `/services/${params.id}.json`; const endpoint = `/services/${params.id}.json`;
const res = await fetch(endpoint); const res = await fetch(endpoint);
if (res.ok) { if (res.ok) {
const { service, isRunning } = await res.json(); const { service, isRunning, settings } = await res.json();
if (!service || Object.entries(service).length === 0) { if (!service || Object.entries(service).length === 0) {
return { return {
status: 302, status: 302,
@ -45,7 +45,8 @@
stuff: { stuff: {
service, service,
isRunning, isRunning,
readOnly readOnly,
settings
} }
}; };
} }

View File

@ -17,7 +17,7 @@ export const get: RequestHandler = async (event) => {
const { id } = event.params; const { id } = event.params;
try { try {
const service = await db.getService({ id, teamId }); const service = await db.getService({ id, teamId });
const { destinationDockerId, destinationDocker, type, version } = service; const { destinationDockerId, destinationDocker, type, version, settings } = service;
let isRunning = false; let isRunning = false;
if (destinationDockerId) { if (destinationDockerId) {
@ -46,7 +46,8 @@ export const get: RequestHandler = async (event) => {
return { return {
body: { body: {
isRunning, isRunning,
service service,
settings
} }
}; };
} catch (error) { } catch (error) {

View File

@ -6,7 +6,8 @@
props: { props: {
service: stuff.service, service: stuff.service,
isRunning: stuff.isRunning, isRunning: stuff.isRunning,
readOnly: stuff.readOnly readOnly: stuff.readOnly,
settings: stuff.settings
} }
}; };
} }
@ -37,6 +38,7 @@
export let service; export let service;
export let isRunning; export let isRunning;
export let readOnly; export let readOnly;
export let settings;
if (browser && window.location.hostname === 'demo.coolify.io' && !service.fqdn) { if (browser && window.location.hostname === 'demo.coolify.io' && !service.fqdn) {
service.fqdn = `http://${cuid()}.demo.coolify.io`; service.fqdn = `http://${cuid()}.demo.coolify.io`;
@ -76,4 +78,4 @@
<ServiceLinks {service} /> <ServiceLinks {service} />
</div> </div>
<Services bind:service {isRunning} {readOnly} /> <Services bind:service {isRunning} {readOnly} {settings} />

View File

@ -0,0 +1,138 @@
import { dev } from '$app/env';
import { asyncExecShell, getEngine, getUserDetails } from '$lib/common';
import { decrypt, encrypt } from '$lib/crypto';
import * as db from '$lib/database';
import { generateDatabaseConfiguration, ErrorHandler, generatePassword } from '$lib/database';
import { checkContainer, startTcpProxy, stopTcpHttpProxy } from '$lib/haproxy';
import type { RequestHandler } from '@sveltejs/kit';
import cuid from 'cuid';
import fs from 'fs/promises';
import getPort, { portNumbers } from 'get-port';
export const post: RequestHandler = async (event) => {
const { status, body, teamId } = await getUserDetails(event);
if (status === 401) return { status, body };
const { id } = event.params;
const data = await db.prisma.setting.findFirst();
const { minPort, maxPort } = data;
const { ftpEnabled } = await event.request.json();
const publicPort = await getPort({ port: portNumbers(minPort, maxPort) });
let ftpUser = cuid();
const ftpPassword = generatePassword();
const hostkeyDir = dev ? '/tmp/hostkeys' : '/app/ssl/hostkeys';
try {
const { stdout: password } = await asyncExecShell(
`echo ${ftpPassword} | openssl passwd -1 -stdin`
);
const data = await db.prisma.wordpress.update({
where: { serviceId: id },
data: { ftpEnabled },
include: { service: { include: { destinationDocker: true } } }
});
const {
service: { destinationDockerId, destinationDocker },
ftpPublicPort: oldPublicPort,
ftpUser: user,
ftpHostKey,
ftpHostKeyPrivate
} = data;
if (user) ftpUser = user;
try {
await fs.stat(hostkeyDir);
} catch (error) {
await asyncExecShell(`mkdir -p ${hostkeyDir}`);
}
if (!ftpHostKey) {
await asyncExecShell(
`ssh-keygen -t ed25519 -f ssh_host_ed25519_key -N "" -q -f /tmp/${id} < /dev/null`
);
const { stdout: ftpHostKey } = await asyncExecShell(`cat ${hostkeyDir}/${id}.ed25519`);
await db.prisma.wordpress.update({
where: { serviceId: id },
data: { ftpHostKey: encrypt(ftpHostKey.replace('\n', '')) }
});
} else {
await asyncExecShell(`echo ${decrypt(ftpHostKey)} > ${hostkeyDir}/${id}.ed25519`);
}
if (!ftpHostKeyPrivate) {
await asyncExecShell(`ssh-keygen -t rsa -b 4096 -N "" -f /tmp/${id}.rsa < /dev/null`);
const { stdout: ftpHostKeyPrivate } = await asyncExecShell(`cat /tmp/${id}.rsa`);
await db.prisma.wordpress.update({
where: { serviceId: id },
data: { ftpHostKeyPrivate: encrypt(ftpHostKeyPrivate.replace('\n', '')) }
});
} else {
await asyncExecShell(`echo ${decrypt(ftpHostKeyPrivate)} > ${hostkeyDir}/${id}.rsa`);
}
if (destinationDockerId) {
const { network, engine } = destinationDocker;
const host = getEngine(engine);
if (ftpEnabled) {
await db.prisma.wordpress.update({
where: { serviceId: id },
data: { ftpPublicPort: publicPort, ftpUser, ftpPassword: encrypt(ftpPassword) }
});
try {
const isRunning = await checkContainer(engine, `${id}-ftp`);
if (isRunning) {
await asyncExecShell(
`DOCKER_HOST=${host} docker stop -t 0 ${id}-ftp && docker rm ${id}-ftp`
);
}
} catch (error) {
console.log(error);
//
}
await asyncExecShell(
`DOCKER_HOST=${host} docker run --restart always --add-host 'host.docker.internal:host-gateway' --network ${network} --name ${id}-ftp -v ${id}-wordpress-data:/home/${ftpUser} -v ${hostkeyDir}/${id}.ed25519:/etc/ssh/ssh_host_ed25519_key -v ${hostkeyDir}/${id}.rsa:/etc/ssh/ssh_host_rsa_key -d atmoz/sftp '${ftpUser}:${password.replace(
'\n',
''
)}:e:1001'`
);
await startTcpProxy(destinationDocker, `${id}-ftp`, publicPort, 22);
} else {
await db.prisma.wordpress.update({
where: { serviceId: id },
data: { ftpPublicPort: null }
});
try {
const isRunning = await checkContainer(engine, `${id}-ftp`);
if (isRunning) {
await asyncExecShell(
`DOCKER_HOST=${host} docker stop -t 0 ${id}-ftp && docker rm ${id}-ftp`
);
}
} catch (error) {
console.log(error);
//
}
await stopTcpHttpProxy(destinationDocker, oldPublicPort);
}
}
if (ftpEnabled) {
return {
status: 201,
body: {
publicPort,
ftpUser,
ftpPassword
}
};
} else {
return {
status: 200,
body: {}
};
}
} catch (error) {
console.log(error);
return ErrorHandler(error);
}
};