瓶踪暇影——使用Flask开发web应用琐记(1)

不知不觉间,已经一年没写博客了。看来写博客这活太重量级,还是微博上灌水省心啊。

之前在一个项目里用 Flask 开发其中的 Web 部分,有些小心得,插空整理出来放到博客上权当备忘。

里面的代码都来自实际项目。

多个 app 部署在同一服务器下

有时我们需要把多个独立的 Flask 应用部署在同一服务器下,根据 URL 前缀转到不同的应用,这时候可以用 werkzeug 的 DispatcherMiddleware 来实现 app 串接。

from werkzeug.wsgi import DispatcherMiddleware

dummy_app = Flask(__name__)

@dummy_app.route('/')
def index():
    return redirect('/uc')

import uc.ucweb
import zs.zsweb

all_apps = ((None, dummy_app), ('/uc', uc.ucweb.app), ('/zs', zs.zsweb.app))

main_app = [app for (mp, app) in all_apps if not mp][0]
sub_apps = dict(((mp, app) for (mp, app) in all_apps if mp))
app = DispatcherMiddleware(main_app, sub_apps)

这个例子中创建了一个占位的 Flask 对象专用于转跳,这是项目中的某个特殊需求所致。一般来说把 all_apps 中想挂载到根路径的 app 对应的 URL 前缀设为 None 即可。后面的代码会根据 URL 前缀来把各应用自动分配给 DispatcherMiddleware 的构造函数。

instance_path 和配置文件读取

Flask 提供了从 Python 对象或者 .py 文件中读取配置的功能。但是因为 Flask 没有像 Pylons 那样使用 paster 来生成项目的初始结构,所以实际项目中这部分代码还是要自己写的。

import defaultcfg

kwargs = {}
kwargs['instance_relative_config'] = True

try:
    instance_folder = os.environ['INSTANCE_FOLDER']
    kwargs['instance_path'] = instance_folder
except KeyError:
    pass

app_name = __name__.split('.')[0]

deploy_mode = 'production'
try:
    if os.environ['DEPLOY_MODE'].upper() == 'DEBUG':
        deploy_mode = 'debug'
except KeyError:
    pass

app = Flask(app_name, **kwargs)

app.config.from_object(defaultcfg)
app.config.from_pyfile('common_%s.py' % (deploy_mode), silent=True)
app.config.from_pyfile('%s_%s.py' % (app_name, deploy_mode), silent=True)

字典 kwargs 将要传给``Flask`` 类构造函数的参数,其中 instance_relative_config 表示是否从实例路径(instance path)中读取配置文件,这样可以把配置与程序本身的路径分离,并且不需要硬编码其路径。当然,程序的运行时数据(比如程序自己的本地数据库、 cache 过来的一些参数表等)也应该放到实例路径中去,具体做法在此不多说。

接下来,看 instance_path 的实际路径是否已在环境变量中设置(其实从 argv 中读取命令行参数也是个不错的主意),没有则取 Flask 的默认值。

app_name 按 Flask 文档中的建议取 __name__ 的第一段。

在构造了 app 对象后,首先从 defaultcfg 对象中读取配置的默认值,然后读取公共配置文件 common_<部署模式>.py ,最后读取应用相关的配置文件 <程序名>_<部署模式>.py 。由于之前设置为从 instance_path 中读取配置文件,因此可以把不同 app 的 instance_path 指定到同一个目录,这样多个 app 的公共配置部分(比如日志设置、公共的数据库设置等)就可以只写一份,而各 app 自己的独有配置则放到各自的配置文件中去。生产系统的配置和调试系统的配置则通过表示部署模式的 deploy_mode 来区分开。

如何让非特权进程监听特权端口(下)

上回书说了一小撮别有用心的其它操作系统,现在要说广大人民群众喜闻乐见的 Linux 了。

Linux 下用 POSIX.1e capabilities [PRIVS] 来实现我们的需求。这个 patch 已经集成进 mainstream 了。

pam_cap

一开始的 linux-privs 只支持基于进程的 capability 控制,这对我们的要求并没有什么帮助,因为仍然需要从特权身份启动来降低权限。理论上,可以编写一个利用了 capability 的 pam 模块,来指定哪些用户的会话继承哪些权限,但和早期内核配套的 libcap 包里并没有这样的模块,直到后面的 libcap2 才有 pam_cap 实现此功能。

  • /etc/pam.d/login 里加入
auth    optional                        pam_cap.so

注意一定要加在任何 auth sufficient 之前,否则就被跳过了。当然,在 Debian 上可以加到 /etc/pam.d/common-auth 里。这是针对 login 用户的,如果是 su 用户,还需要改相应的 pam 文件,因为 @common-auth 在 su 里排在后面。

  • /etc/security/capability.conf 里加入对应用户的设置,比如
cap_net_bind_service    adoal

表示以用户 adoal 身份运行的进程可以绑定到特权端口。

文件系统 capability

从 2.6.24 内核开始, linux-privs 又支持了基于文件系统的 capability 功能,能够给文件系统里特定的可执行程序指定一个 capability,详见 [FCAP] 。简而言之执行

$ sudo setcap cap_net_bind_service=ep /usr/sbin/httpd

之后,任何用户(当然前提是对 /usr/bin/httpd 有 x 权限)都可以启动 apache 监听 80 端口而不需要特权身份。据说可以用 SELinux 来限制哪个用户只能绑定哪个端口,来做精细的控制,类似 FreeBSD 的 portacl 那样吧。但是 SELinux 太复杂,我等新手暂且敬而远之。

杯具的 Red Hat

非常值得一提的是,在企业用户里很流行的 Red Hat Enterprise Linux 5 由于生命周期太长而又坚持 ABI 不变动的原则,于是其古老的内核并不能支持基于文件系统的 capability 模型。另一方面,也没人把 pam_cap 移植过来,所以这里说的方法对 RHEL5 用户(以及 CentOS 5 之类的派生版用户)只能是梦想了。

RHEL6 里内核和 libcap 都更新了,已经可以支持上面两种方法。基于文件系统的方法我做了测试,正常。但是基于 pam_cap 的方法尝试未遂,虽然登录进去之后执行

getpcaps $$

可以看到 cap_net_bind_service 但是在 bind() 时仍然没有权限。究竟问题出在哪里,有待进一步研究。

[PRIVS]Linux POSIX.1e Capabilities <http://www.kernel.org/pub/linux/libs/security/linux-privs/>
[FCAP]POSIX Capabilities & File POSIX Capabilities <http://www.friedhoff.org/posixfilecaps.html>

如何让非特权进程监听特权端口(上)

特权端口(也就是 1024 以下的低端口)是 Unix 系统里最似是而非的安全设计,没有之一。有时候我们希望一个服务程序能够监听在 80 之类的知名低端口(便利),但程序的整个生命周期又要避开 root 身份(安全)。这种对便利和安全的双重追求就让我们感到特权端口的设计是多么蛋疼了。

要解决这个问题,也有一些似是而非的通用办法。比如前置的应用层协议代理(例如 HTTP 反向代理),用户态端口转发(例如 rinetd/xinetd),或者本机防火墙做内核态转发。但这些方法还是有一定限制的,有时候并不合适。我们需要的是让非特权进程真正监听在低端口上。顺便说一下,有些人提到的 authbind/privbind 程序,还是要以 root 身份启动的,虽然程序运行起来之后比一般的 root+suid 方法安全一些,但并不够方便,也算似是而非了。

幸好一些主要的 Unix 类操作系统发展到当代还是有办法解决这个问题的。

FreeBSD 可以用下面的指令关闭特权端口:

# sysctl net.inet.ip.portrange.reservedlow=0 net.inet.ip.portrange.reservedhigh=0

这样非特权进程就能直接监听低端口了。如果需要做权限控制,比如以 uid=80 启动的用户只能听80 端口,那么还需要启动 mac_portacl 模块。具体见 [PORTACL]

Solaris 可以通过:

# /usr/sbin/usermod -K defaultpriv=basic,net_privaddr <user_name>

给特定的用户赋予监听特权端口的权限。但是,没有类似 FreeBSD 的全局选项,而且也没有端口权限控制(如有,请指正)。

NetBSD、 OpenBSD、 Dragonfly BSD 似乎没有相应的机制, OS/X 不知,还有待探索。

Linux 情况比较混乱,且待下回分解。

[PORTACL]The MAC portacl Module <http://www.freebsd.org/doc/en_US.ISO8859-1/books/handbook/mac-portacl.html>

新版 Solaris 10 配 IP Filter 的变化

前些时候上马一台 Sun 小机,装好验收之后,自己想起来要配 IP Filter,实施方工程师给我发了一份文档,是 Sun 培训课程里的材料,于是试图照着做,结果出错,在执行

# autopush -f /etc/ipf/pfil.ap

的时候会出现如下难以理解错误

autopush: ERROR: File /etc/ipf/pfil.ap: could not configure autopush for line ##
autopush: ERROR: Invalid major device number or invalid module name or too many modules

想过各种可能,试试都不对, google 出来的也都是胡说八道。实施方工程师也不知道怎么办,他说没做过,小机一般不在本机配置防火墙,晕。然后突然想到还是看 Sun 官方文档吧,一看之下恍然大悟,原来从 8/07 开始的 Solaris 10 启动 IP Filter 已经不依赖 pfil 了,网卡驱动通过 STREAMS 直接和 IP Filter 起作用,只要用 svcadm 激活 network/ipfilter 服务即可,而 unplumb 之类的操作也不需要做了。

得到的教训是,做配置一定要先看官方文档,网络上的信息人云亦云的太多。另外感慨一下,专职的技术支持工程师毕竟只是靠这个吃饭而已,没教过或者更新了或者用不到的东西不会也是正常的,不能指望他们有激情进行额外的学习和探索。

Debian Volatile

这星期遭遇一件很囧的事情。邮件服务器突然出毛病, SMTP 喂进去的信都塞住不发了。查看一下日志,原来是 ClamAV 罢工,造成邮件入队前的过滤暂时失败,于是堆在那里慢慢等重试。日志里说的主要意思是,最新的病毒特征库里有个无效的字符串。那个字符串的内容是要用户升级到 ClamAV 0.95 版引擎。原因是 ClamAV 开发组停止对老版本的维护,于是在新的特征库里加了这个字符串,这样可以阻止老版本引擎载入特征码,而且文字内容也能让用户得到提示。

又搜索了一下看 Debian 是怎么处理这个问题的。原来在月初已经有人报告过这个 bug / feature 了。在 Squeeze testing 里已经更新到 ClamAV 0.95 版本,但是 Lenny stable 还没有。这时想起还有个叫 Debian volatile 的玩意,其目的就是为了解决在 stable 发行版的更新周期慢和病毒特征码之类数据更新周期快的不河蟹问题。遂找了个源加入,更新,问题解决。

以前用 backports 和现在用 volatile 都是拜 ClamAV 所赐,囧。 Volatie 最大的问题是镜像太少,比以前抱怨的 bakcports 还要少,找个在教育网里速度较快的源太困难。

MBR 签名和 Windows 启动

标准 MBR 从偏移量 0x1b8 开始的 4 个字节是签名,用来标识一个磁盘。这个标记在大多数操作系统里并没有使用,而且很容易被改写掉。 Windows 使用签名来作为识别磁盘的依据,并结合卷的起始偏移量来判定如何分配盘符,如果某个卷的 (签名, 起始偏移量) 组合和盘符的对应关系已保存在注册表里,就给它分配相应的盘符。

直到 Windows 5.x 系列, MBR 签名并不影响 Windows 基本系统的启动过程。 NTLDR 在 boot.ini 里读取的启动配置,磁盘标识是按照 BIOS 约定来编号的。也有资料表明 MBR 签名可以被用于帮助 Windows 在 BIOS 无法处理的情况下完成启动,但并不是强制的。所以操作系统安装程序、磁盘分区管理软件和启动管理软件把 MBR 签名改写之后, Windows 的启动不受影响。也许正是因为如此,这些软件(包括 Windows 自己的安装程序)改 MBR 的时候并不会小心翼翼保留 MBR 签名的值。

然而在 Windows 6.x 时代,这种做法就行不通了。 Windows 6.x 的启动信息放在 BCD 里,不再使用 BIOS 的磁盘编号和 MBR 分区表编号来寻找启动卷,而是 (签名, 起始偏移量) 组合。这样,如果意外改写了 MBR 签名(或者把启动卷移动了位置),就会造成无法启动。然而不幸的是相当多的操作系统(包括 Windows 5.x 在内)的安装程序以及启动管理软件都会去改写 MBR 签名;幸运的是,修复并不麻烦。可以把 MBR 签名改回来,也可以用 Windows 6.x 的安装盘或者 PE 启动盘去修复。

另外网上还有文章说明可以让 Windows 不检查 MBR 签名确定系统盘,从哪个盘引导的就把哪个盘作为系统盘:

BCDEDIT /set {bootmgr} device boot
BCDEDIT /set {default} device boot
BCDEDIT /set {default} osdevice boot

对于引导盘和系统盘一致的情况应该是好用的。

在不远的将来,使用 UEFI boot 和 GPT 分区表的系统将不存在这个问题,因为在 GPT 分区的磁盘里 MBR 只是起防犯旧式软件误操作的作用。操作系统识别卷的依据是 128 位的 GUID,没必要再用 (签名, 起始偏移量) 这种组合了。而移动卷的位置也不会带来什么问题。

用 SPF + Greylisting 给 Postfix 阻挡垃圾邮件

某域名十多年的邮件服务。期间硬件和软件换了好几次。近期将要停止服务。可能这次启用 SPF + greylisting 是我给此域名做的最后一次邮件服务配置了。

安装(Debian下)

$ sudo aptitude install postgrey postfix-policyd-spf-python

配置

  • /etc/postfix/main.cf
[...]
policyd-spf_time_limit = 3600
smtpd_restriction_classes = mfrom_passed_spf
smtpd_recipient_restrictions =
        [...]
        reject_unauth_destination,
        # 一定要放 在reject_unauth_destination 后
        #否则会成为 open relay
        check_policy_service unix:private/policyd-spf,
        check_policy_service inet:127.0.0.1:60000,
        # 后续检查
        [...]
        permit
mfrom_passed_spf =
        # 后续检查
        [...]
        permit

使用 Postfix Restriction Class,只有 SPF 返回值不是 Pass (即 SPF 中性或临时错误)时才检查 greylisting 策略,这样可以减少 greylisting 延迟带来的用户体验变差。

  • /etc/postfix-policyd-spf-python/policyd-spf.conf
[...]
Mail_From_reject = Fail
Mail_From_pass_restriction = mfrom_passed_spf

要注意 policyd-spf.conf 中字段名和值的大小写。

  • /etc/postfix/master.cf
[...]
policyd-spf     unix    -       n       n       -       0       spawn
        user=policyd-spf argv=/usr/bin/policyd-spf

也许用 ppolicy 更合理一点,一个模块化的 policy server 完成多种功能,通过 Python 脚本来配置检查流程。不过看起来比较复杂,也不知道以后有没有机会玩了。

冤枉 Debian 了

前面说的那个在 Lenny domU 里的 PHP 解释器会在访问 Drupal6 的管理页面时停住的问题,其实是误会。问题还是出在 Drupal6 本身,它的更新模块试图直接连 Internet 时如果在内网时连不上,就阻塞在那里了,而且也没法设置代理。之所以我误以为真机上的没这问题,是因为那台真机是通过 NAT 出去的。试了一下把 domU 的 IP 加进 NAT 就好了。为这事还在 Debian bug mailing list 里报告了一下,真丢人。顺便看了一下 Drupal 网站上关于这事的讨论 [DRUPAL7881] ,从 2004 年开始就有,期间也有不少人发过各种简易 patch 但是基本上没啥定论。

[DRUPAL7881]Add support for proxy servers <http://drupal.org/node/7881>

在 Drupal 中使用 reStructuredText - 改进版

用于 Drupal6 的版本,加上了 Pygments 对代码做彩色语法显示。还是很简陋而且乱糟糟的,有很多地方要完善。

需要在主题的 style sheet 里设置菜色显示的颜色方案。 Acquia Marina 有个好处是可以把自己修改的设定加在 local.css 里这样升级的时候就安全了。

  • local.css
#content {
    font-size: 133%;
}

#comments h2.comments {
    text-transform: none;
    font-variant: small-caps;
}

h2.title {
    text-transform: none;
    font-variant: small-caps;
}

pre {
    font-family: "Consolas", monospace;
}

.literal {
    text-decoration: none;
    background-color: #fbeddd;
    border-width: 0 0 1px 0;
    border-style: none none dashed none;
}

/* code-block highlight scheme for Pygments output */
.highlight {
    background-color: #ddedfb;
    border-style: solid;
    border-color: #0099cc;
    border-width: 1px 0 1px 0;
    margin: 1em 0;
    padding: 0.5em 1em;
}

/* css genetated from gen_pyg_style.py should be appended here*/

上面是针对自己的喜好改的 local.css 内容,代码彩色语法的 css 可以用下面的脚本生成,加在 local.css 后面。

  • gen_pyg_style.py
#!/usr/bin/python
import sys
from pygments.formatters import HtmlFormatter
style='friendly'
if len(sys.argv) > 1:
    style = sys.argv[1]
print style
f = HtmlFormatter(style=style)
print f.get_style_defs()

另外 settings.php 最好也改一下

  • settings.php
1 <?php
2 $db_url = '<database url...>';
3 $db_prefix = '';
4 $update_free_access = FALSE;
5 $conf = array(
6     'reverse_proxy' => TRUE,
7     'reverse_proxy_addresses' => array('x.y.z.w',),
8     'allow_insecure_uploads' => 1,
9 );

6、7 行是放在反向代理后面而本服务器又没做特别设置时的必要措施。8 行是因为 Drupal 默认会在上传文件时把多段扩展名加个下划线,譬如 .tar.gz 的改成 .tar_.gz 的样子,号称为了安全,但相当丑陋,我不需要这种安全。

忍无可忍启用 postgrey 之后的感觉

冒着来信延迟会被领导骂的危险启用了 postgrey,结果领导还没骂,发现自己先受不了,因为原先习惯了垃圾邮件,现在一天收收不到两三封,于是总担心服务器是不是坏了,嘿嘿。

同步内容