Skip to content

Repository files navigation

OTAgo

badge-ci-github badge-language badge-license

badge-mastodon badge-twitter

badge-sponsors badge-patreon

About

OTAgo is an OTA app distribution system that allows you and your users to securely install their iOS and/or Android apps over the air (OTA). iOS distribution uses Apple's officially supported method documented here.

Communication

  • If you need help, use Stack Overflow (Tag 'otago').
  • If you'd like to ask a general question, use Stack Overflow.
  • If you've found a bug, open an issue.
  • If you have a feature request, open an issue.
  • If you want to contribute, submit a pull request.
  • If you use OTAgo, please Star the project on GitHub

Requirements

  • HTTPS enabled web server (nginx or Apache recommended)
  • PHP 8.1 or newer
  • binary files to distribute (at least one of):
    • .ipa file (signed with an ad-hoc or enterprise distribution profile)
    • .apk file (fat file, not an app bundle, or an apk split per abi)

How to Use

Clone the repo into a folder accessible via HTTPS. You must use HTTPS with a valid (not self-signed) SSL/TLS certificate. (I recommend Let's Encrypt).

OTAgo sends X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer on its own responses. It does not send Strict-Transport-Security (HSTS): that's a sticky, site-wide browser policy that can lock users out of your whole domain if your certificate ever lapses, so if you want it, set it at the web-server level (nginx/Apache) where you can control its scope and lifetime.

Builds are streamed by ipa.php / apk.php in chunks, with Content-Length, Content-Disposition and Accept-Ranges: bytes, and a single-range Range request is answered with a 206 so an interrupted install resumes instead of restarting. If you serve very large builds and want the transfer handed off to the web server entirely, add X-Sendfile (Apache's mod_xsendfile) or X-Accel-Redirect (nginx) at the web-server level; OTAgo doesn't emit those headers itself, since they do nothing - and would leak the file's path - on a server that isn't configured for them.

Configuration

Copy the file configuration.default.php to configuration.php. You'll configure the system by editing the copy. You should back this file up as it's excluded from the git repository by default.

Configuration variables:

  • $baseURL -> During an OTA installation, some files need to be referenced by their full URL. OTAgo detects the scheme and host from the request (Apache, nginx/php-fpm and PHP's built-in server all work); set this directly if the detected value isn't right, for example when the public URL differs from the one PHP sees.

  • $trustProxyHeaders -> set to true only when OTAgo is behind a TLS-terminating reverse proxy or load balancer that sets X-Forwarded-Proto (and optionally X-Forwarded-Host) on every request. Those headers are then used for $baseURL. Leave it false otherwise: without a proxy in front, a client could set them itself.

  • $authFile -> filename of a .php file to handle the authentication (see below).

  • $webTemplate -> the .html template to be displayed to the user before they install the app.

  • $installURLPlaceholder -> a placeholder token for the link that will start the app installation.

  • $enableIOS -> set to true if you support iOS/iPadOS, then configure the iOS specific variables:

    • $manifestTemplate -> the manifest.plist template used to install the app.
    • $ipaURLPlaceholder -> a placeholder token in the above manifest template file where the authenticated URL will be swapped in.
    • $ipaFile -> the .ipa file for iOS/iPadOS distribution.
  • $enableAndroid -> set to true if you support Android, then configure the Android specific variables:

    • $apkFile -> the .apk file for Android distribution.

The authentication system used may have additional options, examples are in the configuration.default.php file.

The $ipaFile and $apkFile files above do not need to be located in a publicly accessible folder, their contents are served by the ipa.php and apk.php scripts, which require authentication first.

AuthFile

The $authFile variable above needs to name a file that can be included by the OTAgo scripts. This allows you to sub in different methods of authentication, a simple list of username/passwords, connect to an external database, or use OAuth. Currently OTAgo includes two authentication options:

None

This effectively removes authentication, and allows anyone to install the app. Of course, iOS will require the .ipa file to be signed with a profile that includes the required device ids, or optionally, an Enterprise certificate.

To disable authentication, set $authFile = 'auth/none/none.php'; in the configuration.php file.

Simple Auth

Simple Auth allows you to create a simple list of username/passwords. Add them to the $users array in the configuration.php file.

Store passwords as password_hash() digests rather than plaintext, so that someone who can read configuration.php can't reuse the passwords elsewhere. Generate a digest with:

php -r 'echo password_hash("your-password", PASSWORD_DEFAULT), PHP_EOL;'

and paste the result (it starts with $2y$ or $argon2) as the user's value. Plaintext values are still accepted for backwards compatibility - OTAgo tells the two apart automatically - but treat that as a migration step, not a destination. Comparison is timing-safe either way.

There are a few other options you can set:

$simpleAuthTempDirectory: the directory where the authentication system stores access tokens (the web server needs read and write permission). It defaults to the tokens directory that ships with OTAgo, which is created on first use with 0700 permissions and guarded by an index.html and an .htaccess so the web server won't serve it. Token files are written 0600.

Token files are credentials - the ?u= / ?t= pair in the install URLs is what authenticates the iOS installer, which can't send an Authorization header of its own. Don't point this at a shared directory such as /tmp: on a multi-user host, every local user can read the token filenames and reconstruct a working install URL, and can write token files of their own. If you do move it, pick a directory outside the web root that only the web server user can access.

$simpleAuthSecret: a long random string used to sign token files, so a token can't be repurposed for another user and a file dropped into the token directory by someone else won't be accepted. Leave it unset and OTAgo generates one on first use and stores it (0600) in the token directory; set it explicitly if you run several web servers off shared storage. Changing it invalidates every outstanding token.

$simpleAuthTokenLifetime: the number of seconds a token should be valid. By default we set it to 3600 seconds (1 hour), which should be fine for most cases. The age is checked every time a token is used, and expired token files are deleted on sight. Left-over expired token files are also swept up when somebody signs in with a password; requests that carry only a ?u=/?t= token don't scan the token directory, so a large or slow token directory doesn't slow down the install itself.

Custom Authentication

If you wish to use another authentication method, you need to create an alternate authFile that includes the following methods:

	function isValidUser()

This takes no parameters and must determine if the current user is valid or not. Return true if they the user is authorized to install the app.

	function queryStringAuthParameters()

This method takes no arguments. It must return an associated array with name/value pairs to be appended to OTAgo URLs. This is how OTAgo will pass the authentication through to the manifest and ipa URLs.

	function requestAuthentication(): never

This method takes no arguments, and never returns. It must send whatever is needed to the client to deny access and request authorization. Like the version in the simpleAuth.php file, you can call requestBasicAuthentication() to trigger a BASIC authentication request. This method must not return: it has to end the request (call exit, as requestBasicAuthentication() does), otherwise the protected content would be served to an unauthorized visitor. Declare it with the never return type, as the bundled providers do, so PHP enforces that for you. The core OTAgo scripts call exit() immediately after requestAuthentication() anyway as insurance, so don't remove those calls.

Basic authentication behind php-fpm / CGI

The bundled Simple Auth provider (and any custom provider that calls requestBasicAuthentication()) reads the user's credentials from $_SERVER['PHP_AUTH_USER'] and $_SERVER['PHP_AUTH_PW']. PHP only populates those two variables when it can see the request's Authorization header - and when PHP runs as php-fpm/FastCGI or CGI (the usual setup on nginx, and common on Apache too), the web server does not forward that header unless you tell it to.

The symptom is distinctive: the browser prompts for a username and password, you type the right ones, and OTAgo prompts again - forever. Nothing is logged as an error, because from PHP's point of view the request simply arrived with no credentials.

The fix is one line of web server configuration.

nginx (in the location block that passes .php requests to php-fpm):

location ~ \.php$ {
    include        fastcgi_params;
    fastcgi_pass   unix:/run/php/php-fpm.sock;
    fastcgi_param  HTTP_AUTHORIZATION $http_authorization;
}

Apache 2.4.13 and newer - CGIPassAuth is the supported way, and works for both mod_cgi and mod_proxy_fcgi:

<Directory /var/www/otago>
    CGIPassAuth On
</Directory>

Apache, older versions (or if CGIPassAuth isn't available to you) - copy the header into an environment variable with mod_setenvif:

SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1

or, with mod_rewrite:

RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]

Both of these land the header in HTTP_AUTHORIZATION (or REDIRECT_HTTP_AUTHORIZATION) rather than in PHP_AUTH_USER/PHP_AUTH_PW; Simple Auth decodes it from there itself, so no further shim is needed. If you're on Apache 2.4.13+, prefer CGIPassAuth On all the same.

Note that this only affects the first request, where the user actually types their credentials. Once they're authenticated, OTAgo passes authentication along to manifest.php, ipa.php and apk.php in the query string (?u= / ?t=) rather than in an Authorization header. That's deliberate, and not something to "fix": Apple's OTA installer fetches the manifest and the .ipa itself and sends no HTTP authentication of its own, so a query-string credential is the only thing that can reach those requests. The ?t= value is a short-lived, HMAC-signed token (see $simpleAuthTokenLifetime above), the responses are sent no-store, and OTAgo sends Referrer-Policy: no-referrer so the URLs aren't leaked to third parties.

Templates

The $webTemplate file needs to be an HTML file that will be displayed to the user. This can be basic HTML with a single link, or more complicated with details about the app with instructions for the user on how to install it (trusting the Enterprise certificate for example). The template file itself does not need to be in a publicly accessible folder, however any files the page links to, images, stylesheets, etc must be. The $webTemplate file should have at least one link with the href set to the $installURLPlaceholder ({{InstallURL}} in our demo). That link will start the install process when the user taps it. The $webTemplate file may also contain {{OTAGOVERSION}}, which is replaced with the running OTAgo version (OTAGO_VERSION in common.php); the bundled template uses it in the footer.

The $manifestTemplate file needs to be a valid manifest.plist file (see Apple's documentation for specifics), but instead of specifying the URL for the .ipa file, use the $ipaURLPlaceholder placeholder ({{IPAURL}} in our demo). The authenticated URL will be substituted into the .plist file before it's sent to the user's device.

Tests

OTAgo has a PHPUnit suite covering the Simple Auth provider and the $baseURL detection in common.php. Running them needs Composer; the OTAgo scripts themselves have no runtime dependencies beyond PHP 8.1+, and nothing in vendor/ needs to be deployed to your web server.

composer install
composer test

The tests drive the real globals the scripts use, in a throwaway token directory under the system temp directory - they never touch a deployed configuration. tests/BaseURLTest.php covers base URL detection across Apache, nginx/php-fpm (with and without REQUEST_SCHEME), non-standard ports, and proxied deployments with $trustProxyHeaders on and off.

The same checks - php -l over every tracked .php file, then the test suite - run in CI on PHP 8.1 through 8.5 (.github/workflows/ci.yaml - Forgejo reads that directory too, so one workflow serves both the Forgejo origin and the GitHub mirror).

Releasing

When cutting a new release, three things need to stay in sync:

  1. Bump OTAGO_VERSION in common.php (it's shown in the web template footer).
  2. Add an entry to CHANGELOG.md.
  3. Tag the release with the same version number.

Note: when submitting a pull request, please use lots of small commits verses one huge commit. It makes it much easier to merge in when there are several pull requests that need to be combined for a new version.

Contributing

OTAgo can only exist with support from the community. There are many ways you can help continue to make it great.

AI assistance

Since version 1.2.0, OTAgo was developed with assistance from anthropic's claude. An AI-driven code review of 1.1.0 produced a list of security and other issues; Claude then implemented many of the fixes, the PHPUnit test suite and the CI workflow, working issue by issue on feature branches. Every change was directed, reviewed, tested and signed off by me (Dave Wood) before merging, and the project's design decisions remain human decisions. Contributions are welcome whether or not you use AI tooling; the review bar is the same either way.

Why's the project called OTAgo, and why is a koala involved?

I'm personally very concerned about the planet and the current Climate Emergency we're in. During the time I've been developing this project, there have been massive bushfires in Australia. I wanted to name the project after the situation. There are several places in Australia named Otago which has an obvious link to OTA; it felt like a perfect name. The koala is a reference to the hundreds of thousands of animals killed during the fires.

My thanks to Freepik at flaticon.com for providing the koala used in the OTAgo logo.

About

An OTA App Distribution System for iOS and Android apps

Topics

Resources

Stars

27 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages