Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ jobs:
- name: go build
run: go build -trimpath -ldflags="-s -w" -o ipadecrypt ./cmd/ipadecrypt

- name: go build telegram bot
run: go build -trimpath -ldflags="-s -w" -o tgbot ./cmd/tgbot

helper:
name: helper
runs-on: ubuntu-latest
Expand Down
21 changes: 21 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1

FROM golang:1.25 AS builder
WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /out/tgbot ./cmd/tgbot

FROM alpine:3.22
RUN apk add --no-cache ca-certificates tzdata

ENV IPADECRYPT_ROOT_DIR=/data/.ipadecrypt
WORKDIR /app

COPY --from=builder /out/tgbot /usr/local/bin/tgbot

VOLUME ["/data"]
CMD ["/usr/local/bin/tgbot"]
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,60 @@ A four-step interactive wizard:
ipadecrypt decrypt <bundle-id|app-store-id|app-store-url|path-to-local-ipa>
```

## iOS-first remote usage (Telegram Bot + Railway + Cloudflare Tunnel)

If you want to run everything from your phone, this repository now includes a Telegram bot entrypoint at `cmd/tgbot`.

### Why this setup

- Works directly from Telegram on iOS (`/decrypt`, `/versions`, `/status`)
- Keeps Apple session + cookies on persistent disk (`/data/.ipadecrypt`)
- Avoids ephemeral CI runners for interactive 2FA/session-based workflows

### 1) Expose your jailbroken phone SSH via Cloudflare Tunnel

On the jailbroken device:

```sh
cloudflared tunnel --url tcp://localhost:22
```

Use the generated hostname as `DEVICE_HOST` in Railway.

To make it persistent on reboot, run `cloudflared` via a LaunchDaemon (for example `/Library/LaunchDaemons/com.cloudflare.tunnel.plist`).

### 2) Deploy the bot to Railway

This repo includes:

- `Dockerfile` (builds and runs `cmd/tgbot`)
- `railway.toml`

Required Railway env vars:

- `TELEGRAM_TOKEN`
- `ALLOWED_USER_IDS` (comma-separated Telegram numeric IDs)
- `APPLE_EMAIL`
- `APPLE_PASSWORD`
- `APPLE_ACCOUNT_JSON`
- `DEVICE_HOST`
- `DEVICE_USER`
- `DEVICE_PASSWORD`
- `DEVICE_PORT` (optional, default `22`)
- `IPADECRYPT_ROOT_DIR` (optional, default `/data/.ipadecrypt`)
- `IPADECRYPT_OUTPUT_DIR` (optional)

Also mount persistent storage to `/data` in Railway.

### 3) Telegram commands

```text
/decrypt com.instagram.Instagram
/decrypt https://apps.apple.com/us/app/instagram/id389801252
/versions com.instagram.Instagram
/status
```

## License

MIT.
Expand Down
224 changes: 224 additions & 0 deletions cmd/tgbot/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package main

import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"

"github.com/londek/ipadecrypt/internal/appstore"
"github.com/londek/ipadecrypt/internal/config"
)

type settings struct {
TelegramToken string
AllowedUsers map[int64]struct{}
RootDir string
OutputDir string
}

func loadSettingsFromEnv() (settings, error) {
token := strings.TrimSpace(os.Getenv("TELEGRAM_TOKEN"))
if token == "" {
return settings{}, errors.New("TELEGRAM_TOKEN is required")
}
Comment on lines +24 to +27

allowed, err := parseAllowedUserIDs(strings.TrimSpace(os.Getenv("ALLOWED_USER_IDS")))
if err != nil {
return settings{}, err
}

rootDir := strings.TrimSpace(os.Getenv("IPADECRYPT_ROOT_DIR"))
if rootDir == "" {
rootDir = "/data/.ipadecrypt"
}

outputDir := strings.TrimSpace(os.Getenv("IPADECRYPT_OUTPUT_DIR"))
if outputDir == "" {
outputDir = filepath.Join(rootDir, "bot-output")
}

return settings{
TelegramToken: token,
AllowedUsers: allowed,
RootDir: rootDir,
OutputDir: outputDir,
}, nil
}

func parseAllowedUserIDs(raw string) (map[int64]struct{}, error) {
if raw == "" {
return nil, errors.New("ALLOWED_USER_IDS is required")
}

out := make(map[int64]struct{})
for _, part := range strings.Split(raw, ",") {
p := strings.TrimSpace(part)
if p == "" {
continue
}
id, err := strconv.ParseInt(p, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid ALLOWED_USER_IDS entry %q: %w", p, err)
}
out[id] = struct{}{}
}

if len(out) == 0 {
return nil, errors.New("ALLOWED_USER_IDS has no valid user IDs")
}

return out, nil
}

func isAllowedUser(allowed map[int64]struct{}, id int64) bool {
_, ok := allowed[id]
return ok
}

func loadConfigOrDefault(rootDir string) (*config.Config, *config.Paths, error) {
paths, err := config.NewPaths(rootDir)
if err != nil {
return nil, nil, err
}

cfgFile := paths.ConfigPath()
cfg, err := config.Load(cfgFile)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
cfg = config.New(cfgFile)
} else {
return nil, nil, fmt.Errorf("load config: %w", err)
}
}

changed, err := applyEnvOverrides(cfg)
if err != nil {
return nil, nil, err
}
if changed {
if err := cfg.Save(); err != nil {
return nil, nil, fmt.Errorf("save config: %w", err)
}
}

return cfg, paths, nil
}

func applyEnvOverrides(cfg *config.Config) (bool, error) {
changed := false

if v := strings.TrimSpace(os.Getenv("APPLE_EMAIL")); v != "" && cfg.Apple.Email != v {
cfg.Apple.Email = v
changed = true
}
if v := strings.TrimSpace(os.Getenv("APPLE_PASSWORD")); v != "" && cfg.Apple.Password != v {
cfg.Apple.Password = v
changed = true
}
if raw := strings.TrimSpace(os.Getenv("APPLE_ACCOUNT_JSON")); raw != "" {
var acc appstore.Account
if err := json.Unmarshal([]byte(raw), &acc); err != nil {
return false, fmt.Errorf("parse APPLE_ACCOUNT_JSON: %w", err)
}
cfg.Apple.Account = &acc
changed = true
}

if v := strings.TrimSpace(os.Getenv("DEVICE_HOST")); v != "" && cfg.Device.Host != v {
cfg.Device.Host = v
changed = true
}
if v := strings.TrimSpace(os.Getenv("DEVICE_USER")); v != "" && cfg.Device.User != v {
cfg.Device.User = v
changed = true
}
if v := strings.TrimSpace(os.Getenv("DEVICE_PASSWORD")); v != "" && cfg.Device.Auth.Password != v {
cfg.Device.Auth.Password = v
cfg.Device.Auth.Kind = "password"
changed = true
}
if v := strings.TrimSpace(os.Getenv("DEVICE_PORT")); v != "" {
p, err := strconv.Atoi(v)
if err != nil {
return false, fmt.Errorf("parse DEVICE_PORT: %w", err)
}
if cfg.Device.Port != p {
cfg.Device.Port = p
changed = true
}
}

if cfg.Device.Port == 0 {
cfg.Device.Port = 22
changed = true
}
if cfg.Device.User == "" {
cfg.Device.User = "mobile"
changed = true
}
if cfg.Device.Auth.Kind == "" && cfg.Device.Auth.Password != "" {
cfg.Device.Auth.Kind = "password"
changed = true
}

return changed, nil
}

func reauth(cfg *config.Config, as *appstore.Client) error {
acc, err := as.Login(cfg.Apple.Email, cfg.Apple.Password, "")
if err != nil {
return fmt.Errorf("re-auth: %w", err)
}

cfg.Apple.Account = acc
if err := cfg.Save(); err != nil {
return fmt.Errorf("save config: %w", err)
}

return nil
}

func acquireLicense(cfg *config.Config, as *appstore.Client, app appstore.App) error {
perr := as.Purchase(cfg.Apple.Account, app)
if errors.Is(perr, appstore.ErrPasswordTokenExpired) {
if rerr := reauth(cfg, as); rerr != nil {
return rerr
}
perr = as.Purchase(cfg.Apple.Account, app)
}

if perr != nil && !errors.Is(perr, appstore.ErrLicenseAlreadyExists) {
return fmt.Errorf("purchase: %w", perr)
}

return nil
}

func withAuth[T any](cfg *config.Config, as *appstore.Client, app appstore.App, retries int, fn func() (T, error)) (T, error) {
var zero T
for range retries {
out, err := fn()
if err == nil {
return out, nil
}

switch {
case errors.Is(err, appstore.ErrPasswordTokenExpired):
if rerr := reauth(cfg, as); rerr != nil {
return zero, rerr
}
case errors.Is(err, appstore.ErrLicenseRequired):
if lerr := acquireLicense(cfg, as, app); lerr != nil {
return zero, lerr
}
default:
return zero, err
}
}

return zero, errors.New("exhausted retries")
}
Loading
Loading