批量设置域名 dkim 脚本 - iRedMail - dkim_setup.py - ptyhon - debian

环境:debian 12.10 ,iRedMail - 1.7.3
目标:批量配置域名 dkim

步骤:

  1. 将 dkim_setup.py 及 domains.txt 上传到邮局服务器文件系统里,如 /root
  2. python3 dkim_setup.py # 执行 dkim_setup.py 脚本。脚本会从 domains.txt (可自行创建,每行一个域名) 里逐行读取邮件域名,创建 dkim 文件、将 相应的域名和 key 追加到 /etc/amavis/conf.d/50-user 文件内容中。脚本每次执行前,会对当前的 /etc/amavis/conf.d/50-user 复制备份,预防出错后需要还原使用,最后脚本会将域名及对应的解析值追加到 dkim_dns_records.json ,方便您解析域名
  3. amavisd testkey # 如需验证 dkim 的查询结果,可执行此命令

如需手工操作:

  1. amavisd-new genrsa /var/lib/dkim/iredmail.demo.anqun.org.pem 2048 # 为邮件域名 iredmail.demo.anqun.org 生成数字签名文件
  2. chown amavis:amavis amavisd genrsa /var/lib/dkim/iredmail.demo.anqun.org.pem # 更改签名文件属主
  3. chmod 0400 /var/lib/dkim/iredmail.demo.anqun.org.pem # 更改签名权限
  4. 编辑 /etc/amavis/conf.d/50-user 文件内容,将签名的邮件域名和文件等追加进去

    # Add dkim_key here.
    dkim_key('iredmail.demo.anqun.org', 'dkim', '/var/lib/dkim/iredmail.demo.anqun.org.pem');
    
    @dkim_signature_options_bysender_maps = ({
     # 'd' defaults to a domain of an author/sender address,
     # 's' defaults to whatever selector is offered by a matching key
    
     # Per-domain dkim key
     #"domain.com"  => { d => "domain.com", a => 'rsa-sha256', ttl => 10*24*3600 },
    
     # catch-all (one dkim key for all domains)
     '.' => {d => 'iredmail.demo.anqun.org',
             a => 'rsa-sha256',
             c => 'relaxed/simple',
             ttl => 30*24*3600 },
    });

dkim_setup.py 的文件内容:

#!/usr/bin/env python3

import subprocess
import os
import datetime
import json

AMAVIS_CONFIG_FILE = "/etc/amavis/conf.d/50-user"
DKIM_BASE_PATH = "/var/lib/dkim"
DKIM_KEY_SIZE = 2048  # Or 2048, if desired
AMAVIS_COMMAND = "amavisd"  # Changed from amavisd-new
DNS_OUTPUT_FILE = "dkim_dns_records.json"

def generate_dkim_key(domain):
    """Generates a DKIM key for the given domain."""
    key_path = os.path.join(DKIM_BASE_PATH, f"{domain}.pem")
    command = [AMAVIS_COMMAND, "genrsa", key_path, str(DKIM_KEY_SIZE)]
    try:
        subprocess.run(command, check=True)
        subprocess.run(["chown", "amavis:amavis", key_path], check=True)
        subprocess.run(["chmod", "0400", key_path], check=True)
        print(f"DKIM key generated for {domain} at {key_path}")
        return key_path
    except subprocess.CalledProcessError as e:
        print(f"Error generating DKIM key for {domain}: {e}")
        return None

def get_dkim_public_key(key_path):
    """Extracts the DKIM public key from the .pem file using openssl."""
    try:
        command = ["openssl", "rsa", "-in", key_path, "-pubout", "-outform", "PEM"]
        process = subprocess.run(command, capture_output=True, text=True, check=True)
        public_key = process.stdout.strip()

        # Remove the BEGIN and END PUBLIC KEY lines and any newlines
        public_key = public_key.replace("-----BEGIN PUBLIC KEY-----", "")
        public_key = public_key.replace("-----END PUBLIC KEY-----", "")
        public_key = public_key.replace("\n", "")

        return public_key
    except subprocess.CalledProcessError as e:
        print(f"Error extracting DKIM public key using openssl: {e}")
        print(f"Stderr: {e.stderr}")  # Print stderr for more details
        return None
    except Exception as e:
        print(f"Error extracting DKIM public key: {e}")
        return None


def domain_exists(domain):
    """Checks if the domain is already configured in Amavis."""
    try:
        with open(AMAVIS_CONFIG_FILE, "r") as f:
            config_content = f.read()
        return domain in config_content
    except FileNotFoundError:
        print(f"Error: Amavis config file not found at {AMAVIS_CONFIG_FILE}")
        return False

def backup_amavis_config():
    """Backs up the Amavis configuration file with a timestamp."""
    timestamp = datetime.datetime.now().strftime("%Y.%m.%d.%H.%M.%S")
    backup_file = f"{AMAVIS_CONFIG_FILE}.{timestamp}"
    try:
        subprocess.run(["cp", AMAVIS_CONFIG_FILE, backup_file], check=True)
        print(f"Amavis config backed up to {backup_file}")
        return True
    except subprocess.CalledProcessError as e:
        print(f"Error backing up Amavis config: {e}")
        return False

def update_amavis_config(domain, key_path):
    """Updates the Amavis configuration file with the DKIM settings."""
    try:
        with open(AMAVIS_CONFIG_FILE, "r") as f:
            config_lines = f.readlines()

        dkim_key_insert_point = None
        start_index = None
        end_index = None
        last_entry_index = None

        for i, line in enumerate(config_lines):
            if "dkim_key(" in line:
                dkim_key_insert_point = i + 1

            if "@dkim_signature_options_bysender_maps = (" in line:
                start_index = i

            if start_index is not None and "=>" in line:
                # 检查是否是一个 domain entry 的开始
                if line.lstrip().startswith('"') or line.lstrip().startswith("'"):
                    last_entry_index = i

            if "});" in line and start_index is not None:
                end_index = i
                break

        if dkim_key_insert_point is None:
            print("Could not find insertion point for dkim_key.")
            return False

        if start_index is None or end_index is None:
            print("Could not find signature options structure.")
            return False

        # 如果没有找到任何已有的 domain entry,则默认插入在 start_index + 1
        insert_sig_index = last_entry_index + 1 if last_entry_index is not None else start_index + 1

        # 构造新行
        new_dkim_key_line = f"dkim_key('{domain}', 'dkim', '{key_path}');\n"
        new_dkim_sig_line = f'    "{domain}" => {{ d => "{domain}", a => \'rsa-sha256\', ttl => 10*24*3600 }},\n'

        # 插入 dkim_key
        config_lines.insert(dkim_key_insert_point, new_dkim_key_line)

        # 如果不是第一个条目,检查前一行是否有逗号
        if last_entry_index is not None:
            prev_line = config_lines[insert_sig_index - 1]
            if not prev_line.strip().endswith(','):
                config_lines[insert_sig_index - 1] = prev_line.rstrip('\n') + ',\n'

        # 插入新的签名选项
        config_lines.insert(insert_sig_index, new_dkim_sig_line)

        # 写回文件
        with open(AMAVIS_CONFIG_FILE, "w") as f:
            f.writelines(config_lines)

        print(f"Amavis config updated for {domain}")
        return True

    except FileNotFoundError:
        print(f"Error: Amavis config file not found at {AMAVIS_CONFIG_FILE}")
        return False
    except Exception as e:
        print(f"Error updating Amavis config: {e}")
        return False

def restart_amavis():
    """Restarts the Amavis service."""
    try:
        subprocess.run(["systemctl", "restart", "amavis"], check=True)  # Corrected command
        print("Amavis service restarted.")
        return True
    except subprocess.CalledProcessError as e:
        print(f"Error restarting Amavis: {e}")
        return False

def write_dns_record(domain, public_key):
    """Writes the DNS record to a JSON file."""
    dns_record_name = f"dkim._domainkey.{domain}"
    # Construct the TXT record value
    txt_record_value = f"v=DKIM1; p={public_key}"
    data = {dns_record_name: txt_record_value}

    try:
        # Check if the file exists and load existing data
        if os.path.exists(DNS_OUTPUT_FILE):
            with open(DNS_OUTPUT_FILE, "r") as f:
                try:
                    existing_data = json.load(f)
                except json.JSONDecodeError:
                    existing_data = {}  # Handle empty or corrupted JSON file
        else:
            existing_data = {}

        # Update with the new record
        existing_data.update(data)

        # Write back to the file
        with open(DNS_OUTPUT_FILE, "w") as f:
            json.dump(existing_data, f, indent=4, ensure_ascii=False)  # indent for readability, disable ASCII escaping

        print(f"DNS record written to {DNS_OUTPUT_FILE}")

    except Exception as e:
        print(f"Error writing DNS record to file: {e}")

if __name__ == "__main__":
    import sys

    # Backup Amavis config *before* processing any domains
    if not backup_amavis_config():
        print("Failed to backup Amavis config.  Aborting.")
        sys.exit(1)

    try:
        with open("domains.txt", "r") as f:
            domains = [line.strip() for line in f.readlines()]
    except FileNotFoundError:
        print("Error: domains.txt not found")
        sys.exit(1)


    for domain in domains:
        print(f"Processing domain: {domain}")

        if domain_exists(domain):
            print(f"Domain {domain} already exists in Amavis config. Skipping.")
            continue



        key_path = generate_dkim_key(domain)
        if key_path:
            if update_amavis_config(domain, key_path):
                public_key = get_dkim_public_key(key_path)
                if public_key:
                    write_dns_record(domain, public_key)
                else:
                    print(f"Failed to get public key for {domain}")
            else:
                print(f"Failed to update amavis config for {domain}")

    if domains: #Only restart if there was at least one domain to process
        restart_amavis()

演示视频:https://www.bilibili.com/video/BV1Jj7fztEN3/

参考:

为 inbucket 的 web 界面添加一个“随机生成邮箱”的菜单标签

环境:inbucket_3.1.0-beta2_linux_amd64.deb
目标:在已有的网页界面里,添加一个“随机生成邮箱”的菜单标签。访客点击这个标签,会自动生成一个随机邮箱用户名(8位,包含数字和小写字母),且会自动跳转到相应的收件箱页面,如“最近邮箱”标签的作用类似。方便访客使用。

尝试:照着 inbucket 的文档,在 AI (poe 和 deepseek)的帮助下,对 ui/src 里的 Layout.elm 文件内容做了修改。尽可能不改动其它文件内容。

这里是我修改后的 Layout.elm 文件内容:

module Layout exposing (Model, Msg, Page(..), frame, init, reset, update)

import Data.Session as Session exposing (Session)
import Effect exposing (Effect)
import Html
    exposing
        ( Attribute
        , Html
        , a
        , button
        , div
        , footer
        , form
        , h2
        , header
        , i
        , input
        , li
        , nav
        , pre
        , span
        , td
        , text
        , th
        , tr
        , ul
        )
import Html.Attributes
    exposing
        ( attribute
        , class
        , classList
        , href
        , placeholder
        , rel
        , target
        , type_
        , value
        )
import Html.Events as Events
import Modal
import Route
import Timer exposing (Timer)

import Random
import Time

generateRandomMailbox : Time.Posix -> Int -> String
generateRandomMailbox time length =
    let
        chars = "abcdefghijklmnopqrstuvwxyz0123456789"
        charsLength = String.length chars

        -- 使用当前时间的毫秒数作为种子
        seed =
            Random.initialSeed (Time.posixToMillis time)

        -- 生成随机索引列表
        randomIndices =
            Random.step (Random.list length (Random.int 0 (charsLength - 1))) seed
                |> Tuple.first

        -- 将随机索引转换为随机字符
        randomChars =
            List.map (\index -> String.slice index (index + 1) chars) randomIndices
    in
    String.fromList (List.concat (List.map String.toList randomChars))

{-| Used to highlight current page in navbar.
-}
type Page
    = Other
    | Mailbox
    | Monitor
    | Status


type alias Model msg =
    { mapMsg : Msg -> msg
    , mainMenuVisible : Bool
    , recentMenuVisible : Bool
    , recentMenuTimer : Timer
    , mailboxName : String
    }


init : (Msg -> msg) -> Model msg
init mapMsg =
    { mapMsg = mapMsg
    , mainMenuVisible = False
    , recentMenuVisible = False
    , recentMenuTimer = Timer.empty
    , mailboxName = ""
    }


{-| Resets layout state, used when navigating to a new page.
-}
reset : Model msg -> Model msg
reset model =
    { model
        | mainMenuVisible = False
        , recentMenuVisible = False
        , recentMenuTimer = Timer.cancel model.recentMenuTimer
        , mailboxName = ""
    }


type Msg
    = ClearFlash
    | MainMenuToggled
    | ModalFocused Modal.Msg
    | ModalUnfocused
    | OnMailboxNameInput String
    | OpenMailbox
    | RecentMenuMouseOver
    | RecentMenuMouseOut
    | RecentMenuTimeout Timer
    | RecentMenuToggled
    | RandomMailbox
    | GotTimeForRandomMailbox Time.Posix


update : Msg -> Model msg -> ( Model msg, Effect msg )
update msg model =
    case msg of
        ClearFlash ->
            ( model, Effect.clearFlash )

        MainMenuToggled ->
            ( { model | mainMenuVisible = not model.mainMenuVisible }, Effect.none )

        ModalFocused message ->
            ( model, Effect.focusModalResult message )

        ModalUnfocused ->
            ( model, Effect.focusModal (ModalFocused >> model.mapMsg) )

        OnMailboxNameInput name ->
            ( { model | mailboxName = name }, Effect.none )

        OpenMailbox ->
            if model.mailboxName == "" then
                ( model, Effect.none )

            else
                ( model
                , Effect.navigateRoute True (Route.Mailbox model.mailboxName)
                )

        RecentMenuMouseOver ->
            ( { model
                | recentMenuVisible = True
                , recentMenuTimer = Timer.cancel model.recentMenuTimer
              }
            , Effect.none
            )

        RecentMenuMouseOut ->
            let
                -- Keep the recent menu open for a moment even if the mouse leaves it.
                newTimer =
                    Timer.replace model.recentMenuTimer
            in
            ( { model
                | recentMenuTimer = newTimer
              }
            , Effect.schedule (RecentMenuTimeout >> model.mapMsg) newTimer 400
            )

        RecentMenuTimeout timer ->
            if timer == model.recentMenuTimer then
                ( { model
                    | recentMenuVisible = False
                    , recentMenuTimer = Timer.cancel timer
                  }
                , Effect.none
                )

            else
                -- Timer was no longer valid.
                ( model, Effect.none )

        RecentMenuToggled ->
            ( { model | recentMenuVisible = not model.recentMenuVisible }
            , Effect.none
            )

        RandomMailbox ->
            -- 获取当前时间,并通过 Effect.map 转换为 msg
            ( model
            , Effect.map model.mapMsg (Effect.posixTime GotTimeForRandomMailbox)
            )

        GotTimeForRandomMailbox time ->
            -- 生成随机邮箱并导航
            let
                randomMailbox = generateRandomMailbox time 8
            in
            ( model
            , Effect.navigateRoute True (Route.Mailbox randomMailbox)
            )


type alias State msg =
    { model : Model msg
    , session : Session
    , activePage : Page
    , activeMailbox : String
    , modal : Maybe (Html msg)
    , content : List (Html msg)
    }


frame : State msg -> Html msg
frame { model, session, activePage, activeMailbox, modal, content } =
    div [ class "app" ]
        [ header []
            [ nav [ class "navbar" ]
                [ button [ class "navbar-toggle", Events.onClick (MainMenuToggled |> model.mapMsg) ]
                    [ i [ class "fas fa-bars" ] [] ]
                , span [ class "navbar-brand" ]
                    [ a [ href <| session.router.toPath Route.Home ] [ text "@ inbucket" ] ]
                , ul [ class "main-nav", classList [ ( "active", model.mainMenuVisible ) ] ]
                    [ if session.config.monitorVisible then
                        navbarLink Monitor (session.router.toPath Route.Monitor) [ text "Monitor" ] activePage

                      else
                        text ""
                    , li []
                        [ a [ href "#", Events.onClick (RandomMailbox |> model.mapMsg) ]
                            [ text "Random Mailbox" ]
                        ]
                    , navbarLink Status (session.router.toPath Route.Status) [ text "Status" ] activePage                    
                    , navbarRecent activePage activeMailbox model session
                    , li [ class "navbar-mailbox" ]
                        [ form [ Events.onSubmit (OpenMailbox |> model.mapMsg) ]
                            [ input
                                [ type_ "text"
                                , placeholder "mailbox"
                                , value model.mailboxName
                                , Events.onInput (OnMailboxNameInput >> model.mapMsg)
                                ]
                                []
                            ]
                        ]
                    ]
                ]
            ]
        , div [ class "navbar-bg" ] [ text "" ]
        , Modal.view (ModalUnfocused |> model.mapMsg) modal
        , div [ class "page" ] (errorFlash model session.flash :: content)
        , footer []
            [ div [ class "footer" ]
                [ externalLink "https://www.inbucket.org" "Inbucket"
                , text " is an open source project hosted on "
                , externalLink "https://github.com/inbucket/inbucket" "GitHub"
                , text "."
                ]
            ]
        ]


errorFlash : Model msg -> Maybe Session.Flash -> Html msg
errorFlash model maybeFlash =
    let
        row ( heading, message ) =
            tr []
                [ th [] [ text (heading ++ ":") ]
                , td [] [ pre [] [ text message ] ]
                ]
    in
    case maybeFlash of
        Nothing ->
            text ""

        Just flash ->
            div [ class "well well-error" ]
                [ div [ class "flash-header" ]
                    [ h2 [] [ text flash.title ]
                    , a [ href "#", Events.onClick (ClearFlash |> model.mapMsg) ] [ text "Close" ]
                    ]
                , div [ class "flash-table" ] (List.map row flash.table)
                ]


externalLink : String -> String -> Html a
externalLink url title =
    a [ href url, target "_blank", rel "noopener" ] [ text title ]


navbarLink : Page -> String -> List (Html a) -> Page -> Html a
navbarLink page url linkContent activePage =
    li [ classList [ ( "navbar-active", page == activePage ) ] ]
        [ a [ href url ] linkContent ]


{-| Renders list of recent mailboxes, selecting the currently active mailbox.
-}
navbarRecent : Page -> String -> Model msg -> Session -> Html msg
navbarRecent page activeMailbox model session =
    let
        -- Active means we are viewing a specific mailbox.
        active =
            page == Mailbox

        -- Recent tab title is the name of the current mailbox when active.
        title =
            if active then
                activeMailbox

            else
                "Recent Mailboxes"

        -- Mailboxes to show in recent list, doesn't include active mailbox.
        recentMailboxes =
            if active then
                List.tail session.persistent.recentMailboxes |> Maybe.withDefault []

            else
                session.persistent.recentMailboxes

        recentLink mailbox =
            a [ href <| session.router.toPath <| Route.Mailbox mailbox ] [ text mailbox ]
    in
    li
        [ class "navbar-dropdown-container"
        , classList [ ( "navbar-active", active ) ]
        , attribute "aria-haspopup" "true"
        , ariaExpanded model.recentMenuVisible
        , Events.onMouseOver (RecentMenuMouseOver |> model.mapMsg)
        , Events.onMouseOut (RecentMenuMouseOut |> model.mapMsg)
        ]
        [ span [ class "navbar-dropdown" ]
            [ text title
            , button
                [ class "navbar-dropdown-button"
                , Events.onClick (RecentMenuToggled |> model.mapMsg)
                ]
                [ i [ class "fas fa-chevron-down" ] [] ]
            ]
        , div [ class "navbar-dropdown-content" ] (List.map recentLink recentMailboxes)
        ]


ariaExpanded : Bool -> Attribute msg
ariaExpanded value =
    attribute "aria-expanded" <|
        if value then
            "true"

        else
            "false"

修改 Layout.elm 文件过程中,顺便也可以翻译一下 Web 界面的英文。

inbucket 首页,添加了“随机生成邮箱” 和 基本中文翻译

inbucket 查看邮件内容的 web 界面

参考:

FOSSBilling - 0.6.22 - apache2 静态化规则文件 - install

问题:在 Hestiacp 的 nginx proxy + apache2 环境中,下载最新的 FOSSBilling 0.6.22 zip 版本文件,顺利安装。但安装后,无论访问 /admin 还是 news 等各个网址,都是显示 首页 的内容。

尝试:在线安装成功后,网站根目录 .htaccess 的文件内容,关键的一条静态化规则是 RewriteRule ^(.*)$ index.php [QSA,L] ,但安装包里的,是 RewriteRule ^(.*)$ index.php?_url=/$1 [QSA,L] 。用安装包里的 .htaccess 替代,即可。

参考:

在 HestiaCP 里设置邮件域转发 manualroute

需求:将指定邮件域名转发到其它机子。

步骤:

  1. vi /etc/exim4/exim4.conf.template # 编辑配置文件,在 route 节里,添加相应的内容,例如:

    route_to_another_server:
    driver = manualroute
    transport = remote_smtp
    route_list = *exmaple.com  1.1.1.1
    no_more
    no_verify
  2. systemctl restart exim4 # 重启 exim4 服务
  3. 在 HestiaCP 里添加邮件域 example.com,添加用户邮箱,如 route@exmaple.com ,再将 route@example.com 设置为 catch-all 功能
  4. 收信测试

参考:

在 Debian 11 里 为 postfix 设置多实例运行 且 使用 saslauthd pam 认证

目标:因需设置每个 postfix 实例使用指定的 公网 IP地址,所以通过 postmulti 来创建新实例,且使用 asalauth pam 来认证用户。

步骤:

  1. postmulti -e create -I postfix-8 # 创建新的 postfix 实例,实例名为 postfix-8
  2. vi /etc/postfix-8/main.cf # 修改配置内容,如添加不同的 myhostname 和 inet_interfaces

    master_service_disable =
    myhostname = test8.example.com
    inet_interfaces = 1.1.1.1
    smtpd_sasl_auth_enable = yes
    smtpd_sasl_security_options = noanonymous
    smtpd_sasl_local_domain = $myhostname
    broken_sasl_auth_clients = yes
  3. cp -a /etc/postfix/sasl /etc/postfix-8/ 和 mkdir /var/spool/postfix-8/etc && mount --bind /var/spool/postfix/etc /var/spool/postfix-8/etc # 将默认实例的 sasl 配置目录(中的 smtpd.conf)复制到新的实例配置目录中
  4. postmulti -i postfix-8 -e enable # 启用 postfix-8 新实例
  5. 挂载 chroot 环境里可用的 sock 路径:

    mkdir /var/spool/postfix-8/var
    mount --bind /var/spool/postfix/var /var/spool/postfix-8/var
  6. postmulti -i postfix-8 -p start # 启动 postfix-8 新实例

参考: