Skip to content

Commit 14c5e80

Browse files
feat(gateway): add acme-dns provider support for web endpoints
Add support for acme-dns as a third DNS provider option for obtaining wildcard certificates for web endpoints, alongside existing Cloudflare and DigitalOcean providers. This enables customers who don't use Cloudflare or DigitalOcean for DNS management to use custom domains with acme-dns challenge delegation.
1 parent bc10c90 commit 14c5e80

5 files changed

Lines changed: 156 additions & 21 deletions

File tree

docker-compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ services:
109109
- SHELLHUB_WEB_ENDPOINTS_DOMAIN=${SHELLHUB_WEB_ENDPOINTS_DOMAIN}
110110
- SHELLHUB_WEB_ENDPOINTS_DNS_PROVIDER=${SHELLHUB_WEB_ENDPOINTS_DNS_PROVIDER}
111111
- SHELLHUB_WEB_ENDPOINTS_DNS_PROVIDER_TOKEN=${SHELLHUB_WEB_ENDPOINTS_DNS_PROVIDER_TOKEN}
112+
- SHELLHUB_WEB_ENDPOINTS_ACME_DNS_URL=${SHELLHUB_WEB_ENDPOINTS_ACME_DNS_URL}
113+
- SHELLHUB_WEB_ENDPOINTS_ACME_DNS_USERNAME=${SHELLHUB_WEB_ENDPOINTS_ACME_DNS_USERNAME}
114+
- SHELLHUB_WEB_ENDPOINTS_ACME_DNS_PASSWORD=${SHELLHUB_WEB_ENDPOINTS_ACME_DNS_PASSWORD}
115+
- SHELLHUB_WEB_ENDPOINTS_ACME_DNS_SUBDOMAIN=${SHELLHUB_WEB_ENDPOINTS_ACME_DNS_SUBDOMAIN}
112116
- SHELLHUB_VERSION=${SHELLHUB_VERSION}
113117
- SHELLHUB_SSH_PORT=${SHELLHUB_SSH_PORT}
114118
- SHELLHUB_PROXY=${SHELLHUB_PROXY}

gateway/certbot.go

Lines changed: 92 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ const DigitalOceanDNSProvider = "digitalocean"
119119
// CloudflareDNSProvider represents the Cloudflare DNS provider.
120120
const CloudflareDNSProvider = "cloudflare"
121121

