Skip to content
This repository has been archived by the owner on Jul 20, 2022. It is now read-only.

Commit

Permalink
feat: Certificate file permissions (#53)
Browse files Browse the repository at this point in the history
  • Loading branch information
lholota authored Aug 2, 2021
1 parent da26dd3 commit 9eba1ac
Show file tree
Hide file tree
Showing 12 changed files with 180 additions and 18 deletions.
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ jobs:
run: docker build . -t ${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.docker_tag }}

- name: Test Docker image
run: cd tests && sudo --preserve-env gradle test --info -Ddocker_image_tag=${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.docker_tag }} -Droot_domain=${{ secrets.ROOT_DOMAIN }} -Dacme_email=${{ secrets.ACME_EMAIL }} -Dcloudflare_token=${{ secrets.CLOUDFLARE_TOKEN }}
run: cd tests && sudo --preserve-env gradle test --info -Ddocker_image_tag=${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.docker_tag }}
env: # To allow downloading packages
GITHUB_USERNAME: ${{ github.actor }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ACME_EMAIL: ${{ secrets.ACME_EMAIL }}
ROOT_DOMAIN: ${{ secrets.ROOT_DOMAIN }}
CLOUDFLARE_TOKEN: ${{ secrets.CLOUDFLARE_TOKEN }}
5 changes: 4 additions & 1 deletion .github/workflows/ci_cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,13 @@ jobs:
- name: Test Docker image
if: env.RELEASE_VERSION != ''
run: cd tests && sudo --preserve-env gradle test -Ddocker_image_tag=${{ env.IMAGE_NAME }}:${{ env.RELEASE_VERSION }} -Droot_domain=${{ secrets.ROOT_DOMAIN }} -Dacme_email=${{ secrets.ACME_EMAIL }} -Dcloudflare_token=${{ secrets.CLOUDFLARE_TOKEN }}
run: cd tests && sudo --preserve-env gradle test -Ddocker_image_tag=${{ env.IMAGE_NAME }}:${{ env.RELEASE_VERSION }}
env: # To allow downloading packages
GITHUB_USERNAME: ${{ github.actor }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ACME_EMAIL: ${{ secrets.ACME_EMAIL }}
ROOT_DOMAIN: ${{ secrets.ROOT_DOMAIN }}
CLOUDFLARE_TOKEN: ${{ secrets.CLOUDFLARE_TOKEN }}

# Docker hub
- name: "Log into Docker Hub"
Expand Down
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,8 @@ RUN apk add --no-cache \

COPY ./fs/ /

VOLUME "/etc/letsencrypt"
RUN mkdir /logs && chmod 0777 /logs

VOLUME "/etc/letsencrypt"
VOLUME "/data"
VOLUME "/logs"
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ services:
| PUID | 7077 | UID of the user certbot be running as. |
| PGID | 7077 | GID of the user certbot be running as. |
| CERTBOT_ARGS | | Additional arguments passed to certbot's `certonly` command. The argument `--agree-tos` is passed automatically, but you have to provide the `--email` argument. |
| CERTS_GID | 7077 | GID of a group which set as group owner of the certificates in the `/data` directory. This is to simplify sharing the certificates with other containers/components. |

## Exposed ports

Expand All @@ -44,7 +45,9 @@ This image does not expose any ports.

| Container path | Description |
|------------|---------------|
| /etc/letsencrypt | Contains the provisioned certificates. Please note that the "files" in the `/etc/letsencrypt/*` are just symlinks and therefore when mounting, you need to mount either the whole `/etc/letsencrypt/` directory or mount both `/etc/letsencrypt/live` and `/etc/letsencrypt/archive` on same relative levels. |
| /etc/letsencrypt | Directory where certbot keeps its state. This directory should be persisted to avoid issuing the same certificate multiple times. |
| /data | The output certificates will be placed in this directory. This is the directory you can/want share with other components. The certificates are standard files, not symlinks. |
| /logs | Certbot will output detailed logs into this directory. Make sure the PUID user has write permissions in this directory. |

## Security
The container is regularly scanned for vulnerabilities and updated. Further info can be found in the [Security tab](https://github.com/homecentr/docker-certbot).
Expand Down
14 changes: 13 additions & 1 deletion fs/cron/cron-tick
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
#!/usr/bin/env ash

certbot certonly --agree-tos --non-interactive --no-permissions-check $CERTBOT_ARGS
# Execute certbot
certbot certonly --agree-tos --non-interactive --logs-dir /logs $CERTBOT_ARGS

# Fix file permissions
chown "$PUID" -R /etc/letsencrypt
chgrp "${CERTS_GID:-$PGID}" -R /etc/letsencrypt
chmod "0750" -R /etc/letsencrypt

# Copy files over to /data while preserving the file permissions
cp -p /etc/letsencrypt/live/*/*.pem /data


cat /logs/*
29 changes: 29 additions & 0 deletions fs/etc/cont-init.d/15-readers-group.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/with-contenv ash

EXEC_USER=$(cat /var/run/s6/container_environment/EXEC_USER)

if [ "$CERTS_GID" == "0" ] || [ "$CERTS_GID" == "" ]
then
# User doesn't want special group ownership, the group owner of the files will be PGID, skip creating the group
return
fi

if [ "$CERTS_GID" == "$PGID" ]
then
CERTS_GID=$PGID
fi

# Check if the group already exists
cat /etc/group | grep ^ssl-readers: > /dev/null

if [ $? == 0 ]
then
# Group already exists, delete it
delgroup ssl-readers
fi

# Make sure we really need to create a separate group
if [ "$PGID" != "$CERTS_GID" ]
then
addgroup -g $CERTS_GID ssl-readers
fi
5 changes: 5 additions & 0 deletions fs/etc/cont-init.d/16-file-permissions.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/with-contenv ash

chown "$PUID" -R /data
chgrp "${CERTS_GID:-$PGID}" -R /data
chmod "0750" -R /data
5 changes: 5 additions & 0 deletions tests/.idea/jarRepositories.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 1 addition & 4 deletions tests/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,13 @@ repositories {
dependencies {
testImplementation group: 'junit', name: 'junit', version: '4.13.2'
testImplementation group: 'org.testcontainers', name: 'testcontainers', version: '1.16.0'
testImplementation group: 'io.homecentr', name: 'testcontainers-extensions', version: '1.5.0'
testImplementation group: 'io.homecentr', name: 'testcontainers-extensions', version: '1.6.0'
testImplementation group: 'org.slf4j', name: 'slf4j-api', version: '1.7.32'
testImplementation group: 'org.slf4j', name: 'slf4j-simple', version: '1.7.32'
}

test {
systemProperty 'docker_image_tag', System.getProperty('docker_image_tag')
systemProperty 'root_domain', System.getProperty('root_domain')
systemProperty 'acme_email', System.getProperty('acme_email')
systemProperty 'cloudflare_token', System.getProperty('cloudflare_token')

afterTest { desc, result ->
logger.quiet "Executing test ${desc.name} [${desc.className}] with result: ${result.resultType}"
Expand Down
23 changes: 19 additions & 4 deletions tests/src/test/java/CertbotContainerShould.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import java.time.Duration;

import static io.homecentr.testcontainers.WaitLoop.waitFor;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

public class CertbotContainerShould {
Expand All @@ -27,6 +28,8 @@ public static void before() throws Exception {
_testConfig.createCredentialsSecretFile();

_certbotContainer = new GenericContainerEx<>(new CertbotDockerTagResolver())
.withEnv("PUID", "9001")
.withEnv("PGID", "9002")
.withEnv("CRON_SCHEDULE", "* * * * *")
.withEnv("CERTBOT_ARGS", _testConfig.getCertbotArgs())
.withFileSystemBind(TestConfiguration.cloudflareCredentialsHostPath, TestConfiguration.cloudflareCredentialsContainerPath)
Expand All @@ -46,22 +49,34 @@ public static void after() {

@Test
public void createCertificateFullChainFile() throws IOException, InterruptedException {
assertTrue(fileExists(String.format("/etc/letsencrypt/live/%s/fullchain.pem", _testConfig.getDomain())));
assertTrue(fileExists("/data/fullchain.pem"));

assertEquals((Integer)9001, _certbotContainer.getFileOwnerUid("/data/fullchain.pem"));
assertEquals((Integer)9002, _certbotContainer.getFileOwningGid("/data/fullchain.pem"));
}

@Test
public void createCertificateChainFile() throws IOException, InterruptedException {
assertTrue(fileExists(String.format("/etc/letsencrypt/live/%s/chain.pem", _testConfig.getDomain())));
assertTrue(fileExists("/data/chain.pem"));

assertEquals((Integer)9001, _certbotContainer.getFileOwnerUid("/data/chain.pem"));
assertEquals((Integer)9002, _certbotContainer.getFileOwningGid("/data/chain.pem"));
}

@Test
public void createPrivateKeyFile() throws IOException, InterruptedException {
assertTrue(fileExists(String.format("/etc/letsencrypt/live/%s/privkey.pem", _testConfig.getDomain())));
assertTrue(fileExists("/data/privkey.pem"));

assertEquals((Integer)9001, _certbotContainer.getFileOwnerUid("/data/privkey.pem"));
assertEquals((Integer)9002, _certbotContainer.getFileOwningGid("/data/privkey.pem"));
}

@Test
public void createPublicKeyFile() throws IOException, InterruptedException {
assertTrue(fileExists(String.format("/etc/letsencrypt/live/%s/cert.pem", _testConfig.getDomain())));
assertTrue(fileExists("/data/cert.pem"));

assertEquals((Integer)9001, _certbotContainer.getFileOwnerUid("/data/cert.pem"));
assertEquals((Integer)9002, _certbotContainer.getFileOwningGid("/data/cert.pem"));
}

private boolean fileExists(String fileNamePattern) throws IOException, InterruptedException {
Expand Down
86 changes: 86 additions & 0 deletions tests/src/test/java/CertbotContainerWithCertsGidEnvShould.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import helpers.CertbotDockerTagResolver;
import io.homecentr.testcontainers.containers.GenericContainerEx;
import io.homecentr.testcontainers.containers.wait.strategy.WaitEx;
import io.homecentr.testcontainers.images.PullPolicyEx;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.testcontainers.containers.output.Slf4jLogConsumer;

import java.io.IOException;
import java.time.Duration;

import static io.homecentr.testcontainers.WaitLoop.waitFor;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

public class CertbotContainerWithCertsGidEnvShould {
private static final Logger logger = LoggerFactory.getLogger(CertbotContainerShould.class);

private static GenericContainerEx _certbotContainer;
private static TestConfiguration _testConfig;

@BeforeClass
public static void before() throws Exception {
_testConfig = TestConfiguration.create();
_testConfig.createCredentialsSecretFile();

_certbotContainer = new GenericContainerEx<>(new CertbotDockerTagResolver())
.withEnv("PUID", "9001")
.withEnv("PGID", "9002")
.withEnv("CRON_SCHEDULE", "* * * * *")
.withEnv("CERTBOT_ARGS", _testConfig.getCertbotArgs())
.withEnv("CERTS_GID", "9003")
.withFileSystemBind(TestConfiguration.cloudflareCredentialsHostPath, TestConfiguration.cloudflareCredentialsContainerPath)
.withImagePullPolicy(PullPolicyEx.never())
.waitingFor(WaitEx.forS6OverlayStart());

_certbotContainer.start();
_certbotContainer.followOutput(new Slf4jLogConsumer(logger));

waitFor(Duration.ofSeconds(80), () -> _certbotContainer.getLogsAnalyzer().contains("Execution finished"));
}

@AfterClass
public static void after() {
_certbotContainer.close();
}

@Test
public void createCertificateFullChainFile() throws IOException, InterruptedException {
assertTrue(fileExists("/data/fullchain.pem"));

assertEquals((Integer)9001, _certbotContainer.getFileOwnerUid("/data/fullchain.pem"));
assertEquals((Integer)9003, _certbotContainer.getFileOwningGid("/data/fullchain.pem"));
}

@Test
public void createCertificateChainFile() throws IOException, InterruptedException {
assertTrue(fileExists("/data/chain.pem"));

assertEquals((Integer)9001, _certbotContainer.getFileOwnerUid("/data/chain.pem"));
assertEquals((Integer)9003, _certbotContainer.getFileOwningGid("/data/chain.pem"));
}

@Test
public void createPrivateKeyFile() throws IOException, InterruptedException {
assertTrue(fileExists("/data/privkey.pem"));

assertEquals((Integer)9001, _certbotContainer.getFileOwnerUid("/data/privkey.pem"));
assertEquals((Integer)9003, _certbotContainer.getFileOwningGid("/data/privkey.pem"));
}

@Test
public void createPublicKeyFile() throws IOException, InterruptedException {
assertTrue(fileExists("/data/cert.pem"));

assertEquals((Integer)9001, _certbotContainer.getFileOwnerUid("/data/cert.pem"));
assertEquals((Integer)9003, _certbotContainer.getFileOwningGid("/data/cert.pem"));
}

private boolean fileExists(String fileNamePattern) throws IOException, InterruptedException {
return _certbotContainer.execInContainer("ls", fileNamePattern).getExitCode() == 0;
}
}
8 changes: 4 additions & 4 deletions tests/src/test/java/TestConfiguration.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public String getDomain() {
}

public String getCertbotArgs() {
return String.format("--email %s --staging --dns-cloudflare --dns-cloudflare-credentials %s -d %s",
return String.format("--email %s --staging --dns-cloudflare --dns-cloudflare-credentials %s -d %s -v",
getEmail(),
cloudflareCredentialsContainerPath,
getDomain());
Expand All @@ -47,14 +47,14 @@ public void createCredentialsSecretFile() throws IOException {
}

private String getCloudflareToken() {
return System.getProperty("cloudflare_token");
return System.getenv("CLOUDFLARE_TOKEN");
}

private String getEmail() {
return System.getProperty("acme_email");
return System.getenv("ACME_EMAIL");
}

private String getRootDomain() {
return System.getProperty("root_domain");
return System.getenv("ROOT_DOMAIN");
}
}

0 comments on commit 9eba1ac

Please sign in to comment.