The dynips service provides a way to update a DNS hostname automatically when the IP address of a client computer changes. We use dynips at our company to grant firewall access to trusted IPs, for employees who are mobile or don't have fixed IPs.
The service registers hostnames by defining DNS 'A' records in a
domain that you specify. You must own the domain, and it must be
managed by AWS Route 53. When you configure the service, you specify a
root name within your domain, and all hostnames are defined as
subdomains of that root. For example, if you own mydomain.com
, you
can specify the root name to be ips.mydomain.com
. Registered
hostnames will then have names of the form
hostname.ips.mydomain.com
.
The service supports multiple client accounts, each with its own username and password. Each client may register and track the IPs of any number of hostnames. Clients access the service to update IPs via standard HTTPS GET requests. A client may perform an update manually from a web browser, or automatically using some form of cron job and tools like curl.
Normally, the service expires hostnames that clients fail to update regularly. This ensures that the IP addresses for temporary locations, such as coffee shops, are automatically expired. It is also possible for a client to hold a hostname so that it doesn't expire. Holding is useful for clients such as iPhones, for which at present there is no app to perform automatic periodic updates.
As a security measure, the service locks IPs and/or users that fail to provide the correct credentials after a certain number of attempts.
Client accounts are managed via a simple command line utility, written in Python.
It is possible to associate a custom domain with the service web address (such as "dynips.mydomain.com"). To use a custom domain, you must have an appropriate SSL certificate for the domain.
The service is implemented in Python, using AWS Lambda Functions, the API Gateway, S3, and Route 53. No dedicated web server is required to run the service.
In the following section, <dynip-server>
represents the hostname
used to access your instance of the service. If you don't assign
a custom domain to the service, the hostname will be an AWS-assigned
API gateway address of the form:
12345678.execute-api.aws-region.amazonaws.com/prod/
If you assign a custom domain, you would define it to be a CNAME to the API gateway address. It can be any name you choose. For example:
dynips.mydomain.com
To determine your current IP, use:
https://<dynip-server>/dynips
The server will return the following JSON string:
{"ip": "1.2.3.4"}
To register the IP for a hostname, use:
https://<dynip-server>/dynips?host=<hostname>&key=<password>
This request associates your browser's current IP with the specified
<hostname>
. The <password>
is your assigned password.
The <hostname>
is of the form:
<root>[-<extension>]
where <root>
is your assigned username and -<extension>
is an optional dash
followed by an alphanumeric extension that you can choose. For
example, if your username is sally
, you can register the simple
hostname sally
, but you can also create other names, such as
sally-laptop
and sally-iphone
. Users are free to make up as many names as
they need.
By default, the service associates your current browser client IP with the
hostname. But for special situations, you can specify a particular IP
by adding an ip
argument:
https://<dynip-server>/dynips?host=<hostname>&key=<password>&ip=<ip-address>
The service will expire a hostname that is not registered at least
once an hour, by default. In some situations you may want to keep a
name alive even when you can't ping the service regularly. For
example, at present there's no way to automatically ping the service
from an iPhone. To keep a name from expiring, add the argument
expire=no
:
https://<dynip-server>/dynips?host=<hostname>&key=<password>&expire=no
This causes the hostname to remain valid until you
register the same name without the expire
argument.
For all registration requests, the server returns a JSON string of the following form:
{
"host":"<FQDN for the registered hostname>",
"ip":"<the IP address from which the request was made>",
"action":"updated|no_change",
"cur_ip":"<the IP of the hostname prior to the call>",
"new_ip":"<after a change, the new IP of the hostname>"
}
Finally, you can find out the current IP for a hostname by omitting the key from a request:
https://<dynip-server>/dynips?host=<hostname>
The server returns the following JSON string:
{
"host":"<FQDN for the hostname>",
"ip":"<the client IP>",
"cur_ip":"<the IP currently assigned to the hostname>"
}
The dynip
Python program is used to manage user accounts and
hostnames. You can run the program on any computer with Python 2.7
installed.
The program requires the following Python packages:
- boto3 - The AWS Python API
- passlib - A password hashing library
- pytz - Timezone definitions
By default, dynip
uses the standard mechanisms supported by boto3 to
obtain its AWS credentials. These include searching for configuration
files in places like ~/.aws/credentials
. See the boto3 documentation
for all of the options.
You can also pass credentials to dynip
via the optional arguments
--acccess-key-id
, --secret-access-key
, and --session-token
.
The section IAM Roles and Policies, later in this README, details
the rights required to run dynip
.
Call dynip
as follows:
dynip <command> <--arg1> .. <--argN>
The commands are:
create
dynip create --user=<user> [--key=<key> | --key-length=<len>]
Create a user account. Arguments:
-
--user=<user>
(required) specifies the username. Names must be alphanumeric strings, and are case-insensitive. -
--key=<key>
(optional) specifies the user key (i.e. password). Keys must be at least 9 characters long. -
--key-length=<len>
(optional) specifies a key length. If this argument is included, the program generates a random string of the specified length. The minimum allowed length is 9. The program prints the generated key.
You can specify a --key
or --key-length
, but not both. If you specify
neither, the program generates a 16-character random string and prints
it.
NOTE: The program stores a hash of the key, not the key itself, so it's your responsibility to record any key the program generates.
edit
dynip edit --user=<user> [--key=<key> | --key-length=<len>]
Edit a user account. The arguments are the same as for create.
delete
dynip delete --user=<user>
Delete a user account, and any hostnames associated with it.
lock
dynip lock [--user=<user>] [--ip=<ip>]
Lock a user and/or IP address, to block access to the service. Arguments:
--user=<user>
(optional) specifies a username.--ip=<ip>
(optional) specifies an IP address in the standard formn.n.n.n
.
You can specify a user, IP address, or both.
unlock
dynip unlock [--user=<user>|*] [--ip=<ip>|*]
Unlock a user and/or IP address, to grant access to the service. The
arguments are the same as for lock. For unlock,
you can use the wildcard *
, to unlock all currently locked users and/or IPs.
list
dynip list
List all registered hostnames, users, and user/IP locks.
expire
dynip expire [--max-age=<seconds>]
Expire all hostnames that have not been updated more recently than the
specified number of seconds ago. If no --max-age
argument is
specified, the default maximum age is used. The default is 3600
seconds, but you can change it when you configure the
service.
In order to expire hostnames automatically, an expirer daemon must be run periodically. There are a couple of ways to run an expirer:
By default, the dynips installation program creates a
dynips-expirer
lambda function, which when run expires all
out-of-date hostnames. There are various ways you could
arrange to run the lambda periodically. The best method
is with a lambda "Scheduled Event" source.
Unfortunately, the only way at
present to create a scheduled event is via the AWS console. To
schedule the dynips-expirer
do this:
- Install dynips
- In the AWS Console, find the
dynips-expirer
lambda service and click it. - On the lambda's Event sources tab, click Add event source.
- On the Add event source dialog, select event source type Scheduled Event and fill in the rest of the fields to create a schedule.
You can also create a cron job on a convenient computer to run the
command dynip expire
periodically. Of course, the job must have
access to the necessary AWS credentials.
The dynips installer uses the standard sequence of commands:
python configure [options]
make
sudo make install
The configure
program has several required options and a plethora of
optional ones, as follows:
--zone-id=<id>
is the ID of the Route 53 zone used to manage the
domain in which hostnames are registered. The zone must already exist.
--domain-root=<name>
is the root domain name to be used for
hostnames. The FQDN for a hostname is <hostname>.<domain-root>
The domain root must be a valid name for the Route 53 zone you are
using. For example, if your zone has the root name
mydomain.com
, you can specify a domain root of mydomain.com
or a
subdomain such as ips.mydomain.com
. In general, it's best to define
a subdomain so that registered hostnames don't collide with other
names you may define for the zone.
--s3-bucket=<bucket>
is the name of the S3 bucket to be used to
store dynips user account information and the states of registered
hostnames. If the bucket doesn't exist, the installer will create it.
--default-ip-<ip>
is the IP address to be assigned to expired
hostnames. The default is 10.10.10.10
.
--ttl=<seconds>
is the DNS TTL assigned to hostnames. The default is
10 seconds. Normally, it's best to have a short TTL so that hostname
IP changes propogate quickly.
--max-age=<seconds>
is the hostname expiry age. The default is 3600
seconds.
--max-errors=<count>
is the max number of login errors permitted for
a given user or client IP address. When this number of errors is
reached, the user and/or IP is locked out. The default is 5 errors.
--pw-hash-rounds=<count>
is the number of hash rounds to perform
when generating user account password hashes. The default is 8000
rounds. (The hash function is PBKDF2 SHA256.)
--server-lambda-name=<name>
is the name assigned to the server
lambda function and its API gateway. The default is dynips-server
. Normally you
shouldn't need to change this name unless you already have a lambda
function with that name.
--expirer-lambda-name=<name>
is the name assigned to the expirer
lambda function. The default is dynips-expirer
.
--server-iam-role=<name>
is the name of the IAM role used by the
server lambda function. The default is the same name assigned to the
server lambda function (which by default is dynips-server
). You can
assign a different name if you already have an IAM role with the same
name, or if you want to use a pre-existing IAM role.
--expirer-iam-role=<name>
is the name of the IAM role used by the
expirer lambda function. The default is the same name assigned to the
expirer lambda function (which by default is dynips-expirer
). You can
assign a different name if you already have an IAM role with the same
name, or if you want to use a pre-existing IAM role.
--prefix=<path>
is the installation root dir for the dynip
program. The default is
/usr/local
.
--bindir=<path>
is the installation directory for the dynip
program. The default is $(prefix)/bin
.
--srcdir=<path>
is the location of the installation source files.
The default is ./
(which means you intend to run make from the same
directory containing the source files).
--without-bin
causes the dynip
program and the dynips
Python
package, which it uses, not to be installed on the local computer.
--without-expirer-lambda
causes the expirer lambda function and its
IAM role not to be installed.
--without-iam-roles
causes no IAM roles to be created. This option
presumes you have already defined IAM roles for the server and expirer
lambda functions.
--with-server-domain-name
causes a custom domain name to be
associated with the server lambda API gateway. See the following
section.
By specifying the configuration option --with-server-domain
, you can
cause the installer to associate a domain name with the server API.
This allows you to define a friendlier name for the service URL.
Note that the domain name used here doesn't necessarily have to be
in the same domain as the hostnames domain root.
To define a custom domain name:
- The name you choose must be one you can configure to be a CNAME pointing to the name assigned to the server API by AWS.
- You must own an SSL certificate that is associated with the domain name. Also, the certificate key, cert, and chain files must be resident on the computer where you install dynips.
To configure a custom domain name, use these options:
--with-server-domain-name
--server-domain-name=<name>
is the domain name you wish to use (e.g.
dynips.mydomain.com
).
--certificate-file=<path>
is the path to a file containing an SSL
certificate for the domain name.
--private-key-file=<path>
is the path to a file containing the
certificate's private key.
--chain-file=<path>
is the path to a file containing the chain to
the root certificate of the authority that issued the certificate.
All of the certificate files must be in PEM format. Of course, once you have installed dynips you can remove them from the computer.
The installer writes file server_api.txt
, which contains the
hostname assigned to the server API by AWS, and the full URL used to
access the service. To associate your custom domain name with the
server API hostname, define a CNAME that points your domain name server
to the API hostname. For example, if the API hostname is:
12345678.execute-api.us-east-1.amazonaws.com
and your domain name is:
dynips.mydomain.com
then define this CNAME:
dynips.mydomain.com CNAME 12345678.execute-api.us-east-1.amazonaws.com
After you define the CNAME, the URL clients will use to access the service is:
https://dynips.mydomain.com/dynips
By default, the installer creates IAM roles with the correct rights
for the server and expirer. To run the dynip
command line program,
you must arrange that an IAM user or role be in place with the
necessary rights. Note that if you use a cron job to run the dynip expire
operation, the job only needs Expirer rights.
The following table summarizes the IAM rights required by the various dynips processes.
Rights | Server | Expirer | dynip utility |
---|---|---|---|
Full access to S3 state files, change Route 53 record sets | Yes | Yes | Yes |
Read-only access to S3 user files | Yes | ||
Full access to S3 user files | Yes | ||
Write CloudWatch logs | Yes | Yes |
Below are example policy statements for the rights described in the table.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::<dynips-bucket>"
]
},
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject"
],
"Resource": [
"arn:aws:s3:::<dynips-bucket>/state/*"
]
},
{
"Effect": "Allow",
"Action": [
"route53:ChangeResourceRecordSets"
],
"Resource": [
"arn:aws:route53:::hostedzone/<dynips-zone>"
]
},
{
"Effect": "Allow",
"Action": [
"route53:GetChange"
],
"Resource": [
"arn:aws:route53:::change/*"
]
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject"
],
"Resource": [
"arn:aws:s3:::<dynips-bucket>/users/*"
]
},
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject"
],
"Resource": [
"arn:aws:s3:::<dynips-bucket>/users/*"
]
},
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:*:*:*"
},
]
}
In addition to the policies described above, the following trust relationship must be defined, to allow the AWS lambda service to assume an IAM role:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
The installer creates the following AWS items:
-
An S3 bucket, with the name specified by the configuration options.
-
An IAM role for the server lambda function, with the default name
dynips-server
. -
An IAM role for the expirer lambda function, with the default name
dynips-server
. -
A lambda function for the server, with the default name
dynips-server
. -
A lambda function for the expirer, with the default name
dynips-expirer
. -
An API gateway for the server, with the default name
dynips-server
. -
An optional custom domain name for the server API gateway.
The hostname registration web service is implemented as a lambda function. A API gateway attached to the function makes it possible for external clients to access the service via HTTPS. The service maintains hostname to IP mappings by updating record sets in a Route 53 zone.
User credentials and hostname state information are stored in an S3 bucket. The bucket is partitioned into state and user folders, so that the lambda function can be granted full access to the state files, but read-only acccess to the user credential files.
All file content is stored as JSON strings.
User files have names of the form:
<bucket>:users/<username>
A user file contains the following JSON content:
{
"user":"<username>",
"keyhash":"<hash>"
}
The <hash>
is a PBBKDF2/SHA256 hash of the user's key. The hash
string includes a prefix that names the hash used and the number of
rounds. This makes it possible for implementations to change the hash
and/or rounds while maintaining backward compatibility with existing
keys. All of this complexity is handled automatically by the passlib
Python package.
State files have names of the form:
<bucket>:state/<name>.<ext>
A file <name>
can be a hostname, a username, or an IP address. The
<ext>
specifies the file type, as follows:
<hostname>.ping
A ping file records the most-recent registration of a hostname, for hostnames that have not expired. The file contains the following JSON content:
{ "ip":"<IP-address>" }
<hostname>.hold
The presence of a hold file indicates that hostname should not be expired. The file contains the same content as the ping file that was created when the hold file was created.
<hostname>.expired
The presence of an expired file indicates that a hostname has expired. The file contains the content of the last ping file before the hostname was expired.
<name>.error.<count>
Each time a client performs an operation that results in an error
(such as providing invalid credentials), the service writes an error
file, where <name>
is the client IP address. If the error
involves a username, the server also writhes an error file where
<name>
is the username. The <count>
is an ordinal, starting with
1, that increases each time the server writes a new error file for the
same IP and/or username.
<name>.lock
When the number of error files for a given IP or username reaches a fixed threshold, the server writes a lock file, and thereafter stops writing corresponding error files. The presences of a lock file causes the server to refuse acccess to the IP or user.
The server handles a registration request as follows:
-
If the request is from a client IP for which a lock file exists, or if a lock file exists for the username provided by the request, the server refuses the request.
-
The server validates the request by extracting the username prefix from the supplied hostname and matching the supplied key with the hash stored in the user credentials file. If the request is invalid, the server creates error and/or lock files, as described previously.
-
For valid requests, the server creates or updates a ping file for the requested hostname. If a corresponding expired file exists, the server deletes it. If the request includes the
expire=no
param, the server creates a hold file for the hostname, if one does not already exist. Otherwise, if a hold file exists, the server deletes it. -
The server updates the hostname's IP address in the Route 53 zone.
The expirer daemon expires hostnames by enumerating all ping files with a last-modified time older than the specified maximum age, and for which no hold file exists. For each hostname to be expired, the server creates an expired file and deletes the ping file. The expirer also sets the hostname's Route 53 IP address to the expired value, which by default is 10.10.10.10.