view src/luan/host/Https.luan @ 2134:de3107eb911f

improve previous
author Violet7
date Fri, 16 Jan 2026 17:59:40 -0800
parents c3b4c19f2d8a
children
line wrap: on
line source

local Luan = require "luan:Luan.luan"
local error = Luan.error
local new_error = Luan.new_error or error()
local load_file = Luan.load_file or error()
local pairs = Luan.pairs or error()
local ipairs = Luan.ipairs or error()
local Io = require "luan:Io.luan"
local ip = Io.ip or error()
local uri = Io.uri or error()
local String = require "luan:String.luan"
local starts_with = String.starts_with or error()
local Thread = require "luan:Thread.luan"
local try_synchronized = Thread.try_synchronized or error()
local Time = require "luan:Time.luan"
local time_now = Time.now or error()
local Http = require "luan:http/Http.luan"
local Logging = require "luan:logging/Logging.luan"
local logger = Logging.logger "Https"
local sys_logging = require "classpath:luan/host/sys_logging.luan"
local sys_logger = sys_logging "sys-Https"


local Https = {}

local my_ips = Io.my_ips()

local function get_files(domain,site_dir)
	local ssl_files_dir = site_dir.child("ssl/")
	ssl_files_dir.mkdir()

	return {
		ssl_files_dir = ssl_files_dir
		nginx_file = ssl_files_dir.child("nginx.ssl.conf")
		key_file = ssl_files_dir.child(domain..".key")
		local_cer_file = ssl_files_dir.child("fullchain.cer")
		csr_file = ssl_files_dir.child(domain..".csr")
		tmp_cert_out = ssl_files_dir.child(domain..".crt.tmp")
		acme_challenges = ssl_files_dir.child("acme-challenge/")
	}
end

-- for testing
Https.get_files = get_files

local function do_delete_junk(file,canonicals)
	if canonicals[file.canonical().to_string()] == nil then
		sys_logger.info("delete "..file.to_string())
		file.delete()
	elseif file.is_directory() then
		for _, child in ipairs(file.children()) do
			do_delete_junk(child,canonicals)
		end
	end
end

local function delete_junk(domain,site_dir)
	local files = get_files(domain,site_dir)
	files.info_luan = site_dir.child("info.luan")
	local canonicals = {}
	for _, file in pairs(files) do
		canonicals[file.canonical().to_string()] = true
	end
	for _, child in ipairs(site_dir.children()) do
		if child.name() == "site" then
			continue
		end
		do_delete_junk(child,canonicals)
	end
end

-- for testing
Https.delete_junk = delete_junk

local function nginx_ssl_conf(domain,files,luanhost_dir)
%>
	server {
		server_name <%=domain%>;
		listen 80;
		listen [::]:80;

		location / {
			return 301 https://$http_host$request_uri;
		}

		location /.well-known/acme-challenge/ {
			alias <%= files.acme_challenges.to_string() %>/;
			try_files $uri $uri/ =404;
		}
	}

	server {
		server_name <%=domain%>;
		listen 443 ssl;
		listen [::]:443 ssl;

		if ($host != $server_name) {
			return 301 http://$http_host$request_uri;
		}

		ssl_certificate <%= files.local_cer_file.to_string() %>;
		ssl_certificate_key <%= files.key_file.to_string() %>;
		# path is relative to the dir of the master nginx conf
		include <%=luanhost_dir.canonical().to_string()%>/local/luanhost.default.conf;
	}
<%
end

local function reload_nginx(luanhost_dir_str)
	local cmd = "sudo nginx -t && sudo nginx -s reload;"
	local s = uri("bash:"..cmd).read_text()
	logger.info("reload_nginx "..s)
end

local function issue_cert(files, luanhost_dir, dry_run)
	local luanhost_dir_str = luanhost_dir.canonical().to_string()
	local csr_file_str = files.csr_file.canonical().to_string()

	-- Finally, get our cert from letsencrypt.
	local cmd = luanhost_dir_str..[[/acme_tiny \
		--account-key ]]..luanhost_dir_str..[[/local/tiny_account.key \
		--csr ]]..csr_file_str..[[ \
		--acme-dir ]]..files.acme_challenges.canonical().to_string()..[[ \
	]]

	-- Problems here are probably from letsencrypt
	-- leaving this comment here in case its not
	if dry_run then
		local dry_run_dir_url = "https://acme-staging-v02.api.letsencrypt.org/directory"
		cmd = cmd.." --directory-url "..dry_run_dir_url
	end

	local tmp_out_str = files.tmp_cert_out.canonical().to_string()
	cmd = cmd.." > "..tmp_out_str
	logger.info("acme-tiny commandline:\n"..cmd)

	local s = uri("bash:"..cmd).read_text()
	logger.info("get cert signed by letsencrypt\n"..s)

	-- Empty stdout from acme-tiny is a failure.
	if files.tmp_cert_out.length() == 0 then
		logger.error("FAILED getting cert from letsencrypt.\nSee previous output.\nNot writing to fullchain.cer")
		error("FAILED getting cert from letsencrypt.\nSee log output.\nNot writing to fullchain.cer")
	else
		-- Success! Move the temp output to the real fullchain.
		local local_cer_file_str = files.local_cer_file.canonical().to_string()
		if files.local_cer_file.exists() then
			local cmd = "mv "..local_cer_file_str.." "..local_cer_file_str..".old"
			local s = uri("bash:"..cmd).read_text()
			logger.info("moving old fullchain to fullchain.cer.old\n"..s)
		end

		local cmd = "mv "..tmp_out_str.." "..local_cer_file_str
		local s = uri("bash:"..cmd).read_text()
		logger.info("move temp output to fullchain.cer\n"..s)
	end

	reload_nginx(luanhost_dir_str)
end


local function renew_ssl(files,renewal_period,luanhost_dir,dry_run)
	files.csr_file.exists() or error "no CSR file, assuming local https cert"
	if time_now() - files.local_cer_file.last_modified() > renewal_period then
		issue_cert(files, luanhost_dir, dry_run)
		return
	end
end
Https.renew_ssl = renew_ssl

local ssl_renewal_period = Time.period{days=30}

function Https.update(domain,site_dir,luanhost_dir)
	local files = get_files(domain,site_dir)
	if files.nginx_file.exists() then
		-- sys_logger.info("update "..domain)
		local nginx = ` nginx_ssl_conf(domain,files,luanhost_dir) `
		files.nginx_file.write(nginx)
	end
	delete_junk(domain,site_dir)
end

local function do_set_https(is_https,domain,site_dir,luanhost_dir,dry_run)
	local files = get_files(domain,site_dir)

	-- luan/host
	local luanhost_file = "file:"..luanhost_dir.to_string().."/"
	local luanhost_dir_str = luanhost_dir.canonical().to_string()

	if is_https then -- https
		local domain_ip = ip(domain)
		local is_local = domain_ip == "127.0.0.1"
		if not files.key_file.exists() \
			or not files.local_cer_file.exists() or files.local_cer_file.length()==0 \
			or not files.nginx_file.exists() \
		then
			logger.info("is_local "..is_local)

			-- Use openssl directly to make a self-signed cert,
			-- no external cert authority involved
			if is_local then
				local cmd = `%>
					openssl req -x509 -newkey rsa:2048 -nodes \
						-keyout <%= files.key_file.to_string() %> \
						-out <%= files.local_cer_file.to_string() %> -days 365 \
						-subj "/CN=<%=domain%>" \
						-addext "subjectAltName=DNS:<%=domain%>,IP:127.0.0.1"
				<%`
				sys_logger.info("local ssl commandline:\n"..cmd)
				local s = uri("bash:"..cmd).read_text()
				logger.info("issue local certificate")
			else
				if my_ips[domain_ip] ~= true then
					logger.error("the domain "..domain.." doesn't map to this machine")
					return
				end
				try
					-- uncomment for testing
					-- dry_run = true

					-- make the challenge dir. note that this is
					-- directly under sites/DOMAIN, and *not* under
					-- sites/DOMAIN/site.
					files.acme_challenges.mkdir()

					-- Create a domain key to sign the certificate signing request (csr).
					local key_file_str = files.key_file.canonical().to_string()
					local cmd = "openssl genrsa 4096 > "..key_file_str
					local s = uri("bash:"..cmd).read_text()
					logger.info("create domain key\n"..s)

					-- Create the csr.
					local csr_file_str = files.csr_file.canonical().to_string()
					local cmd = 'openssl req -new -sha256 -key '..key_file_str..' -subj "/CN='..domain..'" > '..csr_file_str
					local s = uri("bash:"..cmd).read_text()
					logger.info("create csr\n"..s)

					issue_cert(files, luanhost_dir, dry_run)


				catch e
					logger.error("Error setting up ACME: "..e)
				end_try

			end
				-- We now have our certificate!
				-- Now we just need to generate the nginx config
				-- that uses it, place it in luan/host/sites/DOMAIN/ssl/nginx.ssl.conf
				-- and tell luanhost to reload nginx.

			if files.key_file.exists() and files.local_cer_file.exists() and files.local_cer_file.length() > 0 then
				-- the nginx config only requires 2 files:
				-- fullchain.cer and DOMAIN.key
				logger.info("writing nginx conf to "..files.nginx_file.canonical().to_string())
				local nginx = ` nginx_ssl_conf(domain,files,luanhost_dir) `
				files.nginx_file.write(nginx)
				reload_nginx(luanhost_dir_str)
			end
		else
			if not is_local then
				renew_ssl(files,ssl_renewal_period,luanhost_dir,dry_run)
			end
		end
		if not is_local then
			local function fn()
				renew_ssl(files,ssl_renewal_period,luanhost_dir,dry_run)
			end
			Thread.schedule(fn,{repeating_delay=ssl_renewal_period})
		end
	else -- http
		if files.key_file.exists() or files.nginx_file.exists() then
			for _, file in pairs(files) do
				file.delete()
			end
			reload_nginx(luanhost_dir_str)
		end
	end
	--logger.info "done"
end
Https.do_set_https = do_set_https -- for testing

function Https.set_https(is_https)
	if Http.did_init() then
		logger.error(new_error("set_https called outside of init.luan"))
		return
	end
	local domain = Http.domain
	local site_dir = uri("site:").parent()
	local luanhost_dir = uri("file:.")

	-- use for testing, so as to not hit rate limits
	-- on the real letsencrypt servers
	local dry_run = false

	if not try_synchronized( function()
		do_set_https(is_https,domain,site_dir,luanhost_dir,dry_run)
	end, domain..".lock", 0 )() then
		logger.info("set_https already running for "..domain..", skipping")
	end
end

return Https