122+
// AcmeDNSProvider represents the acme-dns provider for DNS-01 challenges.
123+
const AcmeDNSProvider = "acmedns"
124+
122125
// Config holds the configuration for CertBot operations.
123126
type Config struct {
124127
// RootDir is the root directory where CertBot stores its configurations
@@ -298,22 +301,35 @@ type WebEndpointsCertificate struct {
298301
Domain string
299302
// Provider is the DNS provider used for DNS-01 challenges.
300303
Provider DNSProvider
301-
// Token is the API token for the DNS provider.
304+
// Token is the API token for the DNS provider (used for Cloudflare and DigitalOcean).
302305
Token string
306+
// AcmeDNSURL is the URL of the acme-dns server (only for acmedns provider).
307+
AcmeDNSURL string
308+
// AcmeDNSUsername is the username from acme-dns registration (only for acmedns provider).
309+
AcmeDNSUsername string
310+
// AcmeDNSPassword is the password from acme-dns registration (only for acmedns provider).
311+
AcmeDNSPassword string
312+
// AcmeDNSSubdomain is the subdomain from acme-dns registration (only for acmedns provider).
313+
AcmeDNSSubdomain string
303314

304315
ex Executor
305316
fs afero.Fs
306317
}
307318

308319
// NewWebEndpointsCertificate creates a new TunnelsCertificate instance for generating
309320
// wildcard certificates using DNS-01 challenges.
310-
func NewWebEndpointsCertificate(domain string, provider DNSProvider, token string) Certificate {
321+
func NewWebEndpointsCertificate(domain string, provider DNSProvider, token string, acmeDNSURL, acmeDNSUsername, acmeDNSPassword, acmeDNSSubdomain string) Certificate {
311322
return &WebEndpointsCertificate{
312323
Domain: domain,
313324

314325
Provider: provider,
315326
Token: token,
316327

328+
AcmeDNSURL: acmeDNSURL,
329+
AcmeDNSUsername: acmeDNSUsername,
330+
AcmeDNSPassword: acmeDNSPassword,
331+
AcmeDNSSubdomain: acmeDNSSubdomain,
332+
317333
ex: NewExecutor(),
318334
fs: afero.NewOsFs(),
319335
}
@@ -322,22 +338,46 @@ func NewWebEndpointsCertificate(domain string, provider DNSProvider, token strin
322338
// generateProviderCredentialsFile creates a credentials file for the DNS provider.
323339
// This file contains the API token needed for DNS-01 challenges.
324340
func (d *WebEndpointsCertificate) generateProviderCredentialsFile() (afero.File, error) {
325-
tokenLine := fmt.Sprintf("dns_%s_token = %s", d.Provider, d.Token)
326-
327-
// Certbot Cloudflare plugin expects dns_cloudflare_api_token
328-
if d.Provider == CloudflareDNSProvider {
329-
tokenLine = fmt.Sprintf("dns_cloudflare_api_token = %s", d.Token)
330-
}
331-
332-
file, err := d.fs.Create(fmt.Sprintf("/etc/shellhub-gateway/%s.ini", string(d.Provider)))
341+
var content string
342+
var filename string
343+
344+
switch d.Provider {
345+
case CloudflareDNSProvider:
346+
// Certbot Cloudflare plugin expects dns_cloudflare_api_token
347+
content = fmt.Sprintf("dns_cloudflare_api_token = %s", d.Token)
348+
filename = "/etc/shellhub-gateway/cloudflare.ini"
349+
350+
case DigitalOceanDNSProvider:
351+
content = fmt.Sprintf("dns_digitalocean_token = %s", d.Token)
352+
filename = "/etc/shellhub-gateway/digitalocean.ini"
353+
354+
case AcmeDNSProvider:
355+
// certbot-dns-acmedns expects a JSON file with acme-dns credentials
356+
content = fmt.Sprintf(`{
357+
"%s": {
358+
"username": "%s",
359+
"password": "%s",
360+
"fulldomain": "%s",
361+
"subdomain": "%s",
362+
"allowfrom": []
363+
}
364+
}`, d.Domain, d.AcmeDNSUsername, d.AcmeDNSPassword,
365+
fmt.Sprintf("_acme-challenge.%s", d.Domain), d.AcmeDNSSubdomain)
366+
filename = "/etc/shellhub-gateway/acmedns.json"
367+
368+
default:
369+
return nil, fmt.Errorf("unsupported DNS provider: %s", d.Provider)
370+
}
371+
372+
file, err := d.fs.Create(filename)
333373
if err != nil {
334-
log.WithError(err).Error("failed to create shellhub-gateway file with dns provider token")
374+
log.WithError(err).WithField("filename", filename).Error("failed to create credentials file")
335375

336376
return nil, err
337377
}
338378

339-
if _, err := file.Write([]byte(tokenLine)); err != nil {
340-
log.WithError(err).Error("failed to write the token into credentials file")
379+
if _, err := file.Write([]byte(content)); err != nil {
380+
log.WithError(err).Error("failed to write credentials to file")
341381

342382
return nil, err
343383
}
@@ -354,8 +394,24 @@ func (d *WebEndpointsCertificate) Check() error {
354394
return errors.New("DNS provider is required for certificate generation")
355395
}
356396

357-
if d.Token == "" {
358-
return errors.New("DNS provider token is required for certificate generation")
397+
// Validate provider-specific credentials
398+
switch d.Provider {
399+
case CloudflareDNSProvider, DigitalOceanDNSProvider:
400+
if d.Token == "" {
401+
return fmt.Errorf("DNS provider token is required for %s", d.Provider)
402+
}
403+
case AcmeDNSProvider:
404+
if d.AcmeDNSUsername == "" {
405+
return errors.New("acme-dns username is required for acmedns provider")
406+
}
407+
if d.AcmeDNSPassword == "" {
408+
return errors.New("acme-dns password is required for acmedns provider")
409+
}
410+
if d.AcmeDNSSubdomain == "" {
411+
return errors.New("acme-dns subdomain is required for acmedns provider")
412+
}
413+
default:
414+
return fmt.Errorf("unsupported DNS provider: %s", d.Provider)
359415
}
360416

361417
if _, err := d.fs.Stat("/etc/shellhub-gateway"); os.IsNotExist(err) {
@@ -384,7 +440,7 @@ func (d *WebEndpointsCertificate) Generate(staging bool) error {
384440
// Create the DNS provider credentials file
385441
file, err := d.generateProviderCredentialsFile()
386442
if err != nil {
387-
log.WithError(err).Error("failed to generate INI file")
443+
log.WithError(err).Error("failed to generate credentials file")
388444

389445
return err
390446
}
@@ -397,13 +453,28 @@ func (d *WebEndpointsCertificate) Generate(staging bool) error {
397453
"--register-unsafely-without-email",
398454
"--cert-name",
399455
fmt.Sprintf("*.%s", d.Domain),
400-
fmt.Sprintf("--dns-%s", d.Provider),
401-
fmt.Sprintf("--dns-%s-credentials", d.Provider),
402-
file.Name(),
403-
"-d",
404-
fmt.Sprintf("*.%s", d.Domain),
405456
}
406457

458+
// Add provider-specific arguments
459+
if d.Provider == AcmeDNSProvider {
460+
// certbot-dns-acmedns uses different flags
461+
args = append(args,
462+
"--dns-acmedns",
463+
"--dns-acmedns-credentials",
464+
file.Name(),
465+
)
466+
} else {
467+
// Standard certbot DNS plugins (cloudflare, digitalocean)
468+
args = append(args,
469+
fmt.Sprintf("--dns-%s", d.Provider),
470+
fmt.Sprintf("--dns-%s-credentials", d.Provider),
471+
file.Name(),
472+
)
473+
}
474+
475+
// Add domain
476+
args = append(args, "-d", fmt.Sprintf("*.%s", d.Domain))
477+
407478
if staging {
408479
log.Info("running generate with staging on dns")
409480

gateway/certbot_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,21 @@ func TestTunnelsCertificate_generateProviderCredentialsFile(t *testing.T) {
3737
wantFile: "/etc/shellhub-gateway/cloudflare.ini",
3838
wantContent: "dns_cloudflare_api_token = test-cf",
3939
},
40+
{
41+
name: "AcmeDNS",
42+
provider: AcmeDNSProvider,
43+
token: "", // Not used for acme-dns
44+
wantFile: "/etc/shellhub-gateway/acmedns.json",
45+
wantContent: `{
46+
"localhost": {
47+
"username": "test-username",
48+
"password": "test-password",
49+
"fulldomain": "_acme-challenge.localhost",
50+
"subdomain": "test-subdomain",
51+
"allowfrom": []
52+
}
53+
}`,
54+
},
4055
}
4156
for _, tc := range cases {
4257
t.Run(tc.name, func(t *testing.T) {
@@ -45,6 +60,14 @@ func TestTunnelsCertificate_generateProviderCredentialsFile(t *testing.T) {
4560
Provider: tc.provider,
4661
Token: tc.token,
4762
}
63+
64+
// Add acme-dns specific fields if needed
65+
if tc.provider == AcmeDNSProvider {
66+
cert.AcmeDNSUsername = "test-username"
67+
cert.AcmeDNSPassword = "test-password"
68+
cert.AcmeDNSSubdomain = "test-subdomain"
69+
}
70+
4871
cert.fs = afero.NewMemMapFs()
4972

5073
file, err := cert.generateProviderCredentialsFile()
@@ -173,6 +196,35 @@ func TestTunnelsCertificate_generate(t *testing.T) {
173196
},
174197
expected: nil,
175198
},
199+
{
200+
// AcmeDNS provider invocation
201+
name: "acmedns provider",
202+
config: WebEndpointsCertificate{
203+
Domain: "localhost",
204+
Provider: "acmedns",
205+
AcmeDNSUsername: "test-user",
206+
AcmeDNSPassword: "test-pass",
207+
AcmeDNSSubdomain: "test-subdomain",
208+
},
209+
expectCalls: func(executorMock *gatewayMocks.Executor) {
210+
executorMock.On("Command", "certbot",
211+
"certonly",
212+
"--non-interactive",
213+
"--agree-tos",
214+
"--register-unsafely-without-email",
215+
"--cert-name",
216+
"*.localhost",
217+
"--dns-acmedns",
218+
"--dns-acmedns-credentials",
219+
"/etc/shellhub-gateway/acmedns.json",
220+
"-d",
221+
"*.localhost",
222+
).Return(exec.Command("")).Once()
223+
224+
executorMock.On("Run", mock.AnythingOfType("*exec.Cmd")).Return(nil).Once()
225+
},
226+
expected: nil,
227+
},
176228
}
177229

178230
for _, tc := range tests {

gateway/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ type GatewayConfig struct {
1717
WebEndpointsDomain string `env:"SHELLHUB_WEB_ENDPOINTS_DOMAIN"`
1818
WebEndpointsDNSProvider DNSProvider `env:"SHELLHUB_WEB_ENDPOINTS_DNS_PROVIDER,default=digitalocean"`
1919
WebEndpointsDNSProviderToken string `env:"SHELLHUB_WEB_ENDPOINTS_DNS_PROVIDER_TOKEN"`
20+
WebEndpointsAcmeDNSURL string `env:"SHELLHUB_WEB_ENDPOINTS_ACME_DNS_URL"`
21+
WebEndpointsAcmeDNSUsername string `env:"SHELLHUB_WEB_ENDPOINTS_ACME_DNS_USERNAME"`
22+
WebEndpointsAcmeDNSPassword string `env:"SHELLHUB_WEB_ENDPOINTS_ACME_DNS_PASSWORD"`
23+
WebEndpointsAcmeDNSSubdomain string `env:"SHELLHUB_WEB_ENDPOINTS_ACME_DNS_SUBDOMAIN"`
2024
WorkerProcesses string `env:"WORKER_PROCESSES,default=auto"`
2125
MaxWorkerOpenFiles int `env:"MAX_WORKER_OPEN_FILES,default=0"`
2226
MaxWorkerConnections int `env:"MAX_WORKER_CONNECTIONS,default=16384"`

gateway/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ func NewGateway(config *GatewayConfig, controller *NginxController, features []s
9595
g.Config.WebEndpointsDomain,
9696
g.Config.WebEndpointsDNSProvider,
9797
g.Config.WebEndpointsDNSProviderToken,
98+
g.Config.WebEndpointsAcmeDNSURL,
99+
g.Config.WebEndpointsAcmeDNSUsername,
100+
g.Config.WebEndpointsAcmeDNSPassword,
101+
g.Config.WebEndpointsAcmeDNSSubdomain,
98102
),
99103
)
100104
}

0 commit comments

Comments
 (0)