├── route53
├── views
│ ├── __init__.py
│ ├── main.py
│ ├── slicehost.py
│ ├── zones.py
│ └── records.py
├── static
│ ├── img
│ │ └── grid.png
│ └── css
│ │ ├── print.css
│ │ ├── ie.css
│ │ └── screen.css
├── templates
│ ├── index.html
│ ├── slicehost
│ │ ├── base.html
│ │ ├── zones.html
│ │ ├── index.html
│ │ ├── import_zone.html
│ │ └── records.html
│ ├── _formhelpers.html
│ ├── zones
│ │ ├── detail.html
│ │ ├── clone.html
│ │ ├── delete.html
│ │ ├── new.html
│ │ ├── list.html
│ │ └── records.html
│ ├── xml
│ │ ├── change_batch.xml
│ │ └── _macros.xml
│ ├── records
│ │ ├── delete.html
│ │ ├── update.html
│ │ └── new.html
│ └── base.html
├── connection.py
├── application.cfg.example
├── xmltools.py
├── __init__.py
├── models.py
└── forms.py
├── .gitignore
├── runserver.py
├── create_db.py
├── shell.py
├── requirements.txt
├── sass
├── src
│ ├── print.sass
│ ├── partials
│ │ └── _base.sass
│ ├── ie.sass
│ └── screen.sass
└── config.rb
├── auth.py
├── LICENSE
├── README.rst
└── authdigest.py
/route53/views/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | application.cfg
3 | sass/.sass-cache/*
4 | dev.db
5 |
--------------------------------------------------------------------------------
/runserver.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | from route53 import app
3 | app.run()
4 |
--------------------------------------------------------------------------------
/create_db.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | from route53.models import db
3 | db.drop_all()
4 | db.create_all()
5 |
--------------------------------------------------------------------------------
/route53/static/img/grid.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrew-kurin/route53manager/HEAD/route53/static/img/grid.png
--------------------------------------------------------------------------------
/shell.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ipython
2 | from route53 import app
3 |
4 | ctx = app.test_request_context()
5 | ctx.push()
6 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | Flask==0.10
2 | Flask-SQLAlchemy==0.9.1
3 | Flask-WTF==0.6
4 | simplejson
5 | -e git://github.com/boto/boto.git#egg=boto
6 | pyactiveresource==1.0.1
7 |
--------------------------------------------------------------------------------
/route53/templates/index.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block body %}
4 |
Edit zones
5 | {% endblock %}
6 |
--------------------------------------------------------------------------------
/route53/templates/slicehost/base.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block toolbar %}
4 |
New API key
5 | {% endblock %}
6 |
--------------------------------------------------------------------------------
/route53/views/main.py:
--------------------------------------------------------------------------------
1 | from flask import redirect, Module, url_for, request
2 |
3 | main = Module(__name__)
4 |
5 |
6 | @main.route('/')
7 | def index():
8 | return redirect(url_for('zones.zones_list'))
9 |
--------------------------------------------------------------------------------
/route53/connection.py:
--------------------------------------------------------------------------------
1 | from boto.route53 import Route53Connection
2 |
3 |
4 | def get_connection():
5 | from route53 import app
6 | return Route53Connection(aws_access_key_id=app.config['AWS_ACCESS_KEY_ID'],
7 | aws_secret_access_key=app.config['AWS_SECRET_ACCESS_KEY'])
8 |
--------------------------------------------------------------------------------
/sass/src/print.sass:
--------------------------------------------------------------------------------
1 | @import blueprint
2 |
3 | // To generate css equivalent to the blueprint css but with your configuration applied, uncomment:
4 | // @include blueprint-print
5 |
6 | //Recommended Blueprint configuration with scoping and semantic layout:
7 | body.bp
8 | +blueprint-print(true)
--------------------------------------------------------------------------------
/route53/application.cfg.example:
--------------------------------------------------------------------------------
1 | SECRET_KEY = '\xb3\x8b\xdc\xc4;\xa2I.\x16\x93\xbb"\xc3\xf8]\xaa^a\xed\xad\xb2\xd9wj'
2 | SQLALCHEMY_DATABASE_URI = 'sqlite:////tmp/test.db'
3 | SQLALCHEMY_ECHO = False
4 | DEBUG = True
5 | AWS_ACCESS_KEY_ID=""
6 | AWS_SECRET_ACCESS_KEY=""
7 | AUTH_USERS = [
8 | ('admin', 'admin'),
9 | ]
10 |
--------------------------------------------------------------------------------
/route53/templates/_formhelpers.html:
--------------------------------------------------------------------------------
1 | {% macro render_field(field) %}
2 | {{ field.label }}
3 | {{ field(**kwargs)|safe }}
4 | {% if field.errors %}
5 |
6 | {% for error in field.errors %}{{ error }} {% endfor %}
7 |
8 | {% endif %}
9 |
10 | {% endmacro %}
11 |
--------------------------------------------------------------------------------
/route53/templates/slicehost/zones.html:
--------------------------------------------------------------------------------
1 | {% extends "slicehost/base.html" %}
2 |
3 | {% block breadcrumbs %}
4 | Home
5 | Slicehost Zones
6 | {% endblock %}
7 |
8 | {% block body %}
9 | Slicehost Zones
10 |
17 | {% endblock %}
18 |
--------------------------------------------------------------------------------
/route53/templates/zones/detail.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block breadcrumbs %}
4 | Zones
5 | {{ zone['Name'] }}
6 | {% endblock %}
7 |
8 | {% block body %}
9 | Hosted Zone "{{ zone['Name'] }}"
10 |
11 | Id:
12 | {{ zone['Id']|shortid }}
13 | Nameservers:
14 | {% for ns in nameservers %}
15 | {{ ns }}
16 | {% endfor %}
17 |
18 | {% endblock %}
19 |
--------------------------------------------------------------------------------
/sass/config.rb:
--------------------------------------------------------------------------------
1 | # Require any additional compass plugins here.
2 | # Set this to the root of your project when deployed:
3 | http_path = "/"
4 | http_images_path = "/static/img/"
5 | css_dir = "../route53/static/css"
6 | sass_dir = "src"
7 | images_dir = "../route53/static/img"
8 | javascripts_dir = "javascripts"
9 | output_style = :compact
10 | line_comments = false
11 | # To enable relative paths to assets via compass helper functions. Uncomment:
12 | # relative_assets = true
13 | preferred_syntax = :sass
14 |
--------------------------------------------------------------------------------
/route53/templates/xml/change_batch.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {% if comment %}
5 | {{ comment }}
6 | {% endif %}
7 |
8 | {% for change in changes %}
9 | {% from "xml/_macros.xml" import change_item %}
10 | {{ change_item(change) }}
11 | {% endfor %}
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/route53/templates/zones/clone.html:
--------------------------------------------------------------------------------
1 | {% extends "zones/new.html" %}
2 |
3 | {% block breadcrumbs %}
4 | Zones
5 | Clone zone "{{ original_zone['Name'] }}"
6 | {% endblock %}
7 |
8 | {% block page_title %}Clone zone "{{ original_zone['Name'] }}"{% endblock %}
9 |
10 | {% block afterform %}
11 | {% if errors %}
12 | Zone was cloned with following errors
13 |
14 | {% for error in errors %}
15 | {{ error }}
16 | {% endfor %}
17 |
18 | {% endif %}
19 | {% endblock %}
20 |
--------------------------------------------------------------------------------
/route53/templates/slicehost/index.html:
--------------------------------------------------------------------------------
1 | {% extends "slicehost/base.html" %}
2 |
3 | {% block breadcrumbs %}
4 | Home
5 | Slicehost
6 | {% endblock %}
7 |
8 | {% block toolbar %}{% endblock %}
9 |
10 | {% block body %}
11 | Enter Slicehost API key
12 |
20 | {% endblock %}
21 |
--------------------------------------------------------------------------------
/sass/src/partials/_base.sass:
--------------------------------------------------------------------------------
1 | // Here is where you can define your constants for your application and to configure the blueprint framework.
2 | // Feel free to delete these if you want keep the defaults:
3 |
4 | $blueprint-grid-columns : 24
5 | $blueprint-container-size : 950px
6 | $blueprint-grid-margin : 10px
7 |
8 | // Use this to calculate the width based on the total width.
9 | // Or you can set $blueprint-grid-width to a fixed value and unset $blueprint-container-size -- it will be calculated for you.
10 | $blueprint-grid-width: ($blueprint-container-size + $blueprint-grid-margin) / $blueprint-grid-columns - $blueprint-grid-margin
11 |
--------------------------------------------------------------------------------
/sass/src/ie.sass:
--------------------------------------------------------------------------------
1 | @import blueprint
2 |
3 | // To generate css equivalent to the blueprint css but with your configuration applied, uncomment:
4 | // @include blueprint-ie
5 |
6 | //Recommended Blueprint configuration with scoping and semantic layout:
7 | body.bp
8 | +blueprint-ie(true)
9 | // Note: Blueprint centers text to fix IE6 container centering.
10 | // This means all your texts will be centered under all version of IE by default.
11 | // If your container does not have the .container class, don't forget to restore
12 | // the correct behavior to your main container (but not the body tag!)
13 | // Example:
14 | // .my-container
15 | // text-align: left
16 |
--------------------------------------------------------------------------------
/route53/templates/zones/delete.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block breadcrumbs %}
4 | Zones
5 | Delete {{ zone['Name'] }}
6 | {% endblock %}
7 |
8 | {% block body %}
9 | Delete zone "{{ zone['Name'] }}"
10 | Are you sure?
11 | {% if error %}
12 | {{ error.error_message }}
13 | {% endif %}
14 |
21 | {% endblock %}
22 |
--------------------------------------------------------------------------------
/route53/templates/zones/new.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block breadcrumbs %}
4 | Zones
5 | Add new zone
6 | {% endblock %}
7 |
8 | {% block body %}
9 | {% block page_title %}Add new zone{% endblock %}
10 |
22 | {% block afterform %}{% endblock %}
23 | {% endblock %}
24 |
--------------------------------------------------------------------------------
/route53/templates/slicehost/import_zone.html:
--------------------------------------------------------------------------------
1 | {% extends "slicehost/base.html" %}
2 |
3 | {% block breadcrumbs %}
4 | Home
5 | Slicehost Zones
6 | Slicehost Records for {{ zone['origin'] }}
7 | Errors in import for {{ zone['origin'] }}
8 | {% endblock %}
9 |
10 | {% block body %}
11 | Errors in import for {{ zone['origin'] }}
12 |
13 | {% for record_type, name, error in errors %}
14 | {{ record_type }} {{ name }} {{ error }}
15 | {% endfor %}
16 |
17 | Go to Route 53 zone list
18 | {% endblock %}
19 |
--------------------------------------------------------------------------------
/route53/templates/zones/list.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block breadcrumbs %}
4 | Zones
5 | {% endblock %}
6 |
7 | {% block body %}
8 | Zones
9 | Add new zone
10 |
20 | {% endblock %}
21 |
--------------------------------------------------------------------------------
/route53/templates/xml/_macros.xml:
--------------------------------------------------------------------------------
1 | {% macro change_item(change_object) %}
2 |
3 | {{ change_object.action }}
4 |
5 | {{ change_object.name }}
6 | {{ change_object.type }}
7 | {{ change_object.ttl }}
8 | {% if change_object.values['values'] %}
9 |
10 | {% for value in change_object.values['values'] %}
11 |
12 | {{ value }}
13 |
14 | {% endfor %}
15 |
16 | {% else %}
17 |
18 | {{ change_object.values['alias_hosted_zone_id'] }}
19 | {{ change_object.values['alias_dns_name'] }}
20 |
21 | {% endif %}
22 |
23 |
24 | {% endmacro %}
25 |
--------------------------------------------------------------------------------
/auth.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 | import authdigest
3 | import flask
4 |
5 |
6 | class FlaskRealmDigestDB(authdigest.RealmDigestDB):
7 | def requires_auth(self, f):
8 | @wraps(f)
9 | def decorated(*args, **kwargs):
10 | request = flask.request
11 | if not self.isAuthenticated(request):
12 | return self.challenge()
13 |
14 | return f(*args, **kwargs)
15 |
16 | return decorated
17 |
18 |
19 | class AuthMiddleware(object):
20 |
21 | def __init__(self, app, authDB):
22 | self.app = app
23 | self.authDB = authDB
24 |
25 | def __call__(self, environ, start_response):
26 | req = flask.Request(environ)
27 | if not self.authDB.isAuthenticated(req):
28 | response = self.authDB.challenge()
29 | return response(environ, start_response)
30 | return self.app(environ, start_response)
31 |
--------------------------------------------------------------------------------
/route53/templates/slicehost/records.html:
--------------------------------------------------------------------------------
1 | {% extends "slicehost/base.html" %}
2 |
3 | {% block breadcrumbs %}
4 | Home
5 | Slicehost Zones
6 | Slicehost Records for {{ zone['origin'] }}
7 | {% endblock %}
8 |
9 | {% block body %}
10 | Slicehost Records for {{ zone['origin'] }}
11 |
12 | {% for record_type, name, rcds in records %}
13 | {{ record_type }} {{ name }} {{ rcds[0].ttl }}
14 |
15 | {% for record in rcds %}
16 | {{ record['data'] }} {% if record['record_type'] == 'MX' %} {{ record['aux'] }}{% endif %}
17 | {% endfor %}
18 |
19 |
20 | {% endfor %}
21 |
22 |
25 | {% endblock %}
26 |
--------------------------------------------------------------------------------
/route53/xmltools.py:
--------------------------------------------------------------------------------
1 | try:
2 | import lxml.etree as etree
3 | except ImportError:
4 | try:
5 | import cElementTree as etree
6 | print "Using cElementTree"
7 | except ImportError:
8 | try:
9 | import elementtree.ElementTree as etree
10 | except ImportError:
11 | from xml.etree import ElementTree as etree
12 |
13 |
14 | NAMESPACE = "{https://route53.amazonaws.com/doc/2010-10-01/}"
15 | RECORDSET_TAG = NAMESPACE + 'ResourceRecordSet'
16 | NAME_TAG = NAMESPACE + 'Name'
17 | TYPE_TAG = NAMESPACE + 'Type'
18 | TTL_TAG = NAMESPACE + 'TTL'
19 | RECORD_TAG = NAMESPACE + 'ResourceRecord'
20 | VALUE_TAG = NAMESPACE + 'Value'
21 | RECORDS_TAG = NAMESPACE + 'ResourceRecords'
22 |
23 |
24 | def render_change_batch(context):
25 | from route53 import app
26 | template = app.jinja_env.get_template('xml/change_batch.xml')
27 | rendered_xml = template.render(context)
28 | return rendered_xml
29 |
--------------------------------------------------------------------------------
/route53/static/css/print.css:
--------------------------------------------------------------------------------
1 | body.bp { line-height: 1.5; font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; color: black; background: none; font-size: 10pt; }
2 | body.bp .container { background: none; }
3 | body.bp hr { background: #cccccc; color: #cccccc; width: 100%; height: 2px; margin: 2em 0; padding: 0; border: none; }
4 | body.bp hr.space { background: white; color: white; }
5 | body.bp h1, body.bp h2, body.bp h3, body.bp h4, body.bp h5, body.bp h6 { font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; }
6 | body.bp code { font-size: 0.9em; font-family: "andale mono", "lucida console", monospace; }
7 | body.bp a img { border: none; }
8 | body.bp a:link, body.bp a:visited { background: transparent; font-weight: 700; text-decoration: underline; }
9 | body.bp p img.top { margin-top: 0; }
10 | body.bp blockquote { margin: 1.5em; padding: 1em; font-style: italic; font-size: 0.9em; }
11 | body.bp .small { font-size: 0.9em; }
12 | body.bp .large { font-size: 1.1em; }
13 | body.bp .quiet { color: #999999; }
14 | body.bp .hide { display: none; }
15 |
--------------------------------------------------------------------------------
/route53/static/css/ie.css:
--------------------------------------------------------------------------------
1 | body.bp { text-align: center; }
2 | * html body.bp legend { margin: 0px -8px 16px 0; padding: 0; }
3 | html > body.bp p code { *white-space: normal; }
4 | body.bp .container { text-align: left; }
5 | body.bp sup { vertical-align: text-top; }
6 | body.bp sub { vertical-align: text-bottom; }
7 | body.bp hr { margin: -8px auto 11px; }
8 | body.bp img { -ms-interpolation-mode: bicubic; }
9 | body.bp fieldset { padding-top: 0; }
10 | body.bp textarea { overflow: auto; }
11 | body.bp input.text { margin: 0.5em 0; background-color: white; border: 1px solid #bbbbbb; }
12 | body.bp input.text:focus { border: 1px solid #666666; }
13 | body.bp input.title { margin: 0.5em 0; background-color: white; border: 1px solid #bbbbbb; }
14 | body.bp input.title:focus { border: 1px solid #666666; }
15 | body.bp input.checkbox { position: relative; top: 0.25em; }
16 | body.bp input.radio { position: relative; top: 0.25em; }
17 | body.bp input.button { position: relative; top: 0.25em; }
18 | body.bp textarea { margin: 0.5em 0; }
19 | body.bp select { margin: 0.5em 0; }
20 | body.bp button { position: relative; top: 0.25em; }
21 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2011 Andrii Kurinnyi
2 |
3 | Permission is hereby granted, free of charge, to any person
4 | obtaining a copy of this software and associated documentation
5 | files (the "Software"), to deal in the Software without
6 | restriction, including without limitation the rights to use,
7 | copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | copies of the Software, and to permit persons to whom the
9 | Software is furnished to do so, subject to the following
10 | conditions:
11 |
12 | The above copyright notice and this permission notice shall be
13 | included in all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22 | OTHER DEALINGS IN THE SOFTWARE.
23 |
--------------------------------------------------------------------------------
/route53/__init__.py:
--------------------------------------------------------------------------------
1 | import urllib
2 |
3 | from flask import Flask
4 |
5 | from route53.views.zones import zones
6 | from route53.views.main import main
7 | from route53.views.records import records
8 | from route53.views.slicehost import slicehost
9 |
10 | from auth import FlaskRealmDigestDB, AuthMiddleware
11 |
12 | app = Flask(__name__)
13 | app.register_module(main)
14 | app.register_module(zones, url_prefix='/zones')
15 | app.register_module(records, url_prefix='/records')
16 | app.register_module(slicehost, url_prefix='/slicehost')
17 |
18 | # load configuration
19 | app.config.from_pyfile('application.cfg')
20 |
21 |
22 | @app.template_filter('shortid')
23 | def shortid(s):
24 | return s.replace('/hostedzone/', '')
25 |
26 |
27 | @app.template_filter('urlencode')
28 | def urlencode(s):
29 | return urllib.quote(s, '/')
30 |
31 | #authentication
32 |
33 | auth_users = app.config.get('AUTH_USERS', None)
34 | if auth_users:
35 | authDB = FlaskRealmDigestDB('Route53Realm')
36 |
37 | for user,password in auth_users:
38 | authDB.add_user(user, password)
39 |
40 | app.wsgi_app = AuthMiddleware(app.wsgi_app, authDB)
41 |
42 | import route53.models
43 |
--------------------------------------------------------------------------------
/route53/templates/records/delete.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block breadcrumbs %}
4 | Zones
5 | Records for {{ zone['Name'] }}
6 | Delete record {{ val_dict['type'] }} {{ val_dict['name'] }}
7 | {% endblock %}
8 |
9 | {% block body %}
10 | Delete record
11 | {% if error %}
12 | {{ error.error_message }}
13 | {% endif %}
14 |
29 | {% endblock %}
30 |
--------------------------------------------------------------------------------
/route53/models.py:
--------------------------------------------------------------------------------
1 | import simplejson
2 |
3 | from route53 import app
4 |
5 | from flaskext.sqlalchemy import SQLAlchemy
6 |
7 | # initialize db
8 | db = SQLAlchemy(app)
9 |
10 | # Models
11 |
12 |
13 | class ChangeBatch(db.Model):
14 | __tablename__ = "change_batches"
15 |
16 | id = db.Column(db.Integer, primary_key=True)
17 | change_id = db.Column(db.String(255))
18 | status = db.Column(db.String(255))
19 | comment = db.Column(db.String(255))
20 |
21 | changes = db.relation("Change", backref="change_batch")
22 |
23 | def process_response(self, resp):
24 | change_info = resp['ChangeResourceRecordSetsResponse']['ChangeInfo']
25 | self.change_id = change_info['Id'][8:]
26 | self.status = change_info['Status']
27 |
28 |
29 | class Change(db.Model):
30 | __tablename__ = "changes"
31 |
32 | id = db.Column(db.Integer, primary_key=True)
33 | action = db.Column(db.String(255))
34 | name = db.Column(db.String(255))
35 | type = db.Column(db.String(255))
36 | ttl = db.Column(db.String(255))
37 | value = db.Column(db.String(255))
38 |
39 | change_batch_id = db.Column(db.Integer, db.ForeignKey("change_batches.id"))
40 |
41 | @property
42 | def values(self):
43 | return simplejson.loads(self.value)
44 |
45 | @values.setter
46 | def values(self, values):
47 | self.value = simplejson.dumps(values)
48 |
--------------------------------------------------------------------------------
/route53/templates/records/update.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block breadcrumbs %}
4 | Zones
5 | Records for {{ zone['Name'] }}
6 | Update record {{ val_dict['type'] }} {{ val_dict['name'] }}
7 | {% endblock %}
8 |
9 | {% block body %}
10 | Update record
11 | {% if error %}
12 | {{ error.error_message }}
13 | {% endif %}
14 |
35 | {% endblock %}
36 |
--------------------------------------------------------------------------------
/route53/templates/records/new.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block breadcrumbs %}
4 | Zones
5 | Records for {{ zone['Name'] }}
6 | Add new record
7 | {% endblock %}
8 |
9 | {% block body %}
10 | Add new record
11 | You can use semicolon ";" to separate values in the "Value" field. It allows you to create recordset with several records.
12 | Examples
13 | Assign multiple IPs to one A record for round-robin DNS: 192.168.1.1;192.168.1.2
14 | Configure MX records for Google Apps: 1 ASPMX.L.GOOGLE.COM;5 ALT1.ASPMX.L.GOOGLE.COM;5 ALT2.ASPMX.L.GOOGLE.COM;10 ASPMX2.GOOGLEMAIL.COM;10 ASPMX3.GOOGLEMAIL.COM
15 | {% if error %}
16 | {{ error.error_message }}
17 | {% endif %}
18 |
33 | {% endblock %}
34 |
--------------------------------------------------------------------------------
/route53/templates/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Route 53 Manager
5 |
6 |
7 |
10 |
11 |
12 |
13 |
26 |
27 |
28 | {% block breadcrumbs %}
29 | {% endblock %}
30 |
31 |
32 |
33 | {% with messages = get_flashed_messages() %}
34 | {% if messages %}
35 |
36 | {% for message in messages %}
37 | {{ message }}
38 | {% endfor %}
39 |
40 | {% endif %}
41 | {% endwith %}
42 |
43 |
44 |
45 | {% block body %}
46 | {% endblock %}
47 |
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/route53/templates/zones/records.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block breadcrumbs %}
4 | Zones
5 | Records for {{ zone['Name'] }}
6 | {% endblock %}
7 |
8 | {% block body %}
9 | Hosted Zone "{{ zone['Name'] }}"
10 | Add new record
11 | {% if groups %}
12 | {% for groupname, recordsets in groups %}
13 | {{ groupname }}
14 |
15 | {% for recordset in recordsets %}
16 | {{ recordset.name }} {{ recordset.ttl }}
17 | {% if recordset.resource_records %}
18 | Delete
19 | Update
20 | {% else %}
21 | (This record cannot be edited)
22 | {% endif %}
23 |
24 | {% for value in recordset.resource_records %}
25 | {{ value }}
26 | {% endfor %}
27 | {% if recordset.alias_dns_name and recordset.alias_hosted_zone_id %}
28 | {{ recordset.to_print() }}
29 | {% endif %}
30 |
31 |
32 | {% endfor %}
33 |
34 | {% endfor %}
35 | {% endif %}
36 | {% endblock %}
37 |
--------------------------------------------------------------------------------
/route53/forms.py:
--------------------------------------------------------------------------------
1 | from flaskext import wtf
2 | from flaskext.wtf import validators
3 |
4 |
5 | RECORD_CHOICES = [
6 | ('A', 'A'),
7 | ('AAAA', 'AAAA'),
8 | ('CNAME', 'CNAME'),
9 | ('MX', 'MX'),
10 | ('NS', 'NS'),
11 | ('PTR', 'PTR'),
12 | ('SOA', 'SOA'),
13 | ('SPF', 'SPF'),
14 | ('SRV', 'SRV'),
15 | ('TXT', 'TXT'),
16 | ]
17 |
18 |
19 | class ZoneForm(wtf.Form):
20 | name = wtf.TextField('Domain Name', validators=[validators.Required()])
21 | comment = wtf.TextAreaField('Comment')
22 |
23 |
24 | class RecordForm(wtf.Form):
25 | type = wtf.SelectField("Type", choices=RECORD_CHOICES)
26 | name = wtf.TextField("Name", validators=[validators.Required()])
27 | value = wtf.TextField("Value", validators=[validators.Required()])
28 | ttl = wtf.IntegerField("TTL", default="86400",
29 | validators=[validators.Required()])
30 | comment = wtf.TextAreaField("Comment")
31 |
32 | @property
33 | def values(self):
34 | if self.type.data != 'TXT':
35 | return filter(lambda x: x,
36 | map(lambda x: x.strip(),
37 | self.value.data.strip().split(';')))
38 | else:
39 | return [self.value.data.strip()]
40 |
41 | class RecordAliasForm(wtf.Form):
42 | type = wtf.SelectField("Type", choices=RECORD_CHOICES)
43 | name = wtf.TextField("Name", validators=[validators.Required()])
44 | alias_hosted_zone_id = wtf.TextField("Alias hosted zone ID", validators=[validators.Required()])
45 | alias_dns_name = wtf.TextField("Alias DNS name", validators=[validators.Required()])
46 | ttl = wtf.IntegerField("TTL", default="86400",
47 | validators=[validators.Required()])
48 | comment = wtf.TextAreaField("Comment")
49 |
50 | class APIKeyForm(wtf.Form):
51 | key = wtf.TextField('API Key', validators=[validators.Required()])
52 |
--------------------------------------------------------------------------------
/sass/src/screen.sass:
--------------------------------------------------------------------------------
1 | // This import applies a global reset to any page that imports this stylesheet.
2 | @import blueprint/reset
3 | // To configure blueprint, edit the partials/base.sass file.
4 | @import partials/base
5 | // Import all the default blueprint modules so that we can access their mixins.
6 | @import blueprint
7 | // Import the non-default scaffolding module.
8 | @import blueprint/scaffolding
9 | @import "blueprint/buttons"
10 |
11 | @import "compass/utilities/lists"
12 | @import "compass/utilities/links"
13 | @import "compass/css3"
14 |
15 | // To generate css equivalent to the blueprint css but with your
16 | // configuration applied, uncomment:
17 | // @include blueprint
18 |
19 | // But Compass recommends that you scope your blueprint styles
20 | // So that you can better control what pages use blueprint
21 | // when stylesheets are concatenated together.
22 |
23 | +blueprint-scaffolding("body.bp")
24 | body.bp
25 | +blueprint-typography(true)
26 | +blueprint-utilities
27 | +blueprint-debug
28 | +blueprint-interaction
29 | // Remove the scaffolding when you're ready to start doing visual design.
30 | // Or leave it in if you're happy with how blueprint looks out-of-the-box
31 | .container
32 | +container
33 | +box-shadow
34 |
35 | .heading
36 | $heading-columns: $blueprint-grid-columns - 1
37 | +column($heading-columns, true)
38 | padding: 20px 20px 0
39 | min-height: 60px
40 | border-bottom: 1px solid #222
41 | h1
42 | $sidebar-columns: floor($blueprint-grid-columns / 3)
43 | +column($sidebar-columns)
44 | a
45 | +hover-link
46 | color: $font-color
47 | .toolbar
48 | $content-columns: ceil(2 * $blueprint-grid-columns / 3) - 1
49 | +column($content-columns, true)
50 | ul
51 | +float-right
52 | +horizontal-list
53 |
54 | .breadcrumbs
55 | clear: both
56 | border-bottom: 1px solid #555
57 | ul
58 | overflow: hidden
59 | +horizontal-list(10px)
60 | margin-left: 20px
61 | padding: 0.5em 0
62 | a
63 | +hover-link
64 | li
65 | border-right: 1px solid #222
66 | &:last-child, &.last
67 | border-right: none
68 |
69 | .messages
70 | clear: both
71 | +column($blueprint-grid-columns)
72 | .flashes
73 | padding-left: 20px
74 | margin-bottom: 0
75 | margin-top: 1.5em
76 | li
77 | list-style-type: none
78 | +notice
79 | margin-bottom: 0
80 |
81 | .body
82 | clear: both
83 | padding: 2em 20px
84 | $body-columns: $blueprint-grid-columns - 1
85 | +column($body-columns)
86 |
87 | a.button
88 | +anchor-button
89 |
90 | button
91 | +button-button
92 |
93 | form.bp
94 | +blueprint-form
95 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | Route 53 Manager
2 | ----------------
3 |
4 | Simple GUI for Route 53 Amazon Cloud DNS service written using Flask and
5 | boto.
6 |
7 | Features
8 | ========
9 |
10 | * Create/delete hosted zones
11 | * Create/update/delete records
12 | * Manipulate recordsets
13 | * Stores change log in the SQL database
14 | * Optional Digest Authentication
15 | * Import DNS records from Slicehost one zone at a time
16 | * Clone DNS zone with all rules, but new domain
17 |
18 | Route 53 Manager is meant to be running locally, on user's machine, or local
19 | network behind the firewall. It allows you to manage DNS zones and records
20 | for AWS credentials specified in application.cfg
21 | (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
22 |
23 | Since you are running the app, you don't need to pass your AWS credentials to
24 | any third party website.
25 |
26 | How to install
27 | ==============
28 |
29 | Clone the project from GitHub
30 |
31 | ::
32 |
33 | git clone git://github.com/zen4ever/route53manager.git
34 | cd route53manager
35 |
36 | Install dependencies
37 |
38 | ::
39 |
40 | pip install -r requirements.txt
41 |
42 | Create config file
43 |
44 | ::
45 |
46 | cp route53/application.cfg.example route53/application.cfg
47 |
48 | Add your AWS credentials to newly created "application.cfg"
49 |
50 | Create empty database and run development server
51 |
52 | ::
53 |
54 | ./create_db.py
55 | ./runserver.py
56 |
57 | Visit http://127.0.0.1:5000/ in your favorite browser
58 |
59 | Authentication
60 | ==============
61 |
62 | If you want to run Route53Manager on intranet open only to certain users,
63 | you can enable digest authentication. Define AUTH_USERS variable in your
64 | route53/application.cfg like this:
65 |
66 | ::
67 |
68 | AUTH_USERS = [
69 | ('admin', 'admin_password'),
70 | ('test', 'secret_password'),
71 | ]
72 |
73 | FAQ
74 | ===
75 |
76 | 1. Which Python version is supported by route53manager?
77 |
78 | Flask `documentation `_ says that Python2.5+ is required. Some of the plugins might use Python 2.6 specific features, so, recommended version is Python 2.6+.
79 |
80 | 2. How do I make Route53Manager accessible to requests from other machines on
81 | my local network?
82 |
83 | By default runserver.py spins a local development server which listens to
84 | 127.0.0.1 IP address, which is not accessible from other machines on your
85 | network. You can use your machine's IP address (e.g. 192.168.1.15) or
86 | 0.0.0.0 in runserver.py
87 |
88 | ::
89 |
90 | app.run(host="0.0.0.0")
91 |
92 | so your dev server will listen to external requests.
93 |
94 | You can also use some other WSGI server like Gunicorn.
95 |
96 | ::
97 |
98 | pip install gunicorn
99 | gunicorn route53:app -b 0.0.0.0:8000
100 |
--------------------------------------------------------------------------------
/route53/views/slicehost.py:
--------------------------------------------------------------------------------
1 | from boto.route53.exception import DNSServerError
2 | from functools import wraps
3 | from itertools import groupby
4 |
5 | from flask import Module, session, redirect, url_for, render_template, request
6 |
7 | from pyactiveresource.activeresource import ActiveResource
8 |
9 | from route53.connection import get_connection
10 | from route53.forms import APIKeyForm
11 | from route53.xmltools import render_change_batch
12 |
13 | slicehost = Module(__name__)
14 |
15 | API_KEY = 'slicehost_api_key'
16 | API_URL = 'https://%s@api.slicehost.com/'
17 |
18 |
19 | def get_zone_class():
20 | class Zone(ActiveResource):
21 | _site = API_URL % session[API_KEY]
22 | return Zone
23 |
24 |
25 | def get_record_class():
26 | class Record(ActiveResource):
27 | _site = API_URL % session[API_KEY]
28 | return Record
29 |
30 |
31 | def requires_key(f):
32 | @wraps(f)
33 | def decorated(*args, **kwargs):
34 | if not API_KEY in session:
35 | return redirect(url_for('slicehost.index'))
36 | return f(*args, **kwargs)
37 | return decorated
38 |
39 |
40 | @slicehost.route('/', methods=['GET', 'POST'])
41 | def index():
42 | if 'clean' in request.args:
43 | del session[API_KEY]
44 | if API_KEY in session:
45 | return redirect(url_for('slicehost.zones'))
46 | form = APIKeyForm()
47 | if form.validate_on_submit():
48 | session[API_KEY] = form.key.data
49 | return redirect(url_for('slicehost.zones'))
50 | return render_template('slicehost/index.html', form=form)
51 |
52 |
53 | @slicehost.route('/zones')
54 | @requires_key
55 | def zones():
56 | Zone = get_zone_class()
57 | zones = Zone.find()
58 | return render_template('slicehost/zones.html', zones=zones)
59 |
60 |
61 | @slicehost.route('/zones/')
62 | @requires_key
63 | def records(zone_id):
64 | Zone = get_zone_class()
65 | zone = Zone.find(zone_id)
66 | Record = get_record_class()
67 | records = Record.find(zone_id=zone_id)
68 | records = sorted(records, key=lambda x: x.record_type)
69 | results = []
70 | for k, g in groupby(records, key=lambda x: (x.record_type, x.name)):
71 | record_type, name = k
72 | results.append((record_type, name, list(g)))
73 | return render_template('slicehost/records.html', zone=zone, records=results)
74 |
75 |
76 | @slicehost.route('/zones//import', methods=['GET', 'POST'])
77 | @requires_key
78 | def import_zone(zone_id):
79 | from route53.models import ChangeBatch, Change, db
80 |
81 | Zone = get_zone_class()
82 | zone = Zone.find(zone_id)
83 | Record = get_record_class()
84 |
85 | # filter out NS records
86 | records = filter(lambda x: x.record_type != 'NS', Record.find(zone_id=zone_id))
87 |
88 | records = sorted(records, key=lambda x: x.record_type)
89 |
90 | # order records by record_type and name into recordsets
91 |
92 | conn = get_connection()
93 | response = conn.create_hosted_zone(zone.origin)
94 | info = response['CreateHostedZoneResponse']
95 | new_zone_id = info['HostedZone']['Id']
96 |
97 | errors = []
98 |
99 | for k, g in groupby(records, key=lambda x: (x.record_type, x.name)):
100 | change_batch = ChangeBatch(change_id='',
101 | status='created',
102 | comment='')
103 |
104 | db.session.add(change_batch)
105 | record_type, name = k
106 | rcds = list(g)
107 | record_name = zone.origin in name and name or name + "." + zone.origin
108 |
109 | if record_type not in ('MX', 'SRV'):
110 | values = map(lambda x: x.data, rcds)
111 | else:
112 | values = map(lambda x: "%s %s" % (x.aux, x.data), rcds)
113 | change = Change(action="CREATE",
114 | name=record_name,
115 | type=record_type,
116 | ttl=rcds[0].ttl,
117 | values={'values':values},
118 | change_batch_id=change_batch.id)
119 | db.session.add(change)
120 | changes = [change]
121 |
122 | rendered_xml = render_change_batch({'changes': changes, 'comment': ''})
123 |
124 | try:
125 | from route53 import shortid
126 | resp = conn.change_rrsets(shortid(new_zone_id), rendered_xml)
127 | change_batch.process_response(resp)
128 | db.session.commit()
129 | except DNSServerError as error:
130 | errors.append((record_type, name, error))
131 | db.session.rollback()
132 |
133 | if errors:
134 | return render_template('slicehost/import_zone.html',
135 | errors=errors,
136 | zone=zone)
137 |
138 | return redirect(url_for('main.index'))
139 |
--------------------------------------------------------------------------------
/route53/views/zones.py:
--------------------------------------------------------------------------------
1 | from boto.route53.exception import DNSServerError
2 | from itertools import groupby
3 | from flask import Module
4 |
5 | from flask import url_for, render_template, \
6 | redirect, flash, request
7 |
8 | from route53.forms import ZoneForm
9 | from route53.connection import get_connection
10 |
11 | from route53.xmltools import render_change_batch
12 |
13 | zones = Module(__name__)
14 |
15 |
16 | @zones.route('/')
17 | def zones_list():
18 | conn = get_connection()
19 | response = conn.get_all_hosted_zones()
20 | zones = response['ListHostedZonesResponse']['HostedZones']
21 | return render_template('zones/list.html', zones=zones)
22 |
23 |
24 | @zones.route('/new', methods=['GET', 'POST'])
25 | def zones_new():
26 | conn = get_connection()
27 |
28 | form = ZoneForm()
29 | if form.validate_on_submit():
30 | response = conn.create_hosted_zone(
31 | form.name.data,
32 | comment=form.comment.data)
33 |
34 | info = response['CreateHostedZoneResponse']
35 |
36 | nameservers = ', '.join(info['DelegationSet']['NameServers'])
37 | zone_id = info['HostedZone']['Id']
38 |
39 | flash(u"A zone with id %s has been created. "
40 | u"Use following nameservers %s"
41 | % (zone_id, nameservers))
42 |
43 | return redirect(url_for('zones_list'))
44 | return render_template('zones/new.html', form=form)
45 |
46 |
47 | @zones.route('//delete', methods=['GET', 'POST'])
48 | def zones_delete(zone_id):
49 | conn = get_connection()
50 | zone = conn.get_hosted_zone(zone_id)['GetHostedZoneResponse']['HostedZone']
51 |
52 | error = None
53 |
54 | if request.method == 'POST' and 'delete' in request.form:
55 | try:
56 | conn.delete_hosted_zone(zone_id)
57 |
58 | flash(u"A zone with id %s has been deleted" % zone_id)
59 |
60 | return redirect(url_for('zones_list'))
61 | except DNSServerError as error:
62 | error = error
63 | return render_template('zones/delete.html',
64 | zone_id=zone_id,
65 | zone=zone,
66 | error=error)
67 |
68 |
69 | @zones.route('/')
70 | def zones_detail(zone_id):
71 | conn = get_connection()
72 | resp = conn.get_hosted_zone(zone_id)
73 | zone = resp['GetHostedZoneResponse']['HostedZone']
74 | nameservers = resp['GetHostedZoneResponse']['DelegationSet']['NameServers']
75 |
76 | return render_template('zones/detail.html',
77 | zone_id=zone_id,
78 | zone=zone,
79 | nameservers=nameservers)
80 |
81 |
82 | @zones.route('//records')
83 | def zones_records(zone_id):
84 | conn = get_connection()
85 | resp = conn.get_hosted_zone(zone_id)
86 | zone = resp['GetHostedZoneResponse']['HostedZone']
87 |
88 | record_resp = sorted(conn.get_all_rrsets(zone_id), key=lambda x: x.type)
89 |
90 | groups = groupby(record_resp, key=lambda x: x.type)
91 |
92 | groups = [(k, list(v)) for k, v in groups]
93 |
94 | return render_template('zones/records.html',
95 | zone_id=zone_id,
96 | zone=zone,
97 | groups=groups)
98 |
99 |
100 | @zones.route('/clone/', methods=['GET', 'POST'])
101 | def zones_clone(zone_id):
102 | conn = get_connection()
103 |
104 | zone_response = conn.get_hosted_zone(zone_id)
105 | original_zone = zone_response['GetHostedZoneResponse']['HostedZone']
106 |
107 | form = ZoneForm()
108 | errors = []
109 |
110 | if form.validate_on_submit():
111 | response = conn.create_hosted_zone(
112 | form.name.data,
113 | comment=form.comment.data)
114 |
115 | info = response['CreateHostedZoneResponse']
116 |
117 | nameservers = ', '.join(info['DelegationSet']['NameServers'])
118 |
119 | new_zone_id = info['HostedZone']['Id']
120 |
121 | original_records = conn.get_all_rrsets(zone_id)
122 |
123 | from route53.models import ChangeBatch, Change, db
124 |
125 | for recordset in original_records:
126 | if not recordset.type in ["SOA", "NS"]:
127 |
128 | change_batch = ChangeBatch(change_id='',
129 | status='created',
130 | comment='')
131 | db.session.add(change_batch)
132 | change = Change(action="CREATE",
133 | name=recordset.name.replace(original_zone['Name'],
134 | form.name.data),
135 | type=recordset.type,
136 | ttl=recordset.ttl,
137 | values = recordset.resource_records,
138 | change_batch_id=change_batch.id)
139 |
140 | db.session.add(change)
141 | changes = [change]
142 |
143 | rendered_xml = render_change_batch({'changes': changes, 'comment': ''})
144 |
145 | try:
146 | from route53 import shortid
147 | resp = conn.change_rrsets(shortid(new_zone_id), rendered_xml)
148 | change_batch.process_response(resp)
149 | db.session.commit()
150 | except DNSServerError as error:
151 | errors.append((recordset.type, recordset.name, error))
152 | db.session.rollback()
153 |
154 | if not errors:
155 | flash(u"A zone with id %s has been created. "
156 | u"Use following nameservers %s"
157 | % (new_zone_id, nameservers))
158 | return redirect(url_for('zones_list'))
159 |
160 | return render_template('zones/clone.html',
161 | form=form, errors=errors, original_zone=original_zone)
162 |
--------------------------------------------------------------------------------
/route53/views/records.py:
--------------------------------------------------------------------------------
1 | from boto.route53.exception import DNSServerError
2 | from flask import Module, redirect, url_for, render_template, request, abort
3 |
4 | from route53.forms import RecordForm
5 | from route53.connection import get_connection
6 | from route53.xmltools import render_change_batch
7 |
8 |
9 | records = Module(__name__)
10 |
11 |
12 | @records.route('//new', methods=['GET', 'POST'])
13 | def records_new(zone_id):
14 | from route53.models import ChangeBatch, Change, db
15 | conn = get_connection()
16 | zone = conn.get_hosted_zone(zone_id)['GetHostedZoneResponse']['HostedZone']
17 | form = RecordForm()
18 | error = None
19 | if form.validate_on_submit():
20 | change_batch = ChangeBatch(change_id='',
21 | status='created',
22 | comment=form.comment.data)
23 | db.session.add(change_batch)
24 | change = Change(action="CREATE",
25 | name=form.name.data,
26 | type=form.type.data,
27 | ttl=form.ttl.data,
28 | values={'values': form.values},
29 | change_batch_id=change_batch.id)
30 | db.session.add(change)
31 | rendered_xml = render_change_batch({'changes': [change],
32 | 'comment': change_batch.comment})
33 | try:
34 | resp = conn.change_rrsets(zone_id, rendered_xml)
35 | change_batch.process_response(resp)
36 | db.session.commit()
37 | return redirect(url_for('zones.zones_records', zone_id=zone_id))
38 | except DNSServerError as error:
39 | error = error
40 | db.session.rollback()
41 | return render_template('records/new.html',
42 | form=form,
43 | zone=zone,
44 | zone_id=zone_id,
45 | error=error)
46 |
47 |
48 | def get_record_fields():
49 | fields = [
50 | 'name',
51 | 'type',
52 | 'ttl',
53 | ]
54 | val_dict = {}
55 | for field in fields:
56 | if request.method == "GET":
57 | result = request.args.get(field, None)
58 | elif request.method == "POST":
59 | result = request.form.get("data_"+field, None)
60 | if result is None:
61 | abort(404)
62 | val_dict[field] = result
63 | return val_dict
64 |
65 |
66 | @records.route('//delete', methods=['GET', 'POST'])
67 | def records_delete(zone_id):
68 | from route53.models import ChangeBatch, Change, db
69 | conn = get_connection()
70 | zone = conn.get_hosted_zone(zone_id)['GetHostedZoneResponse']['HostedZone']
71 | val_dict = get_record_fields()
72 |
73 | if request.method == "GET":
74 | values = request.args.getlist('value')
75 | alias_hosted_zone_id = request.args.get('alias_hosted_zone_id', None)
76 | alias_dns_name = request.args.get('alias_dns_name', None)
77 | if not values and not alias_hosted_zone_id and not alias_dns_name:
78 | abort(404)
79 |
80 | error = None
81 | if request.method == "POST":
82 | change_batch = ChangeBatch(change_id='', status='created', comment='')
83 | db.session.add(change_batch)
84 | values = request.form.getlist('data_value')
85 | alias_hosted_zone_id = request.form.get('data_alias_hosted_zone_id', None)
86 | alias_dns_name = request.form.get('data_alias_dns_name', None)
87 | change = Change(action="DELETE",
88 | change_batch_id=change_batch.id,
89 | values={
90 | 'values': values,
91 | 'alias_hosted_zone_id': alias_hosted_zone_id,
92 | 'alias_dns_name': alias_dns_name,
93 | },
94 | **val_dict)
95 | db.session.add(change)
96 | rendered_xml = render_change_batch({'changes': [change],
97 | 'comment': change_batch.comment})
98 | try:
99 | resp = conn.change_rrsets(zone_id, rendered_xml)
100 | change_batch.process_response(resp)
101 | db.session.commit()
102 | return redirect(url_for('zones.zones_records', zone_id=zone_id))
103 | except DNSServerError as error:
104 | error = error
105 | return render_template('records/delete.html',
106 | val_dict=val_dict,
107 | values=values,
108 | alias_hosted_zone_id=alias_hosted_zone_id,
109 | alias_dns_name=alias_dns_name,
110 | zone=zone,
111 | zone_id=zone_id,
112 | error=error)
113 |
114 |
115 | @records.route('//update', methods=['GET', 'POST'])
116 | def records_update(zone_id):
117 | from route53.models import ChangeBatch, Change, db
118 | conn = get_connection()
119 | zone = conn.get_hosted_zone(zone_id)['GetHostedZoneResponse']['HostedZone']
120 | val_dict = get_record_fields()
121 |
122 | if request.method == "GET":
123 | values = request.args.getlist('value')
124 | if not values:
125 | abort(404)
126 | initial_data = dict(val_dict)
127 | initial_data['value'] = ';'.join(values)
128 | form = RecordForm(**initial_data)
129 |
130 | error = None
131 | if request.method == "POST":
132 | form = RecordForm()
133 | change_batch = ChangeBatch(change_id='', status='created', comment=form.comment.data)
134 | db.session.add(change_batch)
135 | values = request.form.getlist('data_value')
136 | delete_change = Change(action="DELETE",
137 | change_batch_id=change_batch.id,
138 | values={'values': values},
139 | **val_dict)
140 | create_change = Change(action="CREATE",
141 | change_batch_id=change_batch.id,
142 | values={'values': form.values},
143 | type=form.type.data,
144 | ttl=form.ttl.data,
145 | name=form.name.data)
146 | db.session.add(delete_change)
147 | db.session.add(create_change)
148 | rendered_xml = render_change_batch({'changes': [delete_change, create_change],
149 | 'comment': change_batch.comment})
150 | try:
151 | resp = conn.change_rrsets(zone_id, rendered_xml)
152 | change_batch.process_response(resp)
153 | db.session.commit()
154 | return redirect(url_for('zones.zones_records', zone_id=zone_id))
155 | except DNSServerError as error:
156 | error = error
157 | return render_template('records/update.html',
158 | val_dict=val_dict,
159 | values=values,
160 | form=form,
161 | zone=zone,
162 | zone_id=zone_id,
163 | error=error)
164 |
--------------------------------------------------------------------------------
/authdigest.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """
3 | werkzeug.contrib.authdigest
4 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~
5 |
6 | The authdigest module contains classes to support
7 | digest authentication compliant with RFC 2617.
8 |
9 |
10 | Usage
11 | =====
12 |
13 | ::
14 |
15 | from werkzeug.contrib.authdigest import RealmDigestDB
16 |
17 | authDB = RealmDigestDB('test-realm')
18 | authDB.add_user('admin', 'test')
19 |
20 | def protectedResource(environ, start_reponse):
21 | request = Request(environ)
22 | if not authDB.isAuthenticated(request):
23 | return authDB.challenge()
24 |
25 | return get_protected_response(request)
26 |
27 | :copyright: (c) 2010 by the Werkzeug Team, see AUTHORS for more details.
28 | :license: BSD, see LICENSE for more details.
29 | """
30 |
31 | #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
32 | #~ Imports
33 | #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
34 |
35 | import os
36 | import weakref
37 | import hashlib
38 | import werkzeug
39 |
40 | #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
41 | #~ Realm Digest Credentials Database
42 | #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
43 |
44 | class RealmDigestDB(object):
45 | """Database mapping user to hashed password.
46 |
47 | Passwords are hashed using realm key, and specified
48 | digest algorithm.
49 |
50 | :param realm: string identifing the hashing realm
51 | :param algorthm: string identifying hash algorithm to use,
52 | default is 'md5'
53 | """
54 |
55 | def __init__(self, realm, algorithm='md5'):
56 | self.realm = realm
57 | self.alg = self.newAlgorithm(algorithm)
58 | self.db = self.newDB()
59 |
60 | @property
61 | def algorithm(self):
62 | return self.alg.algorithm
63 |
64 | def toDict(self):
65 | r = {'cfg':{ 'algorithm': self.alg.algorithm,
66 | 'realm': self.realm},
67 | 'db': self.db, }
68 | return r
69 | def toJson(self, **kw):
70 | import json
71 | kw.setdefault('sort_keys', True)
72 | kw.setdefault('indent', 2)
73 | return json.dumps(self.toDict(), **kw)
74 |
75 | def add_user(self, user, password):
76 | r = self.alg.hashPassword(user, self.realm, password)
77 | self.db[user] = r
78 | return r
79 |
80 | def __contains__(self, user):
81 | return user in self.db
82 | def get(self, user, default=None):
83 | return self.db.get(user, default)
84 | def __getitem__(self, user):
85 | return self.db.get(user)
86 | def __setitem__(self, user, password):
87 | return self.add_user(user, password)
88 | def __delitem__(self, user):
89 | return self.db.pop(user, None)
90 |
91 | #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
92 |
93 | def newDB(self):
94 | return dict()
95 | def newAlgorithm(self, algorithm):
96 | return DigestAuthentication(algorithm)
97 |
98 | def isAuthenticated(self, request, **kw):
99 | authResult = AuthenticationResult(self)
100 | request.authentication = authResult
101 |
102 | authorization = request.authorization
103 | if authorization is None:
104 | return authResult.deny('initial', None)
105 | authorization.result = authResult
106 |
107 | hashPass = self[authorization.username]
108 | if hashPass is None:
109 | return authResult.deny('unknown_user')
110 | elif not self.alg.verify(authorization, hashPass, method=request.method, **kw):
111 | return authResult.deny('invalid_password')
112 | else:
113 | return authResult.approve('success')
114 |
115 | challenge_class = werkzeug.Response
116 | def challenge(self, response=None, status=401):
117 | try:
118 | authReq = response.www_authenticate
119 | except AttributeError:
120 | response = self.challenge_class(response, status)
121 | authReq = response.www_authenticate
122 | else:
123 | if isinstance(status, (int, long)):
124 | response.status_code = status
125 | else: response.status = status
126 |
127 | authReq.set_digest(self.realm, os.urandom(8).encode('hex'))
128 | return response
129 |
130 |
131 | #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
132 | #~ Authentication Result
133 | #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
134 |
135 | class AuthenticationResult(object):
136 | """Authentication Result object
137 |
138 | Created by RealmDigestDB.isAuthenticated to operate as a boolean result,
139 | and storage of authentication information."""
140 |
141 | authenticated = None
142 | reason = None
143 | status = 500
144 |
145 | def __init__(self, authDB):
146 | self.authDB = weakref.ref(authDB)
147 |
148 | def __repr__(self):
149 | return '' % (
150 | self.authenticated, self.reason)
151 | def __nonzero__(self):
152 | return bool(self.authenticated)
153 |
154 | def deny(self, reason, authenticated=False):
155 | if bool(authenticated):
156 | raise ValueError("Denied authenticated parameter must evaluate as False")
157 | self.authenticated = authenticated
158 | self.reason = reason
159 | self.status = 401
160 | return self
161 |
162 | def approve(self, reason, authenticated=True):
163 | if not bool(authenticated):
164 | raise ValueError("Approved authenticated parameter must evaluate as True")
165 | self.authenticated = authenticated
166 | self.reason = reason
167 | self.status = 200
168 | return self
169 |
170 | def challenge(self, response=None, force=False):
171 | if force or not self:
172 | return self.authDB().challenge(response, self.status)
173 |
174 |
175 | #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
176 | #~ Digest Authentication Algorithm
177 | #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
178 |
179 | class DigestAuthentication(object):
180 | """Digest Authentication implementation.
181 |
182 | references:
183 | "HTTP Authentication: Basic and Digest Access Authentication". RFC 2617.
184 | http://tools.ietf.org/html/rfc2617
185 |
186 | "Digest access authentication"
187 | http://en.wikipedia.org/wiki/Digest_access_authentication
188 | """
189 |
190 | def __init__(self, algorithm='md5'):
191 | self.algorithm = algorithm.lower()
192 | self.H = self.hashAlgorithms[self.algorithm]
193 |
194 | def verify(self, authorization, hashPass=None, **kw):
195 | reqResponse = self.digest(authorization, hashPass, **kw)
196 | if reqResponse:
197 | return (authorization.response.lower() == reqResponse.lower())
198 |
199 | def digest(self, authorization, hashPass=None, **kw):
200 | if authorization is None:
201 | return None
202 |
203 | if hashPass is None:
204 | hA1 = self._compute_hA1(authorization, kw['password'])
205 | else: hA1 = hashPass
206 |
207 | hA2 = self._compute_hA2(authorization, kw.pop('method', 'GET'))
208 |
209 | if 'auth' in authorization.qop:
210 | res = self._compute_qop_auth(authorization, hA1, hA2)
211 | elif not authorization.qop:
212 | res = self._compute_qop_empty(authorization, hA1, hA2)
213 | else:
214 | raise ValueError("Unsupported qop: %r" % (authorization.qop,))
215 | return res
216 |
217 | def hashPassword(self, username, realm, password):
218 | return self.H(username, realm, password)
219 |
220 | def _compute_hA1(self, auth, password=None):
221 | return self.hashPassword(auth.username, auth.realm, password or auth.password)
222 | def _compute_hA2(self, auth, method):
223 | return self.H(method, auth.uri)
224 | def _compute_qop_auth(self, auth, hA1, hA2):
225 | return self.H(hA1, auth.nonce, auth.nc, auth.cnonce, auth.qop, hA2)
226 | def _compute_qop_empty(self, auth, hA1, hA2):
227 | return self.H(hA1, auth.nonce, hA2)
228 |
229 | #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
230 |
231 | hashAlgorithms = {}
232 |
233 | @classmethod
234 | def addDigestHashAlg(klass, key, hashObj):
235 | key = key.lower()
236 | def H(*args):
237 | x = ':'.join(map(str, args))
238 | return hashObj(x).hexdigest()
239 |
240 | H.__name__ = "H_"+key
241 | klass.hashAlgorithms[key] = H
242 | return H
243 |
244 | DigestAuthentication.addDigestHashAlg('md5', hashlib.md5)
245 | DigestAuthentication.addDigestHashAlg('sha', hashlib.sha1)
246 |
--------------------------------------------------------------------------------
/route53/static/css/screen.css:
--------------------------------------------------------------------------------
1 | @charset "UTF-8";
2 | html, body { margin: 0; padding: 0; border: 0; font-weight: inherit; font-style: inherit; font-size: 100%; font-family: inherit; vertical-align: baseline; }
3 |
4 | html { font-size: 100.01%; }
5 |
6 | div, span, object, iframe, h1, h2, h3, h4, h5, h6, p, pre, a, abbr, acronym, address, code, del, dfn, em, img, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, caption, tbody, tfoot, thead, tr { margin: 0; padding: 0; border: 0; font-weight: inherit; font-style: inherit; font-size: 100%; font-family: inherit; vertical-align: baseline; }
7 |
8 | blockquote, q { margin: 0; padding: 0; border: 0; font-weight: inherit; font-style: inherit; font-size: 100%; font-family: inherit; vertical-align: baseline; quotes: "" ""; }
9 | blockquote:before, blockquote:after, q:before, q:after { content: ""; }
10 |
11 | th, td, caption { margin: 0; padding: 0; border: 0; font-weight: inherit; font-style: inherit; font-size: 100%; font-family: inherit; vertical-align: baseline; text-align: left; font-weight: normal; vertical-align: middle; }
12 |
13 | table { margin: 0; padding: 0; border: 0; font-weight: inherit; font-style: inherit; font-size: 100%; font-family: inherit; vertical-align: baseline; border-collapse: separate; border-spacing: 0; vertical-align: middle; }
14 |
15 | a img { border: none; }
16 |
17 | body.bp .box { padding: 1.5em; margin-bottom: 1.5em; background: #e5ecf9; }
18 | body.bp div.border { padding-right: 4px; margin-right: 5px; border-right: 1px solid #eeeeee; }
19 | body.bp div.colborder { padding-right: 24px; margin-right: 25px; border-right: 1px solid #eeeeee; }
20 | body.bp hr { background: #dddddd; color: #dddddd; clear: both; float: none; width: 100%; height: 0.1em; margin: 0 0 1.45em; border: none; }
21 | body.bp hr.space { background: #dddddd; color: #dddddd; clear: both; float: none; width: 100%; height: 0.1em; margin: 0 0 1.45em; border: none; background: white; color: white; visibility: hidden; }
22 | body.bp form.inline { line-height: 3; }
23 | body.bp form.inline p { margin-bottom: 0; }
24 |
25 | body.bp { line-height: 1.5; font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; color: #333333; font-size: 75%; }
26 | body.bp h1, body.bp h2, body.bp h3, body.bp h4, body.bp h5, body.bp h6 { font-weight: normal; color: #222222; }
27 | body.bp h1 img, body.bp h2 img, body.bp h3 img, body.bp h4 img, body.bp h5 img, body.bp h6 img { margin: 0; }
28 | body.bp h1 { font-size: 3em; line-height: 1; margin-bottom: 0.50em; }
29 | body.bp h2 { font-size: 2em; margin-bottom: 0.75em; }
30 | body.bp h3 { font-size: 1.5em; line-height: 1; margin-bottom: 1.00em; }
31 | body.bp h4 { font-size: 1.2em; line-height: 1.25; margin-bottom: 1.25em; }
32 | body.bp h5 { font-size: 1em; font-weight: bold; margin-bottom: 1.50em; }
33 | body.bp h6 { font-size: 1em; font-weight: bold; }
34 | body.bp p { margin: 0 0 1.5em; }
35 | body.bp p img.left { display: inline; float: left; margin: 1.5em 1.5em 1.5em 0; padding: 0; }
36 | body.bp p img.right { display: inline; float: right; margin: 1.5em 0 1.5em 1.5em; padding: 0; }
37 | body.bp a { text-decoration: underline; color: #000099; }
38 | body.bp a:visited { color: #000066; }
39 | body.bp a:focus { color: black; }
40 | body.bp a:hover { color: black; }
41 | body.bp a:active { color: #cc0099; }
42 | body.bp blockquote { margin: 1.5em; color: #666666; font-style: italic; }
43 | body.bp strong { font-weight: bold; }
44 | body.bp em { font-style: italic; }
45 | body.bp dfn { font-style: italic; font-weight: bold; }
46 | body.bp sup, body.bp sub { line-height: 0; }
47 | body.bp abbr, body.bp acronym { border-bottom: 1px dotted #666666; }
48 | body.bp address { margin: 0 0 1.5em; font-style: italic; }
49 | body.bp del { color: #666666; }
50 | body.bp pre { margin: 1.5em 0; white-space: pre; }
51 | body.bp pre, body.bp code, body.bp tt { font: 1em "andale mono", "lucida console", monospace; line-height: 1.5; }
52 | body.bp li ul, body.bp li ol { margin: 0; }
53 | body.bp ul, body.bp ol { margin: 0 1.5em 1.5em 0; padding-left: 3.333em; }
54 | body.bp ul { list-style-type: disc; }
55 | body.bp ol { list-style-type: decimal; }
56 | body.bp dl { margin: 0 0 1.5em 0; }
57 | body.bp dl dt { font-weight: bold; }
58 | body.bp dd { margin-left: 1.5em; }
59 | body.bp table { margin-bottom: 1.4em; width: 100%; }
60 | body.bp th { font-weight: bold; }
61 | body.bp thead th { background: #c3d9ff; }
62 | body.bp th, body.bp td, body.bp caption { padding: 4px 10px 4px 5px; }
63 | body.bp tr.even td { background: #e5ecf9; }
64 | body.bp tfoot { font-style: italic; }
65 | body.bp caption { background: #eeeeee; }
66 | body.bp .quiet { color: #666666; }
67 | body.bp .loud { color: #111111; }
68 | body.bp .clear { clear: both; }
69 | body.bp .nowrap { white-space: nowrap; }
70 | body.bp .clearfix { overflow: hidden; *zoom: 1; }
71 | body.bp .small { font-size: 0.8em; margin-bottom: 1.875em; line-height: 1.875em; }
72 | body.bp .large { font-size: 1.2em; line-height: 2.5em; margin-bottom: 1.25em; }
73 | body.bp .first { margin-left: 0; padding-left: 0; }
74 | body.bp .last { margin-right: 0; padding-right: 0; }
75 | body.bp .top { margin-top: 0; padding-top: 0; }
76 | body.bp .bottom { margin-bottom: 0; padding-bottom: 0; }
77 | body.bp .showgrid { background: url('/static/img/grid.png?1293959049'); }
78 | body.bp .error { padding: 0.8em; margin-bottom: 1em; border: 2px solid #dddddd; background: #fbe3e4; color: #8a1f11; border-color: #fbc2c4; }
79 | body.bp .error a { color: #8a1f11; }
80 | body.bp .notice { padding: 0.8em; margin-bottom: 1em; border: 2px solid #dddddd; background: #fff6bf; color: #514721; border-color: #ffd324; }
81 | body.bp .notice a { color: #514721; }
82 | body.bp .success { padding: 0.8em; margin-bottom: 1em; border: 2px solid #dddddd; background: #e6efc2; color: #264409; border-color: #c6d880; }
83 | body.bp .success a { color: #264409; }
84 | body.bp .hide { display: none; }
85 | body.bp .highlight { background: yellow; }
86 | body.bp .added { background: #006600; color: white; }
87 | body.bp .removed { background: #990000; color: white; }
88 | body.bp .container { width: 950px; margin: 0 auto; overflow: hidden; *zoom: 1; -moz-box-shadow: #333333 1px 1px 5px 0; -webkit-box-shadow: #333333 1px 1px 5px 0; -o-box-shadow: #333333 1px 1px 5px 0; box-shadow: #333333 1px 1px 5px 0; }
89 | body.bp .heading { display: inline; float: left; margin-right: 0; width: 910px; padding: 20px 20px 0; min-height: 60px; border-bottom: 1px solid #222222; }
90 | * html body.bp .heading { overflow-x: hidden; }
91 | body.bp .heading h1 { display: inline; float: left; margin-right: 10px; width: 310px; }
92 | * html body.bp .heading h1 { overflow-x: hidden; }
93 | body.bp .heading h1 a { text-decoration: none; color: #333333; }
94 | body.bp .heading h1 a:hover { text-decoration: underline; }
95 | body.bp .heading .toolbar { display: inline; float: left; margin-right: 0; width: 590px; }
96 | * html body.bp .heading .toolbar { overflow-x: hidden; }
97 | body.bp .heading .toolbar ul { display: inline; float: right; margin: 0; padding: 0; border: 0; outline: 0; overflow: hidden; *zoom: 1; }
98 | body.bp .heading .toolbar ul li { list-style-image: none; list-style-type: none; margin-left: 0px; white-space: nowrap; display: inline; float: left; padding-left: 4px; padding-right: 4px; }
99 | body.bp .heading .toolbar ul li:first-child, body.bp .heading .toolbar ul li.first { padding-left: 0; }
100 | body.bp .heading .toolbar ul li:last-child, body.bp .heading .toolbar ul li.last { padding-right: 0; }
101 | body.bp .breadcrumbs { clear: both; border-bottom: 1px solid #555555; }
102 | body.bp .breadcrumbs ul { overflow: hidden; margin: 0; padding: 0; border: 0; outline: 0; overflow: hidden; *zoom: 1; margin-left: 20px; padding: 0.5em 0; }
103 | body.bp .breadcrumbs ul li { list-style-image: none; list-style-type: none; margin-left: 0px; white-space: nowrap; display: inline; float: left; padding-left: 10px; padding-right: 10px; }
104 | body.bp .breadcrumbs ul li:first-child, body.bp .breadcrumbs ul li.first { padding-left: 0; }
105 | body.bp .breadcrumbs ul li:last-child, body.bp .breadcrumbs ul li.last { padding-right: 0; }
106 | body.bp .breadcrumbs ul a { text-decoration: none; }
107 | body.bp .breadcrumbs ul a:hover { text-decoration: underline; }
108 | body.bp .breadcrumbs ul li { border-right: 1px solid #222222; }
109 | body.bp .breadcrumbs ul li:last-child, body.bp .breadcrumbs ul li.last { border-right: none; }
110 | body.bp .messages { clear: both; display: inline; float: left; margin-right: 10px; width: 950px; }
111 | * html body.bp .messages { overflow-x: hidden; }
112 | body.bp .messages .flashes { padding-left: 20px; margin-bottom: 0; margin-top: 1.5em; }
113 | body.bp .messages .flashes li { list-style-type: none; padding: 0.8em; margin-bottom: 1em; border: 2px solid #dddddd; background: #fff6bf; color: #514721; border-color: #ffd324; margin-bottom: 0; }
114 | body.bp .messages .flashes li a { color: #514721; }
115 | body.bp .body { clear: both; padding: 2em 20px; display: inline; float: left; margin-right: 10px; width: 910px; }
116 | * html body.bp .body { overflow-x: hidden; }
117 | body.bp .body a.button { display: -moz-inline-box; -moz-box-orient: vertical; display: inline-block; vertical-align: middle; *vertical-align: auto; margin: 0.7em 0.5em 0.7em 0; border-width: 1px; border-style: solid; font-family: "Lucida Grande", Tahoma, Arial, Verdana, sans-serif; font-size: 100%; line-height: 130%; font-weight: bold; text-decoration: none; cursor: pointer; padding: 5px 10px 5px 7px; }
118 | body.bp .body a.button { *display: inline; }
119 | body.bp .body a.button img { margin: 0 3px -3px 0 !important; padding: 0; border: none; width: 16px; height: 16px; float: none; }
120 | body.bp .body button { display: -moz-inline-box; -moz-box-orient: vertical; display: inline-block; vertical-align: middle; *vertical-align: auto; margin: 0.7em 0.5em 0.7em 0; border-width: 1px; border-style: solid; font-family: "Lucida Grande", Tahoma, Arial, Verdana, sans-serif; font-size: 100%; line-height: 130%; font-weight: bold; text-decoration: none; cursor: pointer; width: auto; overflow: visible; padding: 4px 10px 3px 7px; }
121 | body.bp .body button { *display: inline; }
122 | body.bp .body button img { margin: 0 3px -3px 0 !important; padding: 0; border: none; width: 16px; height: 16px; float: none; }
123 | body.bp .body button[type] { padding: 4px 10px 4px 7px; line-height: 17px; }
124 | *:first-child + html body.bp .body button[type] { padding: 4px 10px 3px 7px; }
125 |
126 | form.bp label { font-weight: bold; }
127 | form.bp fieldset { padding: 1.4em; margin: 0 0 1.5em 0; }
128 | form.bp legend { font-weight: bold; font-size: 1.2em; }
129 | form.bp input.text, form.bp input.title, form.bp input[type=email], form.bp input[type=text], form.bp input[type=password] { margin: 0.5em 0; background-color: white; padding: 5px; }
130 | form.bp input.title { font-size: 1.5em; }
131 | form.bp input[type=checkbox], form.bp input.checkbox, form.bp input[type=radio], form.bp input.radio { position: relative; top: 0.25em; }
132 | form.bp textarea { margin: 0.5em 0; padding: 5px; }
133 | form.bp select { margin: 0.5em 0; }
134 | form.bp fieldset { border: 1px solid #cccccc; }
135 | form.bp input.text, form.bp input.title, form.bp input[type=email], form.bp input[type=text], form.bp input[type=password], form.bp textarea, form.bp select { border: 1px solid #bbbbbb; }
136 | form.bp input.text:focus, form.bp input.title:focus, form.bp input[type=email]:focus, form.bp input[type=text]:focus, form.bp input[type=password]:focus, form.bp textarea:focus, form.bp select:focus { border: 1px solid #666666; }
137 | form.bp input.text, form.bp input.title, form.bp input[type=email], form.bp input[type=text], form.bp input[type=password] { width: 300px; }
138 | form.bp textarea { width: 390px; height: 250px; }
139 |
--------------------------------------------------------------------------------