Skip to content

Commit 24bc443

Browse files
feat(https): add Caddy HTTPS reverse proxy support
Adds Caddy-based HTTPS configuration for LAN deployments with self-signed certificates. Changes: - types: Add enableHttps, httpsPort, lanIp fields to DockerComposeConfig - defaultConfig: Add default values for HTTPS configuration - generator: Add buildCaddyfile and buildCaddyService functions - Add CaddyfileGeneratorService for Caddyfile generation - Add ComposeGeneratorService for compose file generation - Add IP and port validators for HTTPS configuration - Add utility functions for Caddyfile template - Add HTTPS config panel with Caddyfile preview - Add certificate guide component - Add IP and port input components - ConfigForm: Add HTTPS settings section in full-custom mode - ConfigPreview: Update to conditionally show HTTPS config - slice: Add HTTPS-related state management - validation: Add HTTPS configuration validation - i18n: Add English and Chinese translations - docs: Add HTTPS certificate guide Co-Authored-By: Hagicode <noreply@hagicode.com>
1 parent 58c13f9 commit 24bc443

31 files changed

Lines changed: 1313 additions & 28 deletions

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ A modern Docker Compose configuration generator for Hagicode, built with React +
88
- **Docker Compose YAML Generation**: Automatic YAML file generation based on user input
99
- **Multiple Database Options**: Support for internal PostgreSQL or external database connections
1010
- **API Provider Configuration**: Choose from Anthropic, Zhipu AI, or custom API endpoints
11+
- **LAN HTTPS Support**: Optional Caddy reverse proxy with `tls internal`
1112
- **Volume Management**: Configure volume mounts for data persistence
1213
- **User Permissions**: Linux user permission mapping (PUID/PGID) support
1314
- **Responsive Design**: Works on both desktop and mobile devices
1415
- **Local Storage Persistence**: Configuration saved to localStorage for convenience
1516
- **One-Click Copy/Download**: Copy generated YAML to clipboard or download as file
17+
- **Caddyfile Copy Workflow**: Preview and copy Caddyfile (no file download)
1618
- **SEO Optimized**: Full search engine optimization with meta tags, Open Graph, Twitter Cards, and structured data
1719
- **Multi-language Support**: Internationalization (i18n) with English and Chinese support
1820

@@ -138,6 +140,13 @@ The embedded backup configuration (`src/lib/docker-compose/providerConfigLoader.
138140
- **Root User Warning**: Detection and warning for root-owned directories
139141
- **User Permission Mapping**: PUID/PGID configuration for Linux
140142

143+
#### HTTPS (Full Custom mode only)
144+
- **Enable HTTPS Proxy**: Toggle Caddy reverse proxy generation
145+
- **HTTPS Port**: Default `443`, supports custom ports
146+
- **LAN IP Address**: Used for generated Caddy listener
147+
- **Caddyfile Preview + Copy**: Copy content and save as `Caddyfile` alongside `docker-compose.yml`
148+
- **Guide**: See `docs/https-certificate-guide.md`
149+
141150
## Generated Docker Compose File
142151

143152
The generator creates a complete `docker-compose.yml` file with:

docs/https-certificate-guide.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# HTTPS Certificate Guide
2+
3+
## Overview
4+
5+
The Docker Compose Builder uses Caddy with `tls internal` to auto-generate self-signed certificates for LAN HTTPS.
6+
7+
## How It Works
8+
9+
1. Enable HTTPS in **Full Custom** mode.
10+
2. Configure `HTTPS Port` and `LAN IP`.
11+
3. Download `docker-compose.yml`.
12+
4. Copy Caddyfile content from the preview panel and save it as `Caddyfile` in the same directory.
13+
5. Run:
14+
15+
```bash
16+
docker compose up -d
17+
```
18+
19+
## Certificate Storage
20+
21+
- Caddy stores generated certificates in Docker volume: `caddy_data`
22+
- Internal path: `/data` in the Caddy container
23+
24+
## Browser Trust Steps
25+
26+
### Chrome / Edge
27+
- Open `https://<lan-ip>:<https-port>`
28+
- Click **Advanced**
29+
- Continue to the site and optionally trust certificate in your OS certificate manager
30+
31+
### Firefox
32+
- Open `https://<lan-ip>:<https-port>`
33+
- Click **Advanced**
34+
- Accept the risk and continue
35+
36+
### Safari
37+
- Open `https://<lan-ip>:<https-port>`
38+
- Open certificate details from warning page
39+
- Trust certificate in Keychain when needed
40+
41+
## FAQ
42+
43+
### Why do I still see a warning?
44+
Self-signed certificates are not trusted by default. This is expected for local deployments.
45+
46+
### Where is Caddyfile downloaded?
47+
Caddyfile is **not downloaded**. Use the preview panel's copy action, then save it manually as `Caddyfile`.
48+
49+
### Which browsers are supported?
50+
Latest stable Chrome, Edge, Firefox, and Safari.
51+
52+
### Troubleshooting
53+
54+
- Ensure Caddyfile and docker-compose.yml are in the same directory
55+
- Check if `HTTPS Port` conflicts with other services
56+
- Verify `LAN IP` matches your host network interface
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Auto-generated Caddyfile for LAN HTTPS
2+
{{lanIp}}:{{port}} {
3+
tls internal
4+
5+
handle /health {
6+
respond "OK" 200
7+
}
8+
9+
reverse_proxy {{serviceName}}:{{servicePort}} {
10+
header_up Host {host}
11+
header_up X-Forwarded-For {remote_host}
12+
header_up X-Forwarded-Proto {scheme}
13+
}
14+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { useMemo, useState } from 'react';
2+
import { Button } from '@/components/ui/button';
3+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
4+
import { SyntaxHighlighter } from '@/components/ui/syntax-highlighter';
5+
import { Check, ChevronDown, ChevronUp, Copy } from 'lucide-react';
6+
import { useTheme } from '@/contexts/theme-context';
7+
import { copyToClipboard } from '@/lib/docker-compose/exportUtils';
8+
import { useTranslation } from 'react-i18next';
9+
10+
interface CaddyfilePreviewProps {
11+
content: string;
12+
generatedAt: Date;
13+
serviceCount: number;
14+
}
15+
16+
export function CaddyfilePreview({ content, generatedAt, serviceCount }: CaddyfilePreviewProps) {
17+
const { t } = useTranslation();
18+
const [expanded, setExpanded] = useState(true);
19+
const [copyStatus, setCopyStatus] = useState<'idle' | 'success' | 'error'>('idle');
20+
const { theme } = useTheme();
21+
22+
const lineCount = useMemo(() => content.split('\n').length, [content]);
23+
24+
const handleCopy = async () => {
25+
try {
26+
await copyToClipboard(content);
27+
setCopyStatus('success');
28+
setTimeout(() => setCopyStatus('idle'), 2000);
29+
} catch {
30+
setCopyStatus('error');
31+
setTimeout(() => setCopyStatus('idle'), 2000);
32+
}
33+
};
34+
35+
return (
36+
<div className="rounded-lg border bg-background/60">
37+
<div className="px-3 py-2 border-b flex items-center justify-between gap-2">
38+
<div>
39+
<p className="text-sm font-medium">{t('configForm.caddyPreviewTitle')}</p>
40+
<p className="text-xs text-muted-foreground">
41+
{lineCount} lines · {serviceCount} service · {generatedAt.toLocaleString()}
42+
</p>
43+
</div>
44+
<div className="flex items-center gap-2">
45+
<Button size="sm" variant="secondary" onClick={handleCopy}>
46+
{copyStatus === 'success' ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
47+
<span className="ml-1">
48+
{copyStatus === 'success'
49+
? t('configForm.caddyPreviewCopied')
50+
: copyStatus === 'error'
51+
? t('configForm.caddyPreviewRetryCopy')
52+
: t('configForm.caddyPreviewCopy')}
53+
</span>
54+
</Button>
55+
<Collapsible open={expanded} onOpenChange={setExpanded}>
56+
<CollapsibleTrigger asChild>
57+
<Button size="icon" variant="ghost" className="h-8 w-8">
58+
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
59+
</Button>
60+
</CollapsibleTrigger>
61+
<CollapsibleContent />
62+
</Collapsible>
63+
</div>
64+
</div>
65+
{copyStatus === 'error' && (
66+
<p className="px-3 py-1 text-xs text-red-600 dark:text-red-400">
67+
{t('configForm.caddyPreviewCopyError')}
68+
</p>
69+
)}
70+
{expanded && (
71+
<div className="p-3 max-h-80 overflow-auto">
72+
<SyntaxHighlighter
73+
language="nginx"
74+
darkMode={theme === 'dark'}
75+
showLineNumbers
76+
highlightPattern={/tls internal/}
77+
>
78+
{content}
79+
</SyntaxHighlighter>
80+
<p className="text-xs text-muted-foreground mt-2">
81+
{t('configForm.caddyPreviewTlsHint')}
82+
</p>
83+
</div>
84+
)}
85+
</div>
86+
);
87+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { useState } from 'react';
2+
import { Button } from '@/components/ui/button';
3+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
4+
import { ChevronDown, ChevronUp, ShieldCheck, X } from 'lucide-react';
5+
import { useTranslation } from 'react-i18next';
6+
7+
interface CertificateGuideProps {
8+
title: string;
9+
description: string;
10+
detailsLabel: string;
11+
docsHref: string;
12+
docsLabel: string;
13+
}
14+
15+
export function CertificateGuide({
16+
title,
17+
description,
18+
detailsLabel,
19+
docsHref,
20+
docsLabel,
21+
}: CertificateGuideProps) {
22+
const { t } = useTranslation();
23+
const [open, setOpen] = useState(false);
24+
const [dismissed, setDismissed] = useState(false);
25+
26+
if (dismissed) {
27+
return null;
28+
}
29+
30+
return (
31+
<div className="rounded-lg border bg-muted/30 p-3 space-y-2">
32+
<div className="flex items-start justify-between gap-2">
33+
<div className="flex items-center gap-2">
34+
<ShieldCheck className="w-4 h-4 text-emerald-600" />
35+
<p className="text-sm font-medium">{title}</p>
36+
</div>
37+
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setDismissed(true)}>
38+
<X className="w-4 h-4" />
39+
</Button>
40+
</div>
41+
42+
<p className="text-xs text-muted-foreground">{description}</p>
43+
44+
<Collapsible open={open} onOpenChange={setOpen}>
45+
<CollapsibleTrigger asChild>
46+
<Button variant="ghost" size="sm" className="px-0 h-7 text-xs">
47+
{detailsLabel}
48+
{open ? <ChevronUp className="w-3 h-3 ml-1" /> : <ChevronDown className="w-3 h-3 ml-1" />}
49+
</Button>
50+
</CollapsibleTrigger>
51+
<CollapsibleContent>
52+
<div className="text-xs text-muted-foreground space-y-1 mt-1">
53+
<p>{t('configForm.certificateStep1')}</p>
54+
<p>{t('configForm.certificateStep2')}</p>
55+
<p>{t('configForm.certificateStep3')}</p>
56+
<a className="text-primary underline" href={docsHref} target="_blank" rel="noopener noreferrer">
57+
{docsLabel}
58+
</a>
59+
</div>
60+
</CollapsibleContent>
61+
</Collapsible>
62+
</div>
63+
);
64+
}

src/components/docker-compose/ConfigForm.tsx

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@ import { useSelector, useDispatch } from 'react-redux';
22
import { selectConfig, setConfigField, selectProviders, selectProvidersLoading, selectProvidersError, selectProviderById } from '@/lib/docker-compose/slice';
33
import type { DockerComposeConfig, ConfigProfile, RuntimeProvider } from '@/lib/docker-compose/types';
44
import { REGISTRIES } from '@/lib/docker-compose/types';
5+
import type { RootState } from '@/lib/store';
56
import { Label } from '@/components/ui/label';
67
import { Input } from '@/components/ui/input';
78
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
89
import { Checkbox } from '@/components/ui/checkbox';
910
import { Badge } from '@/components/ui/badge';
1011
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
12+
import { HttpsConfigPanel } from '@/components/docker-compose/HttpsConfigPanel';
1113
import { Settings, Loader2, AlertCircle } from 'lucide-react';
1214
import { useTranslation } from 'react-i18next';
1315
import { NAVIGATION_LINKS } from '@/config/navigationLinks';
14-
import { useEffect, useMemo } from 'react';
16+
import { useCallback, useEffect, useMemo } from 'react';
17+
import { validateConfig } from '@/lib/docker-compose/validation';
1518

1619
export function ConfigForm() {
1720
const { t } = useTranslation();
@@ -21,14 +24,14 @@ export function ConfigForm() {
2124
const providersLoading = useSelector(selectProvidersLoading);
2225
const providersError = useSelector(selectProvidersError);
2326

24-
const updateConfig = <K extends keyof DockerComposeConfig>(field: K, value: DockerComposeConfig[K]) => {
27+
const updateConfig = useCallback(<K extends keyof DockerComposeConfig>(field: K, value: DockerComposeConfig[K]) => {
2528
dispatch(setConfigField({ field, value }));
26-
};
29+
}, [dispatch]);
2730

2831
// Get current provider from state
29-
const currentProvider = useMemo(() => {
30-
return selectProviderById({ dockerCompose: { config, providers, isLoading: false, error: null, providersLoading, providersError } }, config.anthropicApiProvider);
31-
}, [config.anthropicApiProvider, providers, providersLoading, providersError]);
32+
const currentProvider = useSelector((state: RootState) =>
33+
selectProviderById(state, config.anthropicApiProvider)
34+
);
3235

3336
// Initialize provider if not set and providers are loaded
3437
useEffect(() => {
@@ -40,6 +43,11 @@ export function ConfigForm() {
4043
}
4144
}, [providersLoading, providers, config.anthropicApiProvider, updateConfig]);
4245

46+
const validationMap = useMemo(() => {
47+
const entries = validateConfig(config).map((error) => [error.field, error.message] as const);
48+
return Object.fromEntries(entries);
49+
}, [config]);
50+
4351
return (
4452
<div className="space-y-6 p-6 sm:p-8">
4553
{/* Header */}
@@ -219,6 +227,14 @@ export function ConfigForm() {
219227
</div>
220228
</div>
221229

230+
{config.profile === 'full-custom' && (
231+
<HttpsConfigPanel
232+
config={config}
233+
updateConfig={updateConfig}
234+
validationErrors={validationMap}
235+
/>
236+
)}
237+
222238
{/* AI Provider Configuration */}
223239
<div className="space-y-4">
224240
<h3 className="text-lg font-semibold">{t('configForm.aiProviderConfig')}</h3>

src/components/docker-compose/ConfigPreview.tsx

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { useState, useMemo } from 'react';
22
import { useSelector } from 'react-redux';
33
import { selectConfig, selectProviderById } from '@/lib/docker-compose/slice';
44
import { generateYAML } from '@/lib/docker-compose/generator';
5+
import { copyToClipboard, downloadComposeYaml } from '@/lib/docker-compose/exportUtils';
6+
import { validateConfig } from '@/lib/docker-compose/validation';
7+
import type { RootState } from '@/lib/store';
58
import { SyntaxHighlighter } from '@/components/ui/syntax-highlighter';
69
import { Button } from '@/components/ui/button';
710
import { Copy, Download, Check, FileCode } from 'lucide-react';
@@ -16,14 +19,16 @@ export function ConfigPreview() {
1619
const [copied, setCopied] = useState(false);
1720

1821
// Get provider configuration for YAML generation
19-
const providerConfig = useSelector((state: any) => selectProviderById(state, config.anthropicApiProvider));
22+
const providerConfig = useSelector((state: RootState) => selectProviderById(state, config.anthropicApiProvider));
2023

2124
const yaml = useMemo(() => generateYAML(config, providerConfig, i18n.language), [config, providerConfig, i18n.language]);
25+
const validationErrors = useMemo(() => validateConfig(config), [config]);
26+
const exportDisabled = validationErrors.length > 0;
2227
const darkMode = theme === 'dark';
2328

2429
const handleCopy = async () => {
2530
try {
26-
await navigator.clipboard.writeText(yaml);
31+
await copyToClipboard(yaml);
2732
setCopied(true);
2833
toast.success(t('configPreview.yamlCopiedSuccess'));
2934
setTimeout(() => setCopied(false), 2000);
@@ -34,15 +39,11 @@ export function ConfigPreview() {
3439
};
3540

3641
const handleDownload = () => {
37-
const blob = new Blob([yaml], { type: 'text/yaml' });
38-
const url = URL.createObjectURL(blob);
39-
const a = document.createElement('a');
40-
a.href = url;
41-
a.download = 'docker-compose.yml';
42-
document.body.appendChild(a);
43-
a.click();
44-
document.body.removeChild(a);
45-
URL.revokeObjectURL(url);
42+
if (exportDisabled) {
43+
toast.error(t('configPreview.invalidConfigCannotExport'));
44+
return;
45+
}
46+
downloadComposeYaml(yaml);
4647
toast.success(t('configPreview.downloadSuccess'));
4748
};
4849

@@ -63,6 +64,7 @@ export function ConfigPreview() {
6364
size="sm"
6465
variant="secondary"
6566
onClick={handleCopy}
67+
disabled={exportDisabled}
6668
className="flex items-center gap-2 hover:bg-accent transition-colors duration-200"
6769
>
6870
{copied ? (
@@ -77,6 +79,7 @@ export function ConfigPreview() {
7779
<Button
7880
size="sm"
7981
onClick={handleDownload}
82+
disabled={exportDisabled}
8083
className="flex items-center gap-2 hover:opacity-90 transition-opacity duration-200"
8184
>
8285
<Download className="w-4 h-4" />
@@ -117,6 +120,11 @@ export function ConfigPreview() {
117120
<div className="text-xs text-muted-foreground space-y-1 px-1">
118121
<p>{t('configPreview.generatedAt')} {new Date().toLocaleString()}</p>
119122
<p>{t('configPreview.basedOnSettings')}</p>
123+
{exportDisabled && (
124+
<p className="text-amber-600 dark:text-amber-400">
125+
{t('configPreview.invalidConfigCannotExport')}
126+
</p>
127+
)}
120128
</div>
121129
</div>
122130
);

0 commit comments

Comments
 (0)