# CashPilot Service Definition Schema # Each YAML file in services/ defines one income service. # This is the SINGLE SOURCE OF TRUTH — README, web UI, and guides all derive from these files. # name: Human-readable service name # slug: URL/container-safe identifier (lowercase, hyphens) # category: bandwidth | depin | storage | compute # status: active | beta | broken | dead | dropped # resurrection_checked: "YYYY-MM-DD" # (dead entries only) the site still answers, but a human # confirmed on this date that the programme is gone. The weekly liveness check stops # reporting a possible resurrection for a year from that date, then asks again. # - dropped: evaluated then removed; hidden from catalog (alongside broken/dead) # priority: (optional) integer, 1 first. Entries with a priority come before the rest, in that # order, everywhere the catalog is listed (web catalog, README lists, Desktop); the rest keep # their usual order (category, then name). Used to lead with the services that pay the project # best, so a reorder is a number change, never a new page. # website: Main URL # support_url: (optional) https:// page where a user reaches the provider's support. Offered # when the provider has disabled the account (a health_signals entry with state account_disabled). # description: One paragraph description # short_description: One line (for tables) # referral: # signup_url: Full signup URL with referral code already embedded # code: The referral code itself, ALWAYS QUOTED -- unquoted, "0071234" parses # as an octal int and "no"/"yes"/"on" as booleans (YAML 1.1). # scripts/referral_check.py proves it stays anchored in signup_url # (query value, bare query key, or path segment), so a domain # migration cannot silently strip the attribution. # program: Whether the PROVIDER runs a referral program. Three-valued, and # absent is not "no": # true verified to run one (true + no code = actionable gap) # false verified NOT to run one (a bare signup_url is correct) # absent nobody has checked yet (reported, never failed) # bonus: (optional) what each side gets, shown on the service detail page: # referrer: "20% of referral traffic earnings" # referee: "$1 welcome bonus" # docker: # image: Docker Hub image # image_by_arch: (optional) image per architecture family, for images whose ARM builds # Docker cannot select itself (separate tags, all labelled linux/amd64). Keys are # amd64, arm64 or arm; the deploy picks by the worker's reported architecture and # falls back to `image`. # arm64: traffmonetizer/cli_v2:arm64v8 # arm: traffmonetizer/cli_v2:arm32v7 # platforms: [linux/amd64, linux/arm64] # what the registry PUBLISHES for this image, nothing # more: the preflight warns an ARM worker off an entry that lacks its family, and the # weekly liveness check compares this list with the manifest. Families: linux/amd64, # linux/arm64 (Pi 4/5 64-bit, Apple Silicon), linux/arm/v7 or v6 or v5 (32-bit Pi). # resources: (optional, durable Docker resource limits applied at container create) # mem_limit: "768m" # hard memory ceiling (str: , e.g. "512m", "2g") # mem_reservation: "512m" # (optional) soft memory reservation, same format as mem_limit # oom_score_adj: -100 # kernel OOM-killer bias, int -1000..1000 (lower = sacrificed last) # cpu_shares: 4096 # relative CPU weight, int 2..262144 (Docker's API default is 1024). # # Only arbitrates while CPU is contended, so it does NOT cap the # # container on an idle box. Raise it for earners that must answer # # inbound connections on time (a storage node the network dials # # back into), which are reported "offline" if they are merely slow. # # It arbitrates CPU between CONTAINERS only: it buys nothing # # against host-process load, I/O-wait, or an unreachable address. # # On cgroup v2 the value maps onto cpu.weight (unset default 100): # # runc >= 1.2 maps 1024->100, 4096->303; OLDER runc maps linearly, # # where anything under ~2600 lands BELOW the unset default -- 2048 # # would give the "protected" service LESS weight than its # # neighbours. Use 4096 or more, which clears 100 on both mappings. # env: # - key: ENV_VAR_NAME # label: "Human label" # required: true/false # secret: true/false # description: "Help text" # default: "" (optional) # pattern: "" (optional) regex a typed value must match in full, or the # deploy is refused. For values that land on a command line, where a # stray space or word becomes an argument and the service stops starting. # port_range: false (optional) the value is "start:end" with # 1 <= start <= end <= 65535. A pattern checks the shape, not the order. # ports: ["port:port"] # volumes: ["host:container"] # critical_volumes: (optional) mounts holding state that CANNOT be recovered # - target: "/container/path" must match the container-side path in `volumes` # holds: "what is lost, in one or two sentences shown to the operator" # Declaring a mount here makes the worker REFUSE to delete that volume unless # the caller passes an explicit override (owner role required). # NOTE: redeploy protection — refusing a redeploy that would drop or retype a # critical mount — is NOT implemented yet; only the delete guard is. # Use it only for genuinely irreplaceable state — node identities, # keystores, generated wallets — not for caches or re-downloadable data. # command: "" (optional override) # network_mode: "" (optional, e.g. "host") # cap_add: [] (optional) # health_signals: [] (optional) log patterns that say whether the service is EARNING, # which container health cannot see. The only signal available for the services with # no collector. # - pattern: "login failed" regex, matched case-insensitively against logs # means: "The service rejected the stored credentials." shown to the user # state: failing failing (default) | idle | account_disabled; any other # value rejects the entry # account_disabled: the PROVIDER refuses to run the # client, so restarting cannot help. Unlike the others # it is judged by the worker on the last finished run's # log (a stopped copy, or one up for under two minutes), # and the dashboard shows # one "Account disabled" card for the service instead of # a crash loop per box. Not with window_hours. # unless: "" (optional) regex whose presence in the same window # cancels the signal: the problem is not there # min_matches: 1 (optional) with window_hours: lines the pattern must match # window_hours: 0 (optional) judge the last N hours of logs (up to 24) # instead of the log tail; checked every 30 minutes, # shown as "Needs fixing" on the dashboard. ${VAR} in # means is filled from that copy's env, ${HOST_ADDRESS} # with the box's LAN address # Only add a pattern you have actually seen in that service's logs. A guessed regex # that never matches is dead weight; one that matches the wrong line tells the user # something false. # advertised_address_env: ADDRESS (optional) the env var holding the address # the NETWORK dials this service back at ("host:port" or "host"). The # worker copies that ONE variable's value into heartbeat container entries # as advertised_address — never any other env, which may hold credentials — # so the UI can compare it against the machine's current egress IP and mark # the service failing when the network is dialling an address this machine # no longer has (the classic silent-ISP-re-provision failure). Only declare # it for services that are genuinely dialled back into; for outbound-only # services the comparison would be meaningless. # node_firewall: (optional) a node that firewalls its own customers off private # ranges, so the worker's isolation check can read whether it does: # flag: "--firewall.protected.networks=" the `command` word carrying the ranges # chain: MYST the nat chain the node writes them to (letters, digits, _ -) # blackhole: "240.0.0.1" the address that chain sends each range to # The check reads the chain from inside the running node and reports every range # in the flag that the chain does not send to the blackhole. # devices: [] (optional) host devices to map in, e.g. ["/dev/net/tun"] # Only devices on the worker's allow-list may be requested, and only by the # service whose own YAML declares them. A device is a direct line to the # kernel, so the catalog cannot widen that ceiling on its own. # privileged: false (optional, security-sensitive — grants the container full host device access) # stop_timeout: 30 (optional, seconds to wait before SIGKILL on stop — default 30). # Also set as the container's own stop timeout, so stops CashPilot does not # send (a plain `docker stop`, Docker Desktop quitting, a host shutdown that # does not pass its own timeout) get it too, and exported as stop_grace_period. # entrypoint: ["/bin/sh", "-c", "..."] (optional, list of strings) replaces the # image's ENTRYPOINT. Read by the worker from its own catalog, never from a # deploy request. Keep it a thin wrapper that ends by exec-ing the image's # own entrypoint, and make it degrade to exactly that if its tweak fails. # setup: "" (optional, one-time setup instructions/commands to run before first deploy) # disclosure: (optional but strongly encouraged) what this service does with the # user's machine. "Is this malware?" is a fair question about closed-source # containers that route traffic through someone's home connection, and an # earnings range does not answer it. # sells: what is actually sold (bandwidth / disk / compute / attention) # third_party_traffic: do strangers route traffic through the user's IP? The value # must OPEN with a standalone yes / no / unknown followed by # punctuation, so it can be read programmatically: # third_party_traffic: >- # Yes. Buyers route their traffic through your connection. # Use a >- block (as above) or quote the string. A BARE `yes`/`no` # is parsed by YAML as a boolean, not text. # "No longer - they route traffic now" does NOT parse as a verdict # and is reported as undocumented, which is deliberate: a phrase # beginning with "no" that means "yes" is the worst possible # silent answer, so anything ambiguous falls back to unknown. # data_collected: what identifying data leaves the machine # isp_risk: what this could mean for their ISP contract, in plain words # account_rules: account and multi-account rules # Write it honestly, including where the answer is unflattering. "unknown - the # provider does not say" is a legitimate and useful entry. Do NOT guess: an absent # block is reported as undocumented, which is safer than a confident wrong answer. # rating: (every active and beta service) the evidence behind the provider's # grade. The method, the criteria and what each verdict means are published in # services/_ratings.yml and on docs/ratings.md. app/ratings.py computes the # letter; it is never written here. The loader rejects any key other than the # five below, so there is no way to type a grade, an override, a sponsor or a # featured flag. # reviewed_on: "2026-11-04" # verdicts: one per criterion in _ratings.yml, none missing # payout_record: good good | fair | poor | unknown # payout_terms: fair # ... # notes: required for every unknown: what was searched # support: "No ticket experience of our own and no repeated reports either way." # cons: worst first; the app shows the first three # - text: "Earnings not cashed out within 24 months can be written off." # criterion: payout_terms # kind: terms terms | provider_reply | our_experience | user_reports | press # source: "https://www.honeygain.com/terms-of-use/" https:// only # checked_on: "2026-11-04" # pros: same shape as cons # A fair or poor verdict needs at least one con citing that criterion, a good # one at least one pro. requirements.container_prohibited: true requires # terms_fit: poor. A user_reports item must show a pattern: at least three # reviewers or posters describing the same specific problem, or two backed by # an independent second source. Never a single complaint, and fewer than that # is an unknown verdict with a note. A provider CashPilot does not run at all # (an app, extension or device the user sets up) is terms_fit: fair. A fact from our own payouts goes # into the provider's guide first, so the source is a public page with no # account details. Thin evidence is an unknown verdict, never a padded one. # device_link: (optional) for a device id the user must add to their account by hand, # or the node earns for nobody (EarnApp, ProxyRack). Shown right after a # successful deploy as a "Link this device to your account" step. # id_env: EARNAPP_UUID the docker.env key holding the id; must not be secret # url: "https://earnapp.com/r/${EARNAPP_UUID}" (optional) where to claim it; # ${VAR} is filled from the deployed env, URL-encoded # steps: "Open this link while signed in..." what to do, in plain words # requirements: # residential_ip: true/false # vps_ip: true/false (default: opposite of residential_ip) # devices_per_account: 0 (0 = unlimited) # devices_per_ip: 1 (0 = unlimited) # # devices_per_ip drives the egress-conflict warning (app/egress.py), so the # distinction between OMITTING it and setting it to 0 is load-bearing: # - omitted -> "nobody has documented this", and the user is told to check # the provider's terms before running a second instance behind one IP; # - 0 -> a verified statement that the provider imposes no per-IP # limit. The "check the terms" warning is then replaced by a milder note # that two instances still share one connection, so the pair earns roughly # what one does. It is downgraded, NOT silenced -- shared bandwidth is a # real effect regardless of what the provider permits. # Only write 0 when the provider actually says so. Writing it to quieten a # warning turns a cautious message into a wrong one. # container_prohibited: true/false (default false) # Set ONLY when the provider explicitly forbids containers/VMs/servers for # its own software. This is the strongest verdict the preflight can give: # the outcome is a terminated account with the pending balance cancelled, # and CashPilot deploying the service as a container IS the violation. # Requires a first-party source; never infer it from a residential-IP rule. # proxy_prohibited: (optional) # source: "https://..." (the provider's own terms that say so) # Set ONLY when the provider's own terms forbid running its software # through a proxy or VPN. The dashboard then refuses a proxied copy of the # service (docs/proxies.md). Absent means nobody has documented it, never # that it is allowed. # note: "" (footnote text for special cases in README table) # note_column: "" (which column footnote applies to: vps_ip, devices_per_ip) # min_bandwidth: "" # gpu: false # min_storage: "" # payment: # methods: [paypal, crypto, bank, giftcard] # crypto_token: "" (specific token if different from currency, e.g. "SOL", "USDT") # minimum_payout: "$20" # currency: USD # frequency: "On request" | "Monthly" | "Weekly" # earnings: # monthly_low: 2 # monthly_high: 5 # currency: USD # per: "device" | "TB" | "GPU-hour" # notes: "" # cashout: # method: redirect | api | manual | auto # - redirect: user clicks through to service dashboard to request payout # - api: CashPilot can trigger payout via API (rare) # - manual: instructions only (e.g. crypto claim, no dashboard) # - auto: payouts happen automatically; no user action needed # dashboard_url: "https://service.com/dashboard/earnings" # min_amount: 20 (numeric, derived from payment.minimum_payout) # currency: USD # notes: "Additional cashout instructions or caveats" # payout: # WHERE THE MONEY ACTUALLY GOES. `cashout` says how to WITHDRAW; this says # where the funds live and whether there is an address at all (CashPilot-luj). # # model: external | internal | minted | unknown (REQUIRED) # - external: the USER supplies an address they already own. CashPilot never # holds a key. There IS an address to show. # - internal: the service keeps an off-chain balance and pays out on # request. There is NO address to register, and no chain to watch, until # the user withdraws. Showing a blank address field here would read as # "you forgot to configure this" when nothing is configurable — which is # exactly why the model is a first-class field rather than an empty slot. # - minted: the container generates its own wallet on first run and # accrues to it. The user has typically never seen the address, let # alone backed up the seed. # - unknown: not yet determined. A DELIBERATE value, not a placeholder: the # registry must flag what it does not know rather than render a blank # that reads as "nothing to see here". # # chain: ethereum | polygon | solana | none | ... # `none` means NO on-chain surface exists at all, ever — Salad pays PayPal # and gift cards only. Tier 2 (on-chain reconciliation) must skip these # rather than fail to find an address for them. # Omit entirely when the chain is chosen per-withdrawal and there is no # persistent on-chain identity (Traffmonetizer picks Arbitrum/Tron/Solana/TON # at withdrawal time). # # address_env: WALLET # `external` only, and only when the address is carried by an env var on the # container — so the registry can read it from the deployed spec instead of # asking the user to retype it. # # address_source: env | manual | app # Where the address is actually set. `manual` covers post-deploy # configuration (Mysterium's beneficiary via TequilAPI, Anyone's anonrc); # `app` covers a wallet linked inside the provider's own site. # # address_read: # command: ["cat", "/etc/anon/anonrc"] # pattern: '(?m)^[ \t]*ContactInfo[ \t][^\n]*@anon:[ \t]*(0x[0-9a-fA-F]{40})(?![0-9a-fA-F])' # `manual` only. How the worker reads a wallet set inside the running # container back: `command` runs in each running copy (it must only read), # and group 1 of `pattern` is the address. Only that group leaves the # worker. Without it, or when the read fails, the payouts page says "not # checked", never "not set". # # notes: "Caveats worth showing the user." # e.g. URnetwork forfeits payout after 30 days with no wallet linked. # # NO PRIVATE KEYS. This block is a registry of PUBLIC addresses and payout # mechanics, and nothing else. That is precisely why it comes first: it # delivers most of the "where is my money" answer at zero custody risk. # platforms: [windows, macos, linux, android, ios, docker, browser_extension] # collector: # type: api | scrape | manual | auto # - auto: earnings accrue/settle automatically; no active collection needed # notes: "Implementation hints for the earnings collector" (for developers) # quiet_days: 14 # How many days the balance can stand still while the service is working # normally: earnings that start late (a vetting period) or arrive in # batches (settlements, epochs). The "gone quiet" heads-up waits at least # this long, and a new deployment gets this long instead of 7 days before # a balance that never grew is mentioned. Omit it for a service that earns # a little every day; its own rhythm is learned from its history. # node_read: (optional) how the worker reads a node's own earnings from inside # its running container, for a provider whose account API cannot give a # balance or a lifetime (app/node_read.py, issue #488): # command: ["sh", "-c", "..."] run inside the container, read-only # node: regex, group 1 names the node # balance: regex, group 1 is the amount waiting to settle # lifetime: regex, group 1 is everything the node ever earned # settled_net, settled_fees: (optional, both or neither) regexes with one # group, every match summed: what the node's settlements paid # to the wallet and what the provider kept as fees, in any one # unit. Their ratio nets the settled amount counted as cashed out. # The collector gets one reading per node; the balance is their unsettled # total and earned figures follow the lifetime, so a settlement is never # asked about as a payout. # per_node_earnings: true|false # The collector can break earnings down per node/machine, not just an # account total. Declared here rather than branched on in app/ so adding a # second such service is one line of YAML, not an edit to a route handler. # credential_hint: | # Where the USER finds the credential in the provider's own UI, shown next # to the input in Settings. A different audience from `notes`: this is the # sentence someone reads while looking at a provider dashboard they have # never seen before. Limited HTML (, ) is allowed and is escaped-safe # because the UI inserts it as markup deliberately. Optional; omit it when # the credential is just the account email and password.