pikesaku’s blog

個人的な勉強メモです。記載内容について一切の責任は持ちません。

Pythonのリスト操作

s[i:j:k]

i番目からj番目までの要素をk個毎に取り出す

>>> a = [0,1,2,3,4,5,6,7,8,9]
>>> a[0:9:2]
[0, 2, 4, 6, 8]
>>> a[0::2]
[0, 2, 4, 6, 8]
>>>

s.index(i)

sの要素の中にiと同じものがあった場合に、その最初のインデックスを返す。

>>> a
[1, 1, 1, 2, 2, 2, 3, 3, 3]
>>> a.index(3)
6
>>> a[6]
3
>>> 

s.count(i)

sの要素の中にiと同じものが何個あるか返す

>>> a
[1, 1, 1, 2, 2, 2, 3, 3, 3]
>>> a.count(2)
3
>>>

enumerate関数

リストのインデックスと要素の両方を取り出す

>>> a = ['a','b']
>>> for i,v in enumerate(a):
...   print(i,v)
... 
0 a
1 b
>>>

MailmanとDMARC

MailmanにDMARC機能が追加されたのでメモ
デフォルト設定なら、前バージョンまでの動きと変わらない。


DMARCとは

SPF/DKIMによる送信者認証を利用した認証の仕組み
SPF/DKIMの以下問題を改善する仕組みを持っている。

SPF/DKIMは認証失敗した時の扱いが明確に定義されていいない。
DNSのDMARCレコードで定義

・送信者が認証結果を知ることができない
→レポートを送信する仕組みを実装

https://www.cuenote.jp/library/marketing/dmarc.html
http://ml.cc.tsukuba.ac.jp/pdf/dmarc-with-mlservice-from.pdf
https://sendgrid.kke.co.jp/blog/?p=3137
https://www.iajapan.org/anti_spam/event/2014/conf0214/pdf/sess1/sakuraba.pdf


MailmanがDMARCを問題にする背景

メンバーが投稿(From: member@example.com)
Mailmanがメンバー展開しリレー(Fromヘッダはそのまま)
リレー先でSPF/DKIM認証を実施→Fromが変わっていないため、SPF認証はエラー。DKIMは認証通りそう?

Mailmanは中継するため、送信者認証失敗の原因になってしまう

DMARC情報よりメンバーへの配信時にRejetc/隔離になることがわかれば、配信時にFromヘッダをMLに変更する機能

元メールを添付
Fromを書き換え
の対策があり。


関連パラメタ

関連パラメタ 意味
DEFAULT_DMARC_QUARANTINE_MODERATION_ACTION DMARCポリシーが隔離の場合、Rejectと同じように扱うかYes/No
DEFAULT_DMARC_MODERATION_ACTION 送信元ドメインのDMARCポリシーが、Rejct or 隔離であった場合の動作を定義
0 = Accept
1 = Munge From
2 = Wrap Message
3= Reject
4 = Discard
DEFAULT_DMARC_WRAPPED_MESSAGE_TEXT 添付ファイル送信を選択した場合の、マルチパート区切り文字列に含む文字の指定
DEFAULT_FROM_IS_LIST DMARC_MODERATION_ACTIONがAcceptではないMLへの投稿に対するデフォルトのアクションを設定する。
0: 何もしない
1: FromヘッダをMLアドレスと置き換え
2: FromをMLにしたメールに元メールを添付して送付
REMOVE_DKIM_HEADERS FROM_IS_LISTが1,2の場合にDKIMヘッダを削除する。理由は、Fromヘッダや添付ファイル化した時に、シグニチャが壊れてしまう可能性があるため
Yes/No

FMLの会員管理ファイルの動作確認

activesファイルの動作

f:id:pikesaku:20170122013125p:plain

シングルクオーテーションは評価されない。ダブルクオーテーションは閉じていれば評価される。
投稿しても有効なメンバーない場合は、以下ログが出る。
SmtpIO: no recipients but O.K.? (test1@example.com)


membersファイルの動作

f:id:pikesaku:20170122013529p:plain

members_onlyの設定でtest1@example.comからメール送信し、メンバーとして認識されるか確認
シングルクオーテーション、ダブルクオーテーション共に閉じていても評価されない。
行頭のコメント1個は無視される!
全角スペースはmembersではアドレスを構成する文字として認識される(?もしくは不正文字のため、判定されてない?)、activesでは不正文字としてエラーになる。
全角スペースは前後のスペース以外の文字と組み合わせた文字として判定される。

FMLのReply-Toヘッダ付与動作

fmlはデフォルト設定。

投稿メールReply-To HOOK設定Reply-To 配信メールに付与されたReply-To
有※ HOOK設定Reply-To
投稿メールReply-To
有※ HOOK設定Reply-To
MLアドレス

※HOOK設定は以下の場合

$START_HOOK = q#
&DEFINE_FIELD_FORCED("reply-to", "$From_address);
#;

FMLの配送制限(PERMIT_POST_FROM)とMailman設定対応表

fml/mailman設定 動作が定義されていない非会員からの投稿に対する動作
(generic_nonmember_action)
承認/保留/拒否/破棄
新しく登録する会員のデフォルトを制限付き会員にしますか?
(default_member_moderation)
いいえ/はい
制限付き会員から投稿があったときの動作
(member_moderation_action)
保留/拒否/破棄
anyone 承認 いいえ 保留
members_only 保留 いいえ 保留
moderator 保留 はい 保留

FMLとMailmanの送信元ヘッダ比較

FMLはデフォルト設定、Mailmanの関連設定は以下の通り

項目
anonymous_list No
include_sender_header Yes
reply_goes_to_list 投稿者
first_strip_reply_to No



メンバー配信メール

  種別    Return-Path    From     Reply-To     Sender  
fml ML名-admin 投稿者 ML名 -
mailman ML名-bounces 投稿者 - ML名-bounces



ML管理者宛通知メール

  種別    Return-Path    From     Reply-To     Sender  
fml ML名-admin ML名-admin - -
mailman ML名-bounces
(投稿者)
(-)
ML名-owner
(投稿者)
(ML名-request)
- ML名-bounces
(-)
(ML名-request)

members_onlyのMLにメンバー外のアドレスから投稿し管理者宛ての通知メールで確認
()内の値はマルチパート部分。
マルチパート構成は以下の通り。
mailman: 通知メール(承認URL記載)、保留されたメール、通知メール(承認返信メール記載)


メンバー宛通知メール

  種別    Return-Path    From     Reply-To     Sender  
fml ML名-admin ML名-admin - -
mailman ML名-bounces ML名-owner - ML名-bounces

members_onlyのMLにメンバーではないアドレスから投稿し該当アドレス宛ての通知メールで確認


モデレータ宛通知メール

  種別    Return-Path    From     Reply-To     Sender  
fml ML名-admin
(owner-ML名)
ML名-admin
(投稿者)
ML名-ctl -
mailman ML名-bounces
(投稿者)
(-)
ML名-owner
(投稿者)
(ML名-request)
- ML名-bounces
(-)
(ML名-request)

モデレータへの通知メールで確認
()内の値はマルチパート部分。
マルチパート構成は以下の通り。
fml: 通知メール、保留されたメール
mailman: 通知メール(承認URL記載)、保留されたメール、通知メール(承認返信メール記載)

FML2Mailman移行ツール(試験まだしてない。。)

ツール説明

作成中

ツール

#!/usr/bin/python
# coding: utf-8

# 留意事項
# 1.activesやmembersの中のローカルパートのみアドレスはskipされる。
# 2.WarnでSkipを含むメッセージは移行していない情報があるを意味する。
#   メッセージより必要に応じて手作業で移行をする。

import os
import argparse
import commands
import re
import syslog
import inspect
import sys
from datetime import datetime

parser = argparse.ArgumentParser(description='fml2mailman convert tool')
parser.add_argument('-v', '--verbose', action='store_true', help='verbose', default=False)
parser.add_argument('-d', '--dryrun', action='store_true', help='', default=False)
parser.add_argument('mlname', help='mlname')
args = parser.parse_args()

MLNAME = args.mlname.lower()
DRYRUN = args.dryrun
VERBOSE = args.verbose

# FMLインストール環境
FML_DIR = '/var/spool/ml'
CONFIG = FML_DIR + '/' + MLNAME + '/' + 'config.ph'
MEMBERS = FML_DIR + '/' + MLNAME + '/' + 'members'
ACTIVES = FML_DIR + '/' + MLNAME + '/' + 'actives'
MEMBERS_ADMIN = FML_DIR + '/' + MLNAME + '/' + 'members-admin'
MODERATORS = FML_DIR + '/' + MLNAME + '/' + 'moderators'
SEQ = FML_DIR + '/' + MLNAME + '/' + 'seq'

# ドメイン設定
DOMAIN = 'exmaple.com'

# ファイル出力先ディレクトり
OUTPUT_DIR = './output'


def fwrite(f, d):
    try:
        with open(f, 'w') as fh:
            if isinstance(d, list):
                fh.write('\n'.join(d))
            else:
                fh.write(d)
    except:
        err_fin('can not write ' + f)


def fopen(f):
    try:
        with open(f) as fh:
            data = [s for s in fh.readlines() if s.strip()]
    except:
        err_fin('file open error[' + f + ']')
    return data


def env_chk():
    for s in [CONFIG, MEMBERS, ACTIVES, SEQ]:
        if not os.path.isfile(s):
            err_fin('file does not exist[' + s + ']')
    if not os.path.isdir(OUTPUT_DIR):
        exec_com('mkdir -p ' + OUTPUT_DIR)


def addr_chk(s):
    p = re.compile('^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z]+$')
    if p.match(s):
        return True
    else:
        return False


def mkpass():
    p = exec_com('mkpasswd -l 12 -s 0')
    return p


def exec_com(s):
    r = commands.getstatusoutput(s)
    if r[0]:
        err_fin('command failed[' + s + ']')
    return r[1]


def err_fin(s):
    output_log('Error: ' + s)
    exit(1)


def skip_chk(s):
    p = re.compile('^[^#\s]+.*\s+s=.*$')
    if p.match(s):
        return True
    else:
        return False


def space_start_chk(s):
    # 全角スペースはマッチしない。
    # 全角スペースはFMLでも区切り文字にならないため、問題なし。
    p = re.compile('^\s+\S+.*$')
    if p.match(s):
        return True
    else:
        return False


def output_log(s):
    s = MLNAME + ': ' + s
    syslog.openlog(os.path.basename(sys.argv[0]))
    # syslog.openlog(os.path.basename(sys.argv[0]), syslog.LOG_PERROR, syslog.LOG_USER)
    syslog.syslog(s)
    print(s)
    syslog.closelog()


def conv_member(li):
    f = OUTPUT_DIR + '/' + MLNAME + '-MEMBER'
    fwrite(f, li)
    exec_com('/usr/local/mailman/bin/add_members -w n -a n -r ' + f + ' ' + MLNAME)


def conv_config(s):
    f = OUTPUT_DIR + '/' + MLNAME + '-CONFIG'
    fwrite(f, s)
    exec_com('/usr/local/mailman/bin/config_list -i ' + f + ' ' + MLNAME)


def create_ml(s):
    f = OUTPUT_DIR + '/' + MLNAME + '-PASSWD'
    mls = exec_com('/usr/local/mailman/bin/list_lists -b')
    if MLNAME in mls:
        # 試験用
        exec_com('/usr/local/mailman/bin/rmlist -a ' + MLNAME)
        # 本番向け
        # err_fin(MLNAME + ' already exsists')
    p = mkpass()
    fwrite(f, p)
    exec_com('/usr/local/mailman/bin/newlist -q' + ' ' + MLNAME + ' ' + s + ' ' + "'" + p + "'")


def set_post_strict(s):
    # generic_nonmember_action
    # 動作が定義されていない非会員からの投稿に対する動作
    # 0:承認 1:保留 2:拒否 3:破棄

    # default_member_moderation
    # 新しく登録する会員のデフォルトを制限付き会員にしますか?
    # 0:いいえ 1:はい

    # member_moderation_action
    # 制限付き会員から投稿があったときの動作
    # 0:保留 1:拒否 2:破棄
    s2 = str()
    if s == 'ao':  # anyone
        s2 += 'generic_nonmember_action = 0\n'
        s2 += 'default_member_moderation = 0\n'
        s2 += 'member_moderation_action = 0\n'
    elif s == 'mo':  # members_only
        s2 += 'generic_nonmember_action = 1\n'
        s2 += 'default_member_moderation = 0\n'
        s2 += 'member_moderation_action = 0\n'
    elif s == 'md':  # moderator
        s2 += 'generic_nonmember_action = 1\n'
        s2 += 'default_member_moderation = 1\n'
        s2 += 'member_moderation_action = 0\n'
    else:
        err_fin('invalid config(post_strict)')
    return s2


def set_stop_deliver(li):
    # String Example
    # delivery_status = { 'test3@example.com': (3, 1483290493.0), 'test6@example.com': (3, 1483290493.0), }
    date = float(datetime.now().strftime('%s'))
    s = 'delivery_status = { '
    for s2 in li:
        s += "'" + s2 + "': " + '(3, ' + str(date) + '), '
    s += '}\n'
    return s


def set_user_opt(post_strict, member, no_post):
    # String Example
    # user_options = { 'test1@example.com': 392,'test6@example.com': 392,'test5@example.com': 392,}
    # デフォルト値(d) = ダブリ無(256) + 平文(8)
    # 制限有効時は128加算
    d = 264
    s = 'user_options = { '
    if post_strict == 'md':  # moderator
        for s2 in member:
            s += "'" + s2 + "': " + str(d + 128) + ', '
    else:
        for s2 in no_post:
            s += "'" + s2 + "': " + str(d + 128) + ', '
        for s2 in set(member) - set(no_post):
            s += "'" + s2 + "': " + str(d) + ', '
    s += '}\n'
    return s


def set_seq(i):
    s = 'post_id = ' + str(i) + '\n'
    return s


def set_admin(li):
    s = 'owner = ['
    for s2 in li:
        s += "'"
        s += s2
        s += "',"
    s += ']\n'
    return s


def set_moderator(li):
    s += 'moderator = ['
    for s2 in li:
        s += "'"
        s += s2
        s += "',"
    s += ']\n'
    return s


def set_reply_to(s):
    # reply_goest_to_list = Int
    # 0: 投稿者
    # 1: このリスト
    # 2: 別のアドレス
    # reply_to_address = Str
    s1 = str()
    if s == 'from':
        s1 = 'reply_goest_to_list = 0'
    elif s == 'ml':
        s1 = 'reply_goest_to_list = 1'
    else:
        s1 = 'reply_goest_to_list = 2\n'
        s1 += 'reply_to_address = ' + "'" + s + "'\n"
    return s1


def get_reply_to(s):
    #&DEFINE_FIELD_FORCED("reply-to", "$From_address, $MAIL_LIST");
    w = 'Warn: HOOK proc exist: '
    li = [s2 for s2 in s if s2.startswith('&DEFINE_FIELD_FORCED(')]
    if not li:
        return False
    if len(li) > 1:
        output_log(w + 'Skip: &DEFINE_FIELD_FORCED appear over twice')
        return False

    s = li[0]
    s2 = s.lstrip('&DEFINE_FIELD_FORCED')
    s2 = s2.rstrip(';')
    s2 = s2.strip().strip('()').strip()
    s2 = s2.split(',')

    w1 = w + 'Skip: invalid define_field: ' + s
    w += s

    if not s2 or len(s2) != 2:
        output_log(w1)
        return False

    f1 = s2[0].strip().strip('"').strip("'").lower()
    f2 = s2[1].strip().strip('"').strip("'")

    if f1 != 'reply-to':
        output_log(w1)
        return False

    if f2 == '$From_address':
        output_log(w)
        return 'from'
    elif f2 == '$MAIL_LIST':
        output_log(w)
        return 'ml'
    else:
        f2 = f2.replace('\\', '')
        if addr_chk(f2):
            output_log(w)
            return f2
        else:
            output_log(w1)
            return False

    output_log(w1)
    return False


def get_val_from_config(config, param):
    for s in config:
        p = s.split()[0].lstrip('$')
        if p == param:
            #v = s.split()[-1].rstrip(';').strip('"')
            v = s.split()[-1].rstrip(';').strip('"').strip("'")
            return v
    return False


def get_subject_tag(s):
    # (mo_ml:%05d)
    v = str()
    t = get_val_from_config(s, 'SUBJECT_TAG_TYPE')
    b = get_val_from_config(s, 'BRACKET')
    f = get_val_from_config(s, 'SUBJECT_FORM_LONG_ID')
    if f == False:
        f = "5"
    if t == False or b == False:
        err_fin('invalid tag')

    if not t:
        v += ''
    elif t == '(:)':
        v += '(' + b + ':' + '%0' + f + 'd)'
#    elif t == '[:]':
#         v += '[' + b + ':' + '%0' + f + 'd]'
    elif t == '()':
        v += '(' + b + ')'
#    elif t == '[]':
#         v += '[' + b + ']'
    else:
        err_fin('invalid tag ' + t)
    return v


def get_post_strict(s):
    s2 = get_val_from_config(s, 'PERMIT_POST_FROM')
    if s2 == False:
        err_fin('invalid post_strict')
    if s2 == 'anyone':
        return 'ao'
    elif s2 == 'members_only':
        return 'mo'
    elif s2 == 'moderator':
        return 'md'
    else:
        err_fin('invalid post_strict')


def get_seq():
    s = fopen(SEQ)
    i = int()
    if not s:
        err_fin('invalid seq')

    s = s[0].rstrip()
    if s.isdigit():
        i = int(s) + 1
    else:
        err_fin('invalid seq')
    return i


def get_addr(f):
    li = fopen(f)
    li = list(s.lower().rstrip() for s in li if not s.startswith('#') and s.strip())
    for s in li:
        if not addr_chk(s):
            err_fin('addr is invalid: ' + s + '[' + f + ']')
    return li


def get_members():
    members = set()
    li = fopen(MEMBERS)
    # activesファイルと行頭のスペースの扱いが違う点に注意
    li = [s.lower().strip() for s in li if not s.startswith('##') and s.strip()]
    for s in li:
        w = str()
        if not addr_chk(s):
            w += 'Not normal entry: '
        if s.startswith('#'):
            s2 = s.lstrip('#').strip()
            if s2:
                s2 = s2.split()[0]
                if addr_chk(s2):
                    members.add(s2)
                else:
                    w += 'Skip: '
            else:
                w += 'Skip: '
        else:
            s2 = s.split()[0]
            if addr_chk(s2):
                members.add(s2)
            else:
                w += 'Skip: '

        if w:
            output_log('Warn: ' + w + s + ' [' + MEMBERS + ']')
        else:
            if VERBOSE:
                output_log('Debug: ' + 'Converted: ' + s + ' [' + MEMBERS + ']')

    if not members:
        output_log('Warn: no valid entry [' + MEMBERS + ']')
    return members


def get_actives():
    actives = set()
    no_deliver = set()
    li = fopen(ACTIVES)
    li = [s.lower().rstrip() for s in li if not s.startswith('##') and s.strip()]
    for s in li:
        w = str()
        if not addr_chk(s):
            w += 'Not normal entry: '

        if s.startswith('#'):
            s2 = s.lstrip('#').strip()
            if s2:
                s2 = s2.split()[0]
                if addr_chk(s2):
                    no_deliver.add(s2)
                else:
                    w += 'Skip: '
            else:
                w += 'Skip: '
        elif space_start_chk(s):
            w += 'Skip: '
        else:
            s2 = s.split()[0]
            if skip_chk(s):
                if addr_chk(s2):
                    no_deliver.add(s2)
                else:
                    w += 'Skip: '
            else:
                if addr_chk(s2):
                    actives.add(s2)
                else:
                    w += 'Skip: '

        if w:
            output_log('Warn: ' + w + s + ' [' + ACTIVES + ']')
        else:
            if VERBOSE:
                output_log('Debug: ' + 'Converted: ' + s + ' [' + ACTIVES + ']')

    if not actives:
        output_log('Warn: no valid entry [' + ACTIVES + ']')
    return actives, no_deliver


def get_member_info():
    # FML1情報取得
    actives, no_deliver = get_actives()
    members = get_members()

    # Mailman用メンバー情報格納オブジェクト作成
    mm_member = actives.union(no_deliver).union(members)
    mm_no_deliver = no_deliver.union(members - actives)
    mm_no_post = actives.union(no_deliver) - members

    # Mailman用メンバー情報格納オブジェクトをリスト変換し戻す
    mm_member = list(mm_member)
    mm_member.sort()

    mm_no_deliver = list(mm_no_deliver)
    mm_no_deliver.sort()

    mm_no_post = list(mm_no_post)
    mm_no_post.sort()

    return mm_member, mm_no_deliver, mm_no_post


def get_config():
    li = fopen(CONFIG)
    li = [s.strip() for s in li if not s.startswith('#')]
    return li


def main():
    env_chk()

    ########################################################
    # FML情報取得                                          #
    ########################################################
    config = get_config()
    post_strict = get_post_strict(config)
    subject_tag = get_subject_tag(config)
    member, no_deliver, no_post = get_member_info()
    admin = get_addr(MEMBERS_ADMIN)
    moderator = list()
    if post_strict == 'md':  # moderator
        moderator = get_addr(MODERATORS)
    seq = get_seq()
    reply_to = get_reply_to(config)

    ########################################################
    # Mailman設定                                          #
    ########################################################

    # 管理者情報
    mm_config = set_admin(admin)

    # 投稿制限
    mm_config += set_post_strict(post_strict)

    # シーケンス番号
    mm_config += set_seq(seq)

    # ユーザーオプション(制限)
    if member and post_strict != 'ao':  # anyone
        mm_config += set_user_opt(post_strict, member, no_post)

    # 配信停止
    if no_deliver:
        mm_config += set_stop_deliver(no_deliver)

    # リスト司会者設定
    if moderator and post_strict == 'md':  # moderator
        mm_config += set_moderator(moderator)

    # 件名タグ付け
    # subject_prefix = Str
    if subject_tag:
        mm_config += 'subject_prefix = ' + "'" + subject_tag + "'\n"
    else:
        mm_config += 'subject_prefix = ""\n'

    # メールドメイン設定
    # host_name = Str
    mm_config += 'host_name = ' + "'" + DOMAIN + "'\n"

    # Reply-To設定
    if reply_to != False:
        mm_config += set_reply_to(reply_to)

    # 移行
    if DRYRUN:
        for s in [s for s in mm_config.split('\n') if s]:
            output_log('DryRun: ' + 'Config: ' + s)
        for s in member:
            output_log('DryRun: ' + 'Member: ' + s)
    else:
        create_ml(admin[0])
        conv_member(member)
        conv_config(mm_config)
        output_log('Info: Successfully finished')


if __name__ == '__main__':
    main()