add ssl certificates and tests

This commit is contained in:
Krrish Ghimire
2026-07-06 14:49:44 +05:45
parent a498d55e8c
commit a6a9ac6e7e
23 changed files with 2041 additions and 40 deletions

View File

@@ -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());
}
}
}

View File

@@ -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);

View File

@@ -20,4 +20,8 @@ public class SelfHostedConfigRequest {
private String deployPath;
private String nginxSitesPath;
private boolean sslEnabled;
private String sslEmail;
}

View File

@@ -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())

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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);