服务器开放单一外部端口共用ssh和https 使用DNS认证设置免费https证书并手动续期

20230806更新:本文后续更新内容将上传到个人笔记,请访问此处获取最新内容。

背景

由于服务器安全设定,只对外开放一个端口,如何提供ssh连接、https服务?搜索了下可以根据流量特征用sslh简单转发一下数据包到不同的内部端口。

sslh

在root下apt install sslh后修改配置:

# Default options for sslh initscript
# sourced by /etc/init.d/sslh

# binary to use: forked (sslh) or single-thread (sslh-select) version
# systemd users: don't forget to modify /lib/systemd/system/sslh.service
DAEMON=/usr/sbin/sslh
Run=yes
DAEMON_OPTS="--user sslh --listen 0.0.0.0:23456 --ssh 127.0.0.1:8864 --ssl 127.0.0.1:443 --http 127.0.0.1:8081 --pidfile /var/run/sslh/sslh.pid"

ssh的设定开了本地22和8864端口,配置时修改/etc/ssh/sshd_config文件,加一行Port 8864即可。同时记得使用公钥认证登录,禁用密码登录。nginx(1.22版本)的配置如下

nginx配置

user  c01dkit;
worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
    keepalive_timeout  65;
    server_tokens off;
    server {
        listen       8081;
		listen       127.0.0.1:8081;
        charset utf-8;
        server_name  xxxx.c01dkit.com;
		if ($scheme = http ) {
			return 301 https://$host:xxxx$request_uri;	
		}
        error_page  404              /404.html;
    }

    server {
		listen       127.0.0.1:443 ssl ;
        listen       443 ssl ;
		listen       [::]:443 ssl ;
        server_name  xxxx.c01dkit.com;
        charset utf-8;
        ssl_certificate      xxxx/fullchain.pem;
        ssl_certificate_key  xxxx/privkey.pem;

        ssl_session_cache    shared:SSL:1m;
        ssl_session_timeout  5m;

        ssl_ciphers  HIGH:!aNULL:!MD5;
        ssl_prefer_server_ciphers  on;

        location / {
            root   xxxxx;
            index  index.html index.htm;
            error_page  404              /404.html;

        }
        location ~ \.php$ {
            fastcgi_pass   unix:/run/php/php8.1-fpm.sock;
            fastcgi_index  index.php;
            fastcgi_param  SCRIPT_FILENAME  xxxx/www$fastcgi_script_name;
            include        fastcgi_params;
            error_page  404              /404.html;
        }
    }

}

然后systemctl enable sslh、systemctl start sslh启动sslh。这里将本地23456端口收到的流量根据ssh、ssl、http的特征分别进行端口转发。此时接着设置防火墙将所有外部流量转发到23456端口即可。这里假定ssh服务也开在了8864端口,nginx配置https监听443端口、http监听8081端口。

iptables配置

iptables -t nat -A PREROUTING -p tcp --dport 22 -j REDIRECT --to-port 23456

这里假定外部端口开放的端口映射到本地22端口。这里22端口也是有ssh服务在监听。有时担心sslh服务挂掉导致23456没有ssh服务、ssh连不上,设置了定时任务来关掉、打开防火墙(此时只能ssh连接,提供运维窗口期),比如每周三4点到6点只提供22端口的ssh服务:

# Edit this file to introduce tasks to be run by cron.
# 
# Each task to run has to be defined through a single line
# indicating with different fields when the task will be run
# and what command to run for the task
# 
# To define the time you can provide concrete values for
# minute (m), hour (h), day of month (dom), month (mon),
# and day of week (dow) or use '*' in these fields (for 'any').
# 
# Notice that tasks will be started based on the cron's system
# daemon's notion of time and timezones.
# 
# Output of the crontab jobs (including errors) is sent through
# email to the user the crontab file belongs to (unless redirected).
# 
# For example, you can run a backup of all your user accounts
# at 5 a.m every week with:
# 0 5 * * 1 tar -zcf /var/backups/home.tgz /home/
# 
# For more information see the manual pages of crontab(5) and cron(8)
# 
# m h  dom mon dow   command
0 4 * * 3 iptables -t nat -D PREROUTING -p tcp --dport 22 -j REDIRECT --to-port 23456
0 6 * * 3 iptables -t nat -A PREROUTING -p tcp --dport 22 -j REDIRECT --to-port 23456

由于这样设置iptables重启后会失效,所以服务器意外重启的话只不过是恢复到最基础的22端口ssh而已。

关于nginx,可以nginx -V查看编译选项,然后自己从源码编译下。常见的-V输出有:

nginx version: nginx/1.22.1
built by gcc 11.3.0 (Ubuntu 11.3.0-1ubuntu1~22.04) 
built with OpenSSL 3.0.2 15 Mar 2022
TLS SNI support enabled
configure arguments: --user=c01dkit --group=c01dkit --prefix=/usr/share/nginx --conf-path=/etc/nginx/nginx.conf --http-log-path=/var/log/nginx/access.log --error-log-path=/var/log/nginx/error.log --lock-path=/var/lock/nginx.lock --pid-path=/run/nginx.pid --modules-path=/usr/lib/nginx/modules --http-client-body-temp-path=/var/lib/nginx/body --http-fastcgi-temp-path=/var/lib/nginx/fastcgi --http-proxy-temp-path=/var/lib/nginx/proxy --http-scgi-temp-path=/var/lib/nginx/scgi --http-uwsgi-temp-path=/var/lib/nginx/uwsgi --with-compat --with-debug --with-pcre-jit --with-http_ssl_module --with-http_stub_status_module --with-http_realip_module --with-http_auth_request_module --with-http_v2_module --with-http_dav_module --with-http_slice_module --with-threads --with-http_addition_module --with-http_gunzip_module --with-http_gzip_static_module --with-http_sub_module

这里指定user为c01dkit,然后网站也都放在c01dkit的家目录里面,以防网站页面因为权限问题打不开(好像默认是www-data),可能是蟹脚改法○( ^皿^)っ

https免费证书

关于https证书,可以按这里的方法,先snap install --classic certbot安装certbot,(不知道为啥当时设置了一下certbot路径sudo ln -s /snap/bin/certbot /usr/bin/certbot)。如果80端口已经对外开放,可以简单地certbot --nginx自动帮忙认证(即certbot创建认证文件然后在公网访问)。如果80端口不对外开放,可以自选dns认证:certbot certonly --manual --preferred-challenges=dns然后在域名管理那边添加一下记录即可。然后在nginx的conf那里设置好证书,访问就有https认证了!对于http访问,可以用301跳转。

证书90天就会过期,需要续期。使用上述方法续期时,如果直接使用certbot renew会告知必须使用脚本来更新,即新增参数 --manual-auth-hook。认证原理是这样的:renew的时候会生成一个随机的challenge存储在CERTBOT_VALIDATION的全局变量里,需要在 --manual-auth-hook执行的脚本里完成DNS TXT记录的更新,并使脚本返回0。当脚本完成后,certbot会检查DNS是否更新成了新的challenge值来判断是否有域名的解析权。

最直观的方法是certbot renew --manual-auth-hook=auth.sh,然后auth.sh里写上:

echo ${CERTBOT_VALIDATION} >> challenge.txt
sleep 120
exit 0

然后手动用challenge.txt里面的内容在域名服务商那边的dns解析更新下txt记录。等到120秒后会认证成功,重置剩余证书有效时间到90天。

也可以写个用域名服务商提供的API自动化更新的脚本,比如这个(未测试)

需要先 pip install alibabacloud_alidns20150109(这都2023年了咋还是用2015的版本)

# -*- coding: utf-8 -*-
# This file is auto-generated, don't edit it. Thanks.
import sys
import os
import ast
from typing import List

from alibabacloud_alidns20150109.client import Client as Alidns20150109Client
from alibabacloud_tea_openapi import models as open_api_models
from alibabacloud_alidns20150109 import models as alidns_20150109_models
from alibabacloud_tea_util import models as util_models
from alibabacloud_tea_util.client import Client as UtilClient
ACCESSKEYID = 'xxxxx'
ACCESSKEYTOKEN = 'xxxxx'
DOMAIN_NAME = 'xxxxx' # 比如c01kdit.com
CHALLEGNE = os.environ.get('CERTBOT_VALIDATION')

class AliDns:
    @staticmethod
    def create_client(
        access_key_id: str,
        access_key_secret: str,
    ) -> Alidns20150109Client:
        """
        使用AK&SK初始化账号Client
        @param access_key_id:
        @param access_key_secret:
        @return: Client
        @throws Exception
        """
        config = open_api_models.Config(
            # 必填,您的 AccessKey ID,
            access_key_id=access_key_id,
            # 必填,您的 AccessKey Secret,
            access_key_secret=access_key_secret
        )
        # 访问的域名
        config.endpoint = f'alidns.cn-shanghai.aliyuncs.com'
        return Alidns20150109Client(config)
    
    def __init__(self):
        self.client = None
    def update(self, access_key_id, access_key_secret):
        self.client = AliDns.create_client(access_key_id, access_key_secret)

    def get_dns(self):
        if self.client is None:
            print('client is None')
            return
        describe_domain_records_request = alidns_20150109_models.DescribeDomainRecordsRequest(
            domain_name=DOMAIN_NAME,
        )
        runtime = util_models.RuntimeOptions()
        try:
            # 复制代码运行请自行打印 API 的返回值
            response = self.client.describe_domain_records_with_options(describe_domain_records_request, runtime)
            return ast.literal_eval(response.body)
        except Exception as error:
            # 如有需要,请打印 error
            print(error)
            return None
if __name__ == '__main__':
    # Sample.main(sys.argv[1:])
    if CHALLEGNE is None:
        print('CHALLEGNE is None')
        sys.exit(1)
    
    client = AliDns()
    client.update(ACCESSKEYID, ACCESSKEYTOKEN)
    records = client.get_dns()
    if records is None:
        print('records is None')
        sys.exit(1)
    for record in records['DomainRecords']['Record']:
        if record['RR'] == '_acme-challenge':
            update_domain_record_request = alidns_20150109_models.UpdateDomainRecordRequest(
                record_id=record['RecordId'],
                rr=record['RR'],
                type=record['Type'],
                value=CHALLEGNE,
            )
            runtime = util_models.RuntimeOptions()
            try:
                # 复制代码运行请自行打印 API 的返回值
                response = client.client.update_domain_record_with_options(update_domain_record_request, runtime)
                print(response.body)
            except Exception as error:
                # 如有需要,请打印 error
                print(error)
                sys.exit(1)


猜你喜欢

转载自blog.csdn.net/weixin_43483799/article/details/129445390