Tạo chứng chỉ tự ký trên Ubuntu 24.04 đúng cách
Tạo TLS certificate tự ký trên Ubuntu 24.04 để Chrome chấp nhận: lệnh openssl có SAN, cấu hình nginx/Apache và trust đúng cách, không dùng curl -k.
Bạn sẽ xây dựng gì
Một TLS certificate tự ký mà các trình duyệt và client hiện đại thực sự chấp nhận, có subjectAltName chính xác, quyền truy cập key hợp lý, được tích hợp vào nginx hoặc Apache, cùng với phần mà hầu hết hướng dẫn đều bỏ qua: cấu hình để client thực sự trust certificate này, thay vì liên tục bấm bỏ qua cảnh báo và hard-code curl -k vào script. Cuối cùng là một private CA gồm 5 lệnh, dùng khi một dịch vụ nội bộ tăng thành 6 dịch vụ.
Trước hết là quyết định sử dụng công cụ, vì self-signed certificate phù hợp trong ít trường hợp hơn nhiều so với mức độ thường được sử dụng. Nếu dịch vụ có thể truy cập từ public Internet bằng một DNS name thực, hãy dừng đọc và dùng certificate Let's Encrypt miễn phí với certbot trên nginx hoặc cách tương tự trên Apache. Certificate này không mất phí, tự gia hạn và mọi trình duyệt trên thế giới đều đã trust nó. Dùng self-signed certificate trên public site sẽ khiến người dùng hình thành thói quen bỏ qua cảnh báo bảo mật, còn tệ hơn việc dùng HTTP thuần.
Self-signed là lựa chọn phù hợp khi không liên quan đến public Internet: admin panel bind vào địa chỉ WireGuard tunnel trên VPS, staging box trong private network, network traffic giữa các backend, thiết bị home lab, hoặc thay certificate placeholder mà Webmin tự tạo trên port 10000. Let's Encrypt cũng không thể cấp certificate cho 10.8.0.1 hoặc git.internal.lan; không CA công khai nào đưa private IP hoặc TLD tự đặt vào certificate. Với các name đó, bạn chính là CA.
Toàn bộ các bước dưới đây chạy trên một máy Ubuntu 24.04 mới, đi kèm OpenSSL 3.0.x (openssl version để xác nhận). Không bước nào cần internet access; mọi thứ đều hoạt động trong môi trường air-gapped.
Vì sao lệnh một dòng cũ tạo ra certificate bị Chrome từ chối
Lệnh mà mọi tutorial trước năm 2017 đều đưa cho bạn thường có dạng sau:
# Do not run this — shown so you recognise it in old guides
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout selfsigned.key -out selfsigned.crtLệnh này yêu cầu bạn trả lời một loạt câu hỏi tương tác, ghi hostname vào trường Common Name và tạo certificate không có extension subjectAltName. Certificate đó chắc chắn sẽ bị từ chối ngay. Chrome ngừng đọc Common Name từ version 58 vào tháng 4 năm 2017. RFC 2818 đã deprecate việc đối chiếu CN từ năm 2000. Firefox, Safari, curl và Python cũng hoạt động tương tự. Certificate xác định server của nó thông qua extension SAN; nếu không có extension này thì certificate không xác định được server nào. Trình duyệt sẽ báo đúng nguyên văn như sau:
NET::ERR_CERT_COMMON_NAME_INVALID
This server could not prove that it is git.internal.lan; its security
certificate does not specify Subject Alternative Names.Không thể sửa lỗi này bằng cách chỉnh trust store, vì certificate thực sự không chứa tên server nào. Nếu hiện tại bạn đang thấy NET::ERR_CERT_COMMON_NAME_INVALID, certificate của bạn không có SAN hoặc SAN không đúng. Bạn cần tạo certificate mới. May là chỉ cần một lệnh để sửa lỗi này.
Tạo certificate được trình duyệt chấp nhận: một lệnh
OpenSSL bổ sung flag -addext trong phiên bản 1.1.1. Vì vậy, bạn không còn cần các thủ thuật với file cấu hình như trong những hướng dẫn cũ để thêm SAN. Trên Ubuntu 24.04:
sudo openssl req -x509 -newkey rsa:4096 -sha256 -days 730 -noenc \
-keyout /etc/ssl/private/git.internal.key \
-out /etc/ssl/certs/git.internal.crt \
-subj "/CN=git.internal.lan" \
-addext "subjectAltName=DNS:git.internal.lan,IP:10.8.0.1"Tác dụng của từng flag:
-x509tạo trực tiếp certificate tự ký thay vì certificate signing request.-newkey rsa:4096tạo key mới trong cùng bước. RSA 4096 tương thích với mọi client cũ; nếu tất cả client kết nối đều hiện đại,-newkey ec -pkeyopt ec_paramgen_curve:P-256có kích thước nhỏ hơn và nhanh hơn.-noenclà cú pháp của OpenSSL 3.x cho-nodestrước đây: không đặt passphrase cho key. Cả hai cú pháp đều hoạt động. Nếu key có passphrase, nginx sẽ treo ở trạng thái chờ input mỗi lần boot. Vì vậy, với key của server, bạn nên dùng tùy chọn này.-days 730, thời hạn hai năm; xem thêm về con số này trong phần expiry.-subjtrả lời các câu hỏi tương tác ngay trong lệnh. CN hiện chỉ mang tính hình thức, nhưng vẫn đặt nó thành tên chính; một số tool sẽ hiển thị giá trị này.-addext "subjectAltName=..."là flag quan trọng nhất. Liệt kê mọi tên và mọi IP mà client sẽ nhập: các entryDNS:cho hostname (wildcard nhưDNS:*.internal.lanđược phép), các entryIP:cho địa chỉ IP. Nếu người dùng sẽ truy cậphttps://10.8.0.1, entryIP:10.8.0.1phải có mặt; chỉ dùng SAN dạng DNS sẽ khiến họ lại gặp lỗiNET::ERR_CERT_COMMON_NAME_INVALID.
Xác nhận SAN thực sự đã được thêm trước khi cấu hình các thành phần khác:
openssl x509 -in /etc/ssl/certs/git.internal.crt -noout -ext subjectAltNameKết quả đúng:
X509v3 Subject Alternative Name:
DNS:git.internal.lan, IP Address:10.8.0.1Nếu lệnh thay vào đó in ra No extensions in certificate, certificate không có SAN và trình duyệt sẽ từ chối nó. Hãy tạo lại certificate thay vì tiếp tục.
Bảo vệ chặt private key
Một private key mà mọi user trên máy đều có thể đọc thì không còn là private key. Trên Ubuntu, /etc/ssl/private đã là 710 root:ssl-cert, giúp ngăn người dùng xem một cách tùy ý, nhưng hãy đặt quyền cho chính file này một cách rõ ràng:
sudo chown root:root /etc/ssl/private/git.internal.key
sudo chmod 600 /etc/ssl/private/git.internal.keyNginx và Apache đều đọc certificate bằng root trước khi hạ quyền, nên mode 600 của root:root hoạt động với chúng. Nếu key được dùng cho một service chạy bằng user riêng và tự tải key, chẳng hạn ứng dụng Node, Gitea hoặc một daemon Python, chown key đó cho user của service tương ứng, vẫn dùng mode 600. Tuyệt đối không làm những việc sau: đặt mode 644, lưu một bản sao trong git repository hoặc lưu một bản sao trong /tmp.
Tích hợp với nginx
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name git.internal.lan;
ssl_certificate /etc/ssl/certs/git.internal.crt;
ssl_certificate_key /etc/ssl/private/git.internal.key;
location / {
proxy_pass http://127.0.0.1:3000;
}
}sudo nginx -t && sudo systemctl reload nginxnginx -t phải in syntax is ok và test is successful trước khi reload thực hiện bất kỳ thay đổi nào. Nếu lệnh in SSL_CTX_use_PrivateKey_file() failed ... key values mismatch, certificate và key được tạo trong hai lần chạy khác nhau; xem phần các trường hợp lỗi.
Tích hợp vào Apache
sudo a2enmod ssl proxy proxy_httpssl là chưa đủ: virtual host bên dưới dùng ProxyPass, và nếu thiếu mod_proxy cùng mod_proxy_http thì kiểm tra cấu hình sẽ dừng với lỗi Invalid command 'ProxyPass', perhaps misspelled or defined by a module not included in the server configuration. Lưu virtual host vào /etc/apache2/sites-available/git-internal.conf:
<VirtualHost *:443>
ServerName git.internal.lan
SSLEngine on
SSLCertificateFile /etc/ssl/certs/git.internal.crt
SSLCertificateKeyFile /etc/ssl/private/git.internal.key
ProxyPass / http://127.0.0.1:3000/
ProxyPassReverse / http://127.0.0.1:3000/
</VirtualHost>sudo a2ensite git-internal
sudo apache2ctl configtest && sudo systemctl reload apache2configtest phải trả về Syntax OK. Bây giờ hãy kiểm tra từ một máy client:
curl -v https://git.internal.lan/và bạn sẽ nhận được lỗi:
curl: (60) SSL certificate problem: self-signed certificateĐó không phải là bug. Đó là TLS đang hoạt động: curl chưa biết chứng chỉ của bạn và từ chối kết nối với một server mà nó không thể xác thực. Phần tiếp theo là cách xử lý thực sự, và không phải cách mà một nửa Internet đang làm tại thời điểm này.
Cho client tin cậy chứng chỉ và các anti-pattern cần tránh
Trước tiên là những cách sửa sai, được gọi đúng bản chất. curl -k (hoặc --insecure) được ghi cứng trong script, verify=False trong Python requests, NODE_TLS_REJECT_UNAUTHORIZED=0 trong Node — không cách nào trong số này khiến client tin cậy chứng chỉ của bạn. Chúng tắt việc xác minh chứng chỉ. Khi đó, client sẽ kết nối với bất kỳ server nào cung cấp bất kỳ chứng chỉ nào, kể cả chứng chỉ do kẻ tấn công đưa vào đường truyền. Bạn vẫn chịu overhead của TLS nhưng mất cơ chế xác thực vốn là mục đích của TLS. Tệ hơn, các flag này dễ lan rộng: được dán vào một cron job, rồi một deploy script, sau đó là code production, cho đến khi không ai còn nhớ kết nối nào ban đầu chỉ cần tạm thời. Nếu một verify=False vẫn còn sau phiên debug đã tạo ra nó, thiết kế đó là sai.
Cách sửa đúng là cấu hình cho hệ điều hành của từng client để tin cậy chứng chỉ này như một root được tin cậy. Trên client Ubuntu và Debian:
sudo cp git.internal.crt /usr/local/share/ca-certificates/git.internal.crt
sudo update-ca-certificatesDòng cần chú ý trong output (sau đó là một block Running hooks in /etc/ca-certificates/update.d...):
Updating certificates in /etc/ssl/certs...
1 added, 0 removed; done.Có 2 điểm dễ sai trong các dòng này. File phải kết thúc bằng .crt. Phần mở rộng .pem sẽ bị bỏ qua mà không có thông báo lỗi, và bạn nhận được 0 added. Nội dung phải ở định dạng PEM; file bắt đầu bằng -----BEGIN CERTIFICATE-----. Nếu chứng chỉ đang ở dạng binary DER, trước tiên hãy chuyển đổi bằng openssl x509 -inform der -in file.der -out file.crt. Thêm chính chứng chỉ tự ký làm root có tác dụng vì chứng chỉ tự ký chính là root của nó.
Sau đó, curl, wget, git, apt và mọi client khác dùng OpenSSL với system bundle sẽ tin cậy server mà không cần flag nào. Một số client có trust store riêng và cần xử lý riêng:
- Chrome/Chromium trên Linux đọc cơ sở dữ liệu NSS, không đọc system store:
sudo apt install libnss3-tools, sau đó chạycertutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n "git.internal" -i git.internal.crtcho từng user. - Firefox có store riêng: vào Settings → Privacy & Security → Certificates → Import, hoặc đặt
security.enterprise_roots.enabledthànhtruetrongabout:configđể Firefox đọc system store. - Python requests đi kèm CA bundle riêng (certifi) và bỏ qua system store: truyền
verify="/usr/local/share/ca-certificates/git.internal.crt"hoặc exportREQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt. - Node.js: export
NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/git.internal.crt.
Trên client Windows, nhấp đúp vào .crt rồi cài vào Trusted Root Certification Authorities. Trên macOS, thêm chứng chỉ vào System keychain trong Keychain Access và đặt thành Always Trust.
Một root cho nhiều dịch vụ: CA riêng nhỏ
Việc trust từng certificate nhanh chóng không thể mở rộng: sáu dịch vụ nhân với bốn máy client là hai mươi bốn lần cài trust, và mỗi dịch vụ mới lại tăng thêm. Cách xử lý là dùng một CA riêng. Client chỉ cần trust một root, sau đó bạn ký certificate của từng dịch vụ bằng root đó.
Lựa chọn thuận tiện là mkcert. Gói này có trong repo của Ubuntu 24.04 và xử lý cả các NSS store của Chrome và Firefox, vốn bị update-ca-certificates bỏ qua:
sudo apt install -y mkcert libnss3-tools
mkcert -install
mkcert git.internal.lan "*.internal.lan" 10.8.0.1mkcert -install tạo một root và đăng ký root đó vào mọi trust store trên máy hiện tại. Lệnh thứ ba tạo ra git.internal.lan+2.pem và git.internal.lan+2-key.pem, có thể đưa trực tiếp vào các đoạn cấu hình nginx hoặc Apache ở trên. Thiết kế của công cụ này giả định bạn đang dùng máy phát triển. Root key nằm trên máy đã chạy -install. Vì vậy, công cụ này rất phù hợp với laptop phát triển nhưng không phù hợp với một cụm server.
Với server, OpenSSL thuần có thể thực hiện toàn bộ quy trình CA chỉ bằng năm lệnh:
openssl genrsa -out lab-ca.key 4096
openssl req -x509 -new -key lab-ca.key -sha256 -days 3650 \
-out lab-ca.crt -subj "/CN=Lab Internal CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
openssl genrsa -out git.key 2048
openssl req -new -key git.key -out git.csr -subj "/CN=git.internal.lan" \
-addext "subjectAltName=DNS:git.internal.lan,IP:10.8.0.1"
openssl x509 -req -in git.csr -CA lab-ca.crt -CAkey lab-ca.key \
-CAcreateserial -days 730 -sha256 -copy_extensions copy -out git.crtĐiểm dễ mắc lỗi nằm ở lệnh cuối: theo mặc định, openssl x509 -req loại bỏ toàn bộ extension khỏi CSR, bao gồm SAN mà bạn đã cẩn thận thêm vào. -copy_extensions copy là một tùy chọn của OpenSSL 3.x nên hoạt động trên 24.04; tùy chọn này giữ lại các extension. Nếu bỏ qua, certificate được ký sẽ không có SAN và Chrome lại hiển thị NET::ERR_CERT_COMMON_NAME_INVALID. Xác minh bằng cùng kiểm tra openssl x509 -noout -ext subjectAltName như trước.
Phân phối lab-ca.crt đến client bằng các bước cài vào trust store ở trên, mỗi máy chỉ cần thực hiện một lần. Bảo vệ lab-ca.key như một tài sản cực kỳ quan trọng: đặt mode 600, và tốt nhất lưu trên một máy không phải là một trong các server mà nó ký certificate. Người nào giữ được file này có thể tạo certificate cho bất kỳ hostname nào mà client của bạn tin tưởng.
Hạn sử dụng và xoay vòng
Thời hạn của chứng chỉ CA public đang bị rút ngắn nhanh chóng. CA/Browser Forum đã giới hạn chứng chỉ được cấp mới bởi các CA được tin cậy công khai ở mức 200 ngày vào tháng 3 năm 2026, giảm từ 398 ngày, rồi giảm tiếp xuống 100 ngày vào năm 2027 và 47 ngày vào tháng 3 năm 2029. Tuy nhiên, các quy định này chỉ áp dụng cho CA được tin cậy công khai. Private CA của bạn không chịu các quy định đó, và trình duyệt cũng không thực thi chúng đối với các root được cài đặt thủ công. Có một giới hạn thực tế vẫn áp dụng: các nền tảng Apple từ chối mọi chứng chỉ TLS của server có thời hạn dài hơn 825 ngày, bất kể chứng chỉ do ai cấp. Vì vậy, nếu iPhone hoặc Mac sẽ kết nối, hãy giữ chứng chỉ leaf ở mức không quá hai năm. -days 730 đáp ứng giới hạn đó trên mọi nền tảng; root có thời hạn mười năm cùng các chứng chỉ leaf có thời hạn hai năm là mô hình nội bộ phù hợp.
Chứng chỉ có thời hạn dài chỉ hỏng theo một cách: âm thầm hết hạn cùng lúc vào một ngày mà không ai nhớ đã chọn. Hãy kiểm tra các chứng chỉ hiện có:
openssl x509 -in /etc/ssl/certs/git.internal.crt -noout -enddateHãy đưa việc gia hạn vào lịch thực tế, hoặc dùng cron để nhắc trước 30 ngày. openssl x509 -checkend 2592000 -in cert.crt trả về mã khác 0 khi thời điểm hết hạn còn trong số giây đó. Nếu bạn đã chạy Uptime Kuma để giám sát trạng thái, các HTTPS monitor của Uptime Kuma sẽ miễn phí cảnh báo khi chứng chỉ sắp hết hạn.
Xoay vòng với private CA khá đơn giản: chạy lại các lệnh tạo CSR và ký, thay các file, rồi reload web server. Root không thay đổi, nên không client nào nhận thấy.
Các dạng lỗi và chuỗi bạn sẽ thấy
NET::ERR_CERT_AUTHORITY_INVALID, đây là trạng thái expected trước khi bạn cài trust, không phải lỗi của certificate. Nếu lỗi vẫn còn sau khi bạn đã cài root: trên Linux, Chrome đọc NSS thay vì system store (xem bước certutil); hoặc file đã copy không kết thúc bằng .crt và update-ca-certificates trả về 0 added; hoặc server đang gửi một certificate khác với certificate bạn đã trust, hãy so sánh fingerprint bằng openssl s_client -connect git.internal.lan:443 </dev/null 2>/dev/null | openssl x509 -noout -fingerprint -sha256.
NET::ERR_CERT_COMMON_NAME_INVALID, certificate không có SAN hoặc SAN không bao phủ tên trong thanh địa chỉ. Trường hợp điển hình: SAN liệt kê DNS:git.internal.lan nhưng người dùng truy cập https://10.8.0.1. Thay đổi trust store không sửa được lỗi này; hãy cấp lại certificate với entry còn thiếu.
curl: (60) SSL certificate problem: self-signed certificate, curl không trust certificate. Biến thể self-signed certificate in certificate chain cũng có nghĩa tương tự đối với certificate được ký bởi private CA của bạn. Cách sửa tạm thời: curl --cacert lab-ca.crt https://...; cách sửa lâu dài: trust store. Không dùng -k.
unable to load certificate ... Expecting: TRUSTED CERTIFICATE (hoặc Expecting: CERTIFICATE REQUEST, hoặc no start line), PEM bị nhầm loại. Bạn đã đưa cho OpenSSL sai loại file: key hoặc CSR trong khi OpenSSL cần certificate, hoặc file nhị phân DER trong khi OpenSSL cần PEM. head -1 filename cho biết file thực tế bạn đang có; certificate bắt đầu bằng -----BEGIN CERTIFICATE-----. Với DER, hãy chuyển đổi bằng openssl x509 -inform der -in file.der -out file.crt.
nginx: [emerg] SSL_CTX_use_PrivateKey_file(...) failed (SSL: error ... key values mismatch), certificate và key không thuộc cùng một cặp, thường vì lệnh tạo đã được chạy hai lần và các file bị trộn. Xác nhận bằng cách so sánh openssl x509 -in git.internal.crt -noout -pubkey | sha256sum với openssl pkey -in git.internal.key -pubout | sha256sum; hash trùng nhau nghĩa là một cặp khớp. Nếu khác nhau, hãy tạo lại cả hai cùng lúc.
FAQ
Tại sao Chrome vẫn báo "Không an toàn" sau khi tôi tạo self-signed certificate?
Nếu lỗi là NET::ERR_CERT_AUTHORITY_INVALID, certificate không có vấn đề; Chrome chỉ chưa có lý do để tin cậy nó. Hãy cài certificate đó (hoặc private CA root) vào trust store của client. Lưu ý rằng trên Linux, Chrome dùng cơ sở dữ liệu NSS thông qua certutil, không dùng system store. Nếu lỗi là NET::ERR_CERT_COMMON_NAME_INVALID, certificate thiếu Subject Alternative Name khớp với URL và phải được cấp lại bằng -addext "subjectAltName=...".
Làm cách nào để curl tin cậy self-signed certificate mà không dùng -k?
Sao chép certificate (định dạng PEM, phần mở rộng .crt) vào /usr/local/share/ca-certificates/ rồi chạy sudo update-ca-certificates. Kết quả phải hiển thị 1 added. Từ đó, curl sẽ xác minh certificate này như mọi public certificate khác. Với một request chạy một lần mà không thay đổi hệ thống, curl --cacert /path/to/cert.crt sẽ chỉ xác minh dựa trên file đó; -k tắt hoàn toàn việc xác minh và không được dùng trong script.
Self-signed certificate có thể có hiệu lực trong bao lâu?
Về mặt kỹ thuật, thời hạn có thể tùy ý. Các giới hạn của CA/Browser Forum (hiện là 200 ngày, giảm còn 47 ngày vào năm 2029) áp dụng cho CA được tin cậy công khai, không áp dụng cho private trust. Trên thực tế, hãy giới hạn server certificate ở 825 ngày, vì thiết bị Apple sẽ từ chối certificate dài hơn bất kể issuer là ai. Một private root có thời hạn 10 năm cùng các leaf certificate thời hạn 2 năm (-days 730) là mức mặc định hợp lý. Hãy lên lịch gia hạn, vì một internal certificate hết hạn có thể âm thầm làm mọi thứ ngừng hoạt động vào một ngày không ai nhớ.
Tôi nên dùng self-signed certificate hay Let's Encrypt?
Nếu service có public DNS name và có thể truy cập từ Internet, hãy luôn dùng Let's Encrypt: miễn phí, tự động và đã được mọi client tin cậy. Self-signed (hoặc private CA) dành cho những trường hợp Let's Encrypt không thể cấp certificate: private IP, hostname chỉ dùng nội bộ như .lan, mạng air-gapped và các service được chủ động ẩn sau VPN. Quyết định này phụ thuộc vào khả năng truy cập và cách đặt tên, không phải độ mạnh bảo mật; cryptography là như nhau.
Tại sao certificate của tôi vẫn bị từ chối sau khi thêm vào /usr/local/share/ca-certificates?
Hãy kiểm tra 3 yếu tố. File phải kết thúc bằng .crt. File có phần mở rộng .pem sẽ bị bỏ qua mà không báo lỗi, và update-ca-certificates sẽ báo 0 added. Nội dung phải là văn bản PEM bắt đầu bằng -----BEGIN CERTIFICATE-----, không phải binary DER. Ngoài ra, application phải thực sự dùng system store. Chrome trên Linux, Firefox, Python requests, Node.js và Java đều có trust store riêng, nên cần thêm certificate riêng cho từng application.