add ssl certificates and tests
This commit is contained in:
@@ -26,5 +26,19 @@ public class SchemaMigration {
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not drop encrypted_ssh_key column: {}", e.getMessage());
|
||||
}
|
||||
try {
|
||||
jdbcTemplate.execute("ALTER TABLE self_hosted_configs ADD COLUMN IF NOT EXISTS ssl_enabled BOOLEAN DEFAULT FALSE");
|
||||
jdbcTemplate.execute("UPDATE self_hosted_configs SET ssl_enabled = FALSE WHERE ssl_enabled IS NULL");
|
||||
jdbcTemplate.execute("ALTER TABLE self_hosted_configs ALTER COLUMN ssl_enabled SET NOT NULL");
|
||||
log.info("Added column ssl_enabled to self_hosted_configs");
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not add ssl_enabled column: {}", e.getMessage());
|
||||
}
|
||||
try {
|
||||
jdbcTemplate.execute("ALTER TABLE self_hosted_configs ADD COLUMN IF NOT EXISTS ssl_email VARCHAR(255)");
|
||||
log.info("Added column ssl_email to self_hosted_configs");
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not add ssl_email column: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,10 @@ public class SelfHostedController {
|
||||
@Valid @RequestBody SelfHostedConfigRequest request) {
|
||||
Site site = findSiteForUser(id, principal.id());
|
||||
|
||||
selfHostedConfigRepository.findBySiteId(id).ifPresent(existing ->
|
||||
selfHostedConfigRepository.delete(existing));
|
||||
selfHostedConfigRepository.findBySiteId(id).ifPresent(existing -> {
|
||||
selfHostedConfigRepository.delete(existing);
|
||||
selfHostedConfigRepository.flush();
|
||||
});
|
||||
|
||||
String deployPath = request.getDeployPath() != null && !request.getDeployPath().isBlank()
|
||||
? request.getDeployPath()
|
||||
@@ -78,6 +80,8 @@ public class SelfHostedController {
|
||||
.sshUser(request.getSshUser())
|
||||
.deployPath(deployPath)
|
||||
.nginxSitesPath(nginxSitesPath)
|
||||
.sslEnabled(request.isSslEnabled())
|
||||
.sslEmail(request.getSslEmail())
|
||||
.build();
|
||||
|
||||
config = selfHostedConfigRepository.save(config);
|
||||
|
||||
@@ -20,4 +20,8 @@ public class SelfHostedConfigRequest {
|
||||
private String deployPath;
|
||||
|
||||
private String nginxSitesPath;
|
||||
|
||||
private boolean sslEnabled;
|
||||
|
||||
private String sslEmail;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ public class SelfHostedConfigResponse {
|
||||
private String sshUser;
|
||||
private String deployPath;
|
||||
private String nginxSitesPath;
|
||||
private boolean sslEnabled;
|
||||
private String sslEmail;
|
||||
private String publicKey;
|
||||
private LocalDateTime deployedAt;
|
||||
private LocalDateTime createdAt;
|
||||
@@ -36,6 +38,8 @@ public class SelfHostedConfigResponse {
|
||||
.sshUser(config.getSshUser())
|
||||
.deployPath(config.getDeployPath())
|
||||
.nginxSitesPath(config.getNginxSitesPath())
|
||||
.sslEnabled(config.isSslEnabled())
|
||||
.sslEmail(config.getSslEmail())
|
||||
.publicKey(publicKey)
|
||||
.deployedAt(config.getDeployedAt())
|
||||
.createdAt(config.getCreatedAt())
|
||||
|
||||
@@ -18,29 +18,30 @@ import java.util.UUID;
|
||||
public class GitRemote {
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "site_id", nullable = false)
|
||||
private UUID siteId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
@Column(name = "provider", nullable = false)
|
||||
private GitProvider provider;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "remote_url", nullable = false)
|
||||
private String remoteUrl;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "encrypted_token", nullable = false)
|
||||
private String encryptedToken;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "default_branch", nullable = false)
|
||||
@Builder.Default
|
||||
private String defaultBranch = "main";
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
|
||||
@@ -15,38 +15,47 @@ import java.util.UUID;
|
||||
public class SelfHostedConfig {
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
@Column(name = "site_id", nullable = false, unique = true)
|
||||
private UUID siteId;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "domain", nullable = false)
|
||||
private String domain;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "ssh_host", nullable = false)
|
||||
private String sshHost;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "ssh_port", nullable = false)
|
||||
@Builder.Default
|
||||
private int sshPort = 22;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "ssh_user", nullable = false)
|
||||
private String sshUser;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "deploy_path", nullable = false)
|
||||
@Builder.Default
|
||||
private String deployPath = "/var/www/{domain}/public";
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "nginx_sites_path", nullable = false)
|
||||
@Builder.Default
|
||||
private String nginxSitesPath = "/etc/nginx/sites-available";
|
||||
|
||||
@Column(name = "ssl_enabled")
|
||||
@Builder.Default
|
||||
private boolean sslEnabled = false;
|
||||
|
||||
@Column(name = "ssl_email")
|
||||
private String sslEmail;
|
||||
|
||||
@Column(name = "deployed_at")
|
||||
private LocalDateTime deployedAt;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
|
||||
@@ -18,43 +18,47 @@ import java.util.UUID;
|
||||
public class Site {
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "name", nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(length = 1000)
|
||||
@Column(name = "description", length = 1000)
|
||||
private String description;
|
||||
|
||||
@Column(unique = true)
|
||||
@Column(name = "subdomain", unique = true)
|
||||
private String subdomain;
|
||||
|
||||
@Column(name = "published_url")
|
||||
private String publishedUrl;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
@Column(name = "status", nullable = false)
|
||||
@Builder.Default
|
||||
private SiteStatus status = SiteStatus.DRAFT;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "deployment_target")
|
||||
@Builder.Default
|
||||
private DeploymentTarget deploymentTarget = DeploymentTarget.NONE;
|
||||
|
||||
@Column(name = "published_at")
|
||||
private LocalDateTime publishedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public DeploymentTarget getDeploymentTarget() {
|
||||
return deploymentTarget != null ? deploymentTarget : DeploymentTarget.NONE;
|
||||
}
|
||||
|
||||
private LocalDateTime publishedAt;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public enum DeploymentTarget {
|
||||
GIT_PAGES, SELF_HOSTED, NONE
|
||||
}
|
||||
|
||||
@@ -18,31 +18,36 @@ import java.util.UUID;
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
@Column(name = "email", nullable = false, unique = true)
|
||||
private String email;
|
||||
|
||||
@Column(name = "password")
|
||||
private String password;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "name", nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(name = "avatar_url")
|
||||
private String avatarUrl;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
@Column(name = "provider", nullable = false)
|
||||
private AuthProvider provider;
|
||||
|
||||
@Column(name = "provider_id")
|
||||
private String providerId;
|
||||
|
||||
@Column(name = "email_verified")
|
||||
@Builder.Default
|
||||
private boolean emailVerified = false;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
|
||||
@@ -38,7 +38,11 @@ public class SshRsyncDeployer implements Deployer {
|
||||
|
||||
rsync(tempDir.resolve("site"), keyFile, config, resolvedDeployPath);
|
||||
|
||||
String nginxConfig = buildNginxConfig(config.getDomain(), resolvedDeployPath);
|
||||
if (config.isSslEnabled()) {
|
||||
provisionSsl(keyFile, config, resolvedDeployPath);
|
||||
}
|
||||
|
||||
String nginxConfig = buildNginxConfig(config.getDomain(), resolvedDeployPath, config.isSslEnabled());
|
||||
Path nginxConfPath = tempDir.resolve(config.getDomain());
|
||||
Files.writeString(nginxConfPath, nginxConfig);
|
||||
|
||||
@@ -73,6 +77,10 @@ public class SshRsyncDeployer implements Deployer {
|
||||
+ " /etc/nginx/sites-enabled/" + config.getDomain()
|
||||
+ " && sudo nginx -s reload");
|
||||
|
||||
if (config.isSslEnabled()) {
|
||||
removeSsl(keyFile, config);
|
||||
}
|
||||
|
||||
log.info("Removed deployment for site {} ({})", config.getSiteId(), config.getDomain());
|
||||
} finally {
|
||||
deleteDirectory(tempDir);
|
||||
@@ -81,7 +89,8 @@ public class SshRsyncDeployer implements Deployer {
|
||||
|
||||
@Override
|
||||
public String buildUrl(SelfHostedConfig config) {
|
||||
return "https://" + config.getDomain() + "/";
|
||||
String scheme = config.isSslEnabled() ? "https" : "http";
|
||||
return scheme + "://" + config.getDomain() + "/";
|
||||
}
|
||||
|
||||
private void writeSiteFiles(Path tempDir, SiteOutput output) throws IOException {
|
||||
@@ -122,7 +131,25 @@ public class SshRsyncDeployer implements Deployer {
|
||||
execute(rsyncCmd);
|
||||
}
|
||||
|
||||
private String buildNginxConfig(String domain, String rootPath) {
|
||||
private String buildNginxConfig(String domain, String rootPath, boolean sslEnabled) {
|
||||
if (sslEnabled) {
|
||||
return "server {\n"
|
||||
+ " listen 443 ssl http2;\n"
|
||||
+ " server_name " + domain + ";\n"
|
||||
+ " root " + rootPath + ";\n"
|
||||
+ " index index.html;\n"
|
||||
+ " ssl_certificate /etc/letsencrypt/live/" + domain + "/fullchain.pem;\n"
|
||||
+ " ssl_certificate_key /etc/letsencrypt/live/" + domain + "/privkey.pem;\n"
|
||||
+ " location / {\n"
|
||||
+ " try_files $uri $uri/ /index.html;\n"
|
||||
+ " }\n"
|
||||
+ "}\n"
|
||||
+ "server {\n"
|
||||
+ " listen 80;\n"
|
||||
+ " server_name " + domain + ";\n"
|
||||
+ " return 301 https://$host$request_uri;\n"
|
||||
+ "}\n";
|
||||
}
|
||||
return "server {\n"
|
||||
+ " listen 80;\n"
|
||||
+ " server_name " + domain + ";\n"
|
||||
@@ -134,6 +161,61 @@ public class SshRsyncDeployer implements Deployer {
|
||||
+ "}\n";
|
||||
}
|
||||
|
||||
private void provisionSsl(String keyFile, SelfHostedConfig config, String deployPath) throws Exception {
|
||||
String domain = config.getDomain();
|
||||
String email = config.getSslEmail();
|
||||
if (email == null || email.isBlank()) {
|
||||
throw new RuntimeException("SSL email is required when SSL is enabled");
|
||||
}
|
||||
|
||||
String checkCerts = "sudo test -d /etc/letsencrypt/live/" + domain + " && echo EXISTS || echo MISSING";
|
||||
String certStatus = sshWithOutput(keyFile, config, checkCerts);
|
||||
|
||||
if (certStatus.contains("EXISTS")) {
|
||||
log.info("SSL certificates already exist for {}", domain);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Provisioning SSL certificate for {} via Let's Encrypt", domain);
|
||||
|
||||
String certbotCmd = String.format(
|
||||
"sudo certbot certonly --webroot -w %s -d %s --non-interactive --agree-tos -m %s",
|
||||
deployPath, domain, email
|
||||
);
|
||||
ssh(keyFile, config, certbotCmd);
|
||||
log.info("SSL certificate obtained for {}", domain);
|
||||
}
|
||||
|
||||
private void removeSsl(String keyFile, SelfHostedConfig config) throws Exception {
|
||||
String domain = config.getDomain();
|
||||
|
||||
String checkCerts = "sudo test -d /etc/letsencrypt/live/" + domain + " && echo EXISTS || echo MISSING";
|
||||
String certStatus = sshWithOutput(keyFile, config, checkCerts);
|
||||
|
||||
if (!certStatus.contains("EXISTS")) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Removing SSL certificates for {}", domain);
|
||||
ssh(keyFile, config, "sudo certbot delete --non-interactive -d " + domain);
|
||||
}
|
||||
|
||||
private String sshWithOutput(String keyFile, SelfHostedConfig config, String command) throws Exception {
|
||||
String sshCmd = String.format(
|
||||
"ssh -i %s -p %d -o StrictHostKeyChecking=accept-new %s@%s \"%s\"",
|
||||
keyFile, config.getSshPort(), config.getSshUser(), config.getSshHost(), command
|
||||
);
|
||||
ProcessBuilder pb = new ProcessBuilder("sh", "-c", sshCmd);
|
||||
pb.redirectErrorStream(true);
|
||||
Process process = pb.start();
|
||||
String output = new String(process.getInputStream().readAllBytes());
|
||||
int exitCode = process.waitFor();
|
||||
if (exitCode != 0) {
|
||||
throw new RuntimeException("Command failed (exit " + exitCode + "): " + sshCmd + "\n" + output);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
private void execute(String command) throws Exception {
|
||||
ProcessBuilder pb = new ProcessBuilder("sh", "-c", command);
|
||||
pb.redirectErrorStream(true);
|
||||
|
||||
Reference in New Issue
Block a user