Menyimpan variabel lingkungan dengan aman di GAE dengan app.yaml

98

Saya perlu menyimpan kunci API dan informasi sensitif lainnya app.yamlsebagai variabel lingkungan untuk penerapan di GAE. Masalah dengan ini adalah jika saya mendorong app.yamlke GitHub, informasi ini menjadi publik (tidak baik). Saya tidak ingin menyimpan info di datastore karena tidak sesuai dengan proyek. Sebaliknya, saya ingin menukar nilai dari file yang terdaftar di .gitignoresetiap penerapan aplikasi.

Ini adalah file app.yaml saya:

application: myapp
version: 3 
runtime: python27
api_version: 1
threadsafe: true

libraries:
- name: webapp2
  version: latest
- name: jinja2
  version: latest

handlers:
- url: /static
  static_dir: static

- url: /.*
  script: main.application  
  login: required
  secure: always
# auth_fail_action: unauthorized

env_variables:
  CLIENT_ID: ${CLIENT_ID}
  CLIENT_SECRET: ${CLIENT_SECRET}
  ORG: ${ORG}
  ACCESS_TOKEN: ${ACCESS_TOKEN}
  SESSION_SECRET: ${SESSION_SECRET}

Ada ide?

Ben
sumber
73
Saya berharap GAE akan menambahkan opsi untuk menyetel instance env vars melalui konsol pengembang (seperti setiap PaaS lain yang saya kenal).
Kereta Spanyol
4
Anda dapat menggunakan datastore. Silakan merujuk ke jawaban ini: stackoverflow.com/a/35254560/1027846
Mustafa İlhan
Memperluas komentar mustilica di atas tentang penggunaan datastore. Lihat jawaban saya di bawah ini untuk kode yang saya gunakan dalam proyek saya untuk melakukan ini: stackoverflow.com/a/35261091#35261091 . Akibatnya, ini memungkinkan Anda mengedit variabel lingkungan dari konsol pengembang, dan nilai placeholder dibuat secara otomatis.
Martin Omander
Terima kasih mustilica dan Martin. Kami sebenarnya telah menggunakan pendekatan datastore sementara dan saya setuju itu adalah solusi terbaik untuk masalah ini. Lebih mudah dilakukan dengan penyiapan CI / CD daripada pendekatan file json, IMO.
Kereta Spanyol
1
2019 dan GAE masih belum memperbaiki masalah ini: /
Josh Noe

Jawaban:

53

Jika ini adalah data sensitif, Anda tidak boleh menyimpannya dalam kode sumber karena akan diperiksa ke kontrol sumber. Orang yang salah (di dalam atau di luar organisasi Anda) mungkin menemukannya di sana. Selain itu, lingkungan pengembangan Anda mungkin menggunakan nilai konfigurasi yang berbeda dari lingkungan produksi Anda. Jika nilai-nilai ini disimpan dalam kode, Anda harus menjalankan kode yang berbeda dalam pengembangan dan produksi, yang merupakan praktik yang berantakan dan buruk.

Dalam proyek saya, saya meletakkan data konfigurasi di datastore menggunakan kelas ini:

from google.appengine.ext import ndb

class Settings(ndb.Model):
  name = ndb.StringProperty()
  value = ndb.StringProperty()

  @staticmethod
  def get(name):
    NOT_SET_VALUE = "NOT SET"
    retval = Settings.query(Settings.name == name).get()
    if not retval:
      retval = Settings()
      retval.name = name
      retval.value = NOT_SET_VALUE
      retval.put()
    if retval.value == NOT_SET_VALUE:
      raise Exception(('Setting %s not found in the database. A placeholder ' +
        'record has been created. Go to the Developers Console for your app ' +
        'in App Engine, look up the Settings record with name=%s and enter ' +
        'its value in that record\'s value field.') % (name, name))
    return retval.value

Aplikasi Anda akan melakukan ini untuk mendapatkan nilai:

API_KEY = Settings.get('API_KEY')

Jika ada nilai untuk kunci itu di datastore, Anda akan mendapatkannya. Jika tidak ada, rekaman placeholder akan dibuat dan pengecualian akan diberikan. Pengecualian akan mengingatkan Anda untuk pergi ke Developers Console dan memperbarui catatan placeholder.

Saya menemukan ini menghilangkan tebakan dari pengaturan nilai konfigurasi. Jika Anda tidak yakin nilai konfigurasi apa yang harus disetel, jalankan saja kode dan itu akan memberi tahu Anda!

Kode di atas menggunakan pustaka ndb yang menggunakan memcache dan datastore di bawah tenda, jadi cepat.


Memperbarui:

jelder menanyakan cara menemukan nilai Datastore di konsol App Engine dan menyetelnya. Begini caranya:

  1. Buka https://console.cloud.google.com/datastore/

  2. Pilih proyek Anda di bagian atas halaman jika belum dipilih.

  3. Di kotak tarik-turun Jenis , pilih Pengaturan .

  4. Jika Anda menjalankan kode di atas, kunci Anda akan muncul. Mereka semua akan memiliki nilai NOT SET . Klik masing-masing dan tetapkan nilainya.

Semoga ini membantu!

Pengaturan Anda, dibuat oleh kelas Pengaturan

Klik untuk mengedit

Masukkan nilai sebenarnya dan simpan

Martin Omander
sumber
2
Dari semua jawaban yang diberikan, ini tampaknya paling mendekati cara Heroku menangani berbagai hal. Menjadi agak baru di GAE, saya tidak begitu mengerti di bagian mana di Developers Console untuk menemukan catatan placeholder. Bisakah Anda menjelaskan, atau untuk poin bonus, memposting tangkapan layar?
jelder
2
dam ~… dengan segala pertimbangan untuk gcloud, tampaknya sangat buruk harus menggunakan layanan lain untuk kebutuhan khusus ini. Selain itu, google menyediakan pendekatan "100% -herokuish" untuk env vars dalam fungsi firebase, tetapi tidak untuk fungsi gcloud (setidaknya tidak berdokumen… jika saya tidak salah)
Ben
1
Berikut ini intisari berdasarkan pendekatan Anda yang menambahkan keunikan dan fallback variabel lingkungan - gist.github.com/SpainTrain/6bf5896e6046a5d9e7e765d0defc8aa8
Kereta Spanyol
3
Fungsi @Ben Non-Firebase mendukung env vars (setidaknya sekarang).
NReilingh
3
@obl - Aplikasi App Engine otomatis diautentikasi ke datastore-nya sendiri, tidak perlu detail autentikasi. Cukup rapi :-)
Martin Omander
49

Solusi ini sederhana tetapi mungkin tidak cocok untuk semua tim yang berbeda.

Pertama, letakkan variabel lingkungan di env_variables.yaml , misalnya,

env_variables:
  SECRET: 'my_secret'

Kemudian, masukkan ini ke env_variables.yamldalamapp.yaml

includes:
  - env_variables.yaml

Terakhir, tambahkan env_variables.yamlke .gitignore, sehingga variabel rahasia tidak akan ada di repositori.

Dalam hal ini, env_variables.yamlkebutuhan dibagikan di antara manajer penerapan.

Shih-Wen Su
sumber
1
Hanya untuk menambahkan apa yang mungkin tidak jelas bagi sebagian orang, variabel lingkungan Anda kemudian akan ditemukan di process.env.MY_SECRET_KEYdan jika Anda memerlukan variabel lingkungan ini di lingkungan dev lokal Anda, Anda dapat menggunakan dotenvpaket node
Dave Kiss
2
Bagaimana env_variables.yamlmendapatkan semua contoh adalah bagian yang hilang dari teka-teki.
Christopher Oezbek
1
Juga: Bagaimana cara menggunakan ini secara lokal?
Christopher Oezbek
@ChristopherOezbek 1. Bagaimana cara menyebarkan? Cukup gunakan gcloud app deployseperti yang biasa Anda lakukan untuk menerapkan ke Google Cloud. 2. Bagaimana mengatur variabel lingkungan rahasia secara lokal? Ada banyak cara. Anda bisa menggunakan exportdi command prompt atau menggunakan alat apa pun seperti yang disarankan @DaveKiss.
Shih-Wen Su
Ini adalah solusi paling sederhana. Rahasia dapat diakses di aplikasi Anda melalui os.environ.get('SECRET').
Quinn Comendant
19

Pendekatan saya adalah menyimpan rahasia klien hanya dalam aplikasi App Engine itu sendiri. Rahasia klien tidak ada di kontrol sumber maupun di komputer lokal mana pun. Keuntungannya adalah setiap kolaborator App Engine dapat menerapkan perubahan kode tanpa harus mengkhawatirkan rahasia klien.

Saya menyimpan rahasia klien langsung di Datastore dan menggunakan Memcache untuk meningkatkan latensi dalam mengakses rahasia. Entitas Datastore hanya perlu dibuat sekali dan akan tetap ada di penerapan di masa mendatang. tentu saja konsol App Engine dapat digunakan untuk memperbarui entitas ini kapan saja.

Ada dua opsi untuk melakukan pembuatan entitas satu kali:

  • Gunakan shell interaktif App Engine Remote API untuk membuat entitas.
  • Buat penangan Admin saja yang akan menginisialisasi entitas dengan nilai dummy. Panggil penangan admin ini secara manual, lalu gunakan konsol App Engine untuk memperbarui entitas dengan rahasia klien produksi.
Bernd Verst
sumber
7
Tidak rumit sama sekali. Terima kasih mesin aplikasi.
courtimas
17

Ini tidak ada saat Anda memposting, tetapi untuk siapa pun yang tersandung di sini, Google sekarang menawarkan layanan yang disebut Manajer Rahasia .

Ini adalah layanan REST sederhana (dengan SDK yang membungkusnya, tentu saja) untuk menyimpan rahasia Anda di lokasi yang aman di platform cloud google. Ini adalah pendekatan yang lebih baik daripada Penyimpanan Data, yang membutuhkan langkah-langkah ekstra untuk melihat rahasia yang disimpan dan memiliki model izin yang lebih terperinci - Anda dapat mengamankan rahasia individu secara berbeda untuk berbagai aspek proyek Anda, jika perlu.

Ini menawarkan pembuatan versi, sehingga Anda dapat menangani perubahan kata sandi dengan relatif mudah, serta kueri yang kuat dan lapisan manajemen yang memungkinkan Anda menemukan dan membuat rahasia pada waktu proses, jika perlu.

Python SDK

Contoh penggunaan:

from google.cloud import secretmanager_v1beta1 as secretmanager

secret_id = 'my_secret_key'
project_id = 'my_project'
version = 1    # use the management tools to determine version at runtime

client = secretmanager.SecretManagerServiceClient()

secret_path = client.secret_verion_path(project_id, secret_id, version)
response = client.access_secret_version(secret_path)
password_string = response.payload.data.decode('UTF-8')

# use password_string -- set up database connection, call third party service, whatever
Randolpho
sumber
3
Ini harus menjadi jawaban baru yang benar. Secret Manager masih dalam versi Beta, tetapi ini adalah cara maju saat bekerja dengan variabel lingkungan.
Raja Leon
@KingLong, apakah penggunaan ini berarti harus mereforeksi sekelompok os.getenv('ENV_VAR')s?
Alejandro
Saya meletakkan kode yang mirip dengan di atas dalam sebuah fungsi, lalu saya menggunakan sesuatu seperti SECRET_KEY = env('SECRET_KEY', default=access_secret_version(GOOGLE_CLOUD_PROJECT_ID, 'SECRET_KEY', 1)). Menyetel default untuk menggunakanaccess_secret_version
King Leon
Juga, saya menggunakan django-environment. github.com/joke2k/django-environ
Raja Leon
16

Cara terbaik untuk melakukannya, adalah menyimpan kunci dalam file client_secrets.json, dan mengecualikannya agar tidak diunggah ke git dengan mencantumkannya di file .gitignore Anda. Jika Anda memiliki kunci yang berbeda untuk lingkungan yang berbeda, Anda dapat menggunakan app_identity api untuk menentukan apa id aplikasi, dan memuat dengan tepat.

Ada contoh yang cukup komprehensif di sini -> https://developers.google.com/api-client-library/python/guide/aaa_client_secrets .

Berikut beberapa contoh kode:

# declare your app ids as globals ...
APPID_LIVE = 'awesomeapp'
APPID_DEV = 'awesomeapp-dev'
APPID_PILOT = 'awesomeapp-pilot'

# create a dictionary mapping the app_ids to the filepaths ...
client_secrets_map = {APPID_LIVE:'client_secrets_live.json',
                      APPID_DEV:'client_secrets_dev.json',
                      APPID_PILOT:'client_secrets_pilot.json'}

# get the filename based on the current app_id ...
client_secrets_filename = client_secrets_map.get(
    app_identity.get_application_id(),
    APPID_DEV # fall back to dev
    )

# use the filename to construct the flow ...
flow = flow_from_clientsecrets(filename=client_secrets_filename,
                               scope=scope,
                               redirect_uri=redirect_uri)

# or, you could load up the json file manually if you need more control ...
f = open(client_secrets_filename, 'r')
client_secrets = json.loads(f.read())
f.close()
Gwyn Howell
sumber
2
Jelas ke arah yang benar, tetapi ini tidak mengatasi masalah pertukaran nilai pada app.yamlsaat penerapan aplikasi. Ada ide di sana?
Ben
1
Jadi miliki file client_secrets yang berbeda untuk setiap lingkungan. Misalnya client_secrets_live.json, client_secrets_dev.json, client_secrets_pilot.json dll, kemudian gunakan logika python untuk menentukan server mana Anda berada dan memuat file json yang sesuai. Metode app_identity.get_application_id () mungkin berguna untuk secara otomatis mendeteksi server mana Anda berada. Apakah ini yang Anda maksud?
Gwyn Howell
@BenGrunfeld melihat jawaban saya. Solusi saya melakukan persis seperti ini. Saya tidak melihat bagaimana jawaban ini menyelesaikan pertanyaan. Saya berasumsi bahwa tujuannya adalah untuk menjaga konfigurasi rahasia dari git, dan menggunakan git sebagai bagian dari penerapan. Di sini, file ini masih perlu berada di suatu tempat dan didorong ke dalam proses penerapan. Ini bisa menjadi sesuatu yang Anda lakukan di aplikasi Anda, tetapi Anda hanya akan menggunakan teknik yang saya soroti, mungkin menyimpan di file lain jika Anda ingin menggunakan ini vs. app.yaml. Jika saya memahami pertanyaannya, itu mirip dengan mengirimkan aplikasi open source dengan rahasia atau prod klien sebenarnya dari pembuat perpustakaan. kunci.
kembali
1
Butuh beberapa saat bagi saya untuk memikirkannya, tetapi saya pikir ini adalah pendekatan yang benar. Anda tidak mencampur pengaturan aplikasi ( app.yaml) dengan kunci rahasia dan informasi rahasia, dan yang sangat saya sukai adalah Anda menggunakan alur kerja Google untuk menyelesaikan tugas tersebut. Terima kasih @GwynHowell. =)
Ben
1
Pendekatan serupa adalah dengan menempatkan file JSON tersebut di lokasi yang diketahui di bucket GCS default aplikasi ( cloud.google.com/appengine/docs/standard/python/… ).
Kereta Spanyol
15

Solusi ini bergantung pada appcfg.py yang tidak digunakan lagi

Anda dapat menggunakan opsi baris perintah -E dari appcfg.py untuk menyiapkan variabel lingkungan saat Anda menerapkan aplikasi Anda ke GAE (appcfg.py update)

$ appcfg.py
...
-E NAME:VALUE, --env_variable=NAME:VALUE
                    Set an environment variable, potentially overriding an
                    env_variable value from app.yaml file (flag may be
                    repeated to set multiple variables).
...
jla
sumber
Dapatkah Anda menanyakan variabel lingkungan tersebut di suatu tempat setelah penerapan? (Saya harap tidak.)
Ztyx
Apakah ada cara untuk meneruskan variabel lingkungan seperti ini menggunakan gcloudutilitas?
Trevor
6

Sebagian besar jawaban sudah usang. Menggunakan datastore google cloud sebenarnya sedikit berbeda sekarang. https://cloud.google.com/python/getting-started/using-cloud-datastore

Berikut contohnya:

from google.cloud import datastore
client = datastore.Client()
datastore_entity = client.get(client.key('settings', 'TWITTER_APP_KEY'))
connection_string_prod = datastore_entity.get('value')

Ini mengasumsikan nama entitas adalah 'TWITTER_APP_KEY', jenisnya adalah 'pengaturan', dan 'nilai' adalah properti dari entitas TWITTER_APP_KEY.

Jason F
sumber
3

Sepertinya Anda bisa melakukan beberapa pendekatan. Kami memiliki masalah serupa dan melakukan hal berikut (disesuaikan dengan kasus penggunaan Anda):

  • Buat file yang menyimpan nilai app.yaml dinamis apa pun dan letakkan di server aman di lingkungan build Anda. Jika Anda benar-benar paranoid, Anda dapat mengenkripsi nilai secara asimetris. Anda bahkan dapat menyimpan ini dalam repo pribadi jika Anda memerlukan kontrol versi / penarikan dinamis, atau cukup gunakan skrip shell untuk menyalin / menariknya dari tempat yang sesuai.
  • Tarik dari git selama skrip penerapan
  • Setelah git pull, ubah app.yaml dengan membaca dan menulisnya dalam python murni menggunakan pustaka yaml

Cara termudah untuk melakukannya adalah dengan menggunakan server integrasi berkelanjutan seperti Hudson , Bamboo , atau Jenkins . Cukup tambahkan beberapa plugin, langkah skrip, atau alur kerja yang melakukan semua item di atas yang saya sebutkan. Anda bisa memasukkan variabel lingkungan yang dikonfigurasi di Bamboo itu sendiri misalnya.

Singkatnya, cukup masukkan nilai selama proses build Anda di lingkungan yang hanya dapat Anda akses. Jika Anda belum mengotomatiskan build Anda, Anda harus melakukannya.

Opsi opsi lainnya adalah apa yang Anda katakan, taruh di database. Jika alasan Anda tidak melakukannya adalah karena semuanya terlalu lambat, cukup dorong nilainya ke dalam memcache sebagai cache lapisan kedua, dan sematkan nilai ke instance sebagai cache lapisan pertama. Jika nilai dapat berubah dan Anda perlu memperbarui instans tanpa me-rebootnya, cukup simpan hash yang dapat Anda periksa untuk mengetahui kapan mereka berubah atau memicunya entah bagaimana ketika sesuatu yang Anda lakukan mengubah nilainya. Seharusnya begitu.

masih ada kekurangan
sumber
1
FWIW, pendekatan ini paling dekat mengikuti faktor konfigurasi dalam pedoman Aplikasi 12 Faktor ( 12factor.net )
Kereta Spanyol
3

Anda harus mengenkripsi variabel dengan google kms dan menyematkannya di kode sumber Anda. ( https://cloud.google.com/kms/ )

echo -n the-twitter-app-key | gcloud kms encrypt \
> --project my-project \
> --location us-central1 \
> --keyring THEKEYRING \
> --key THECRYPTOKEY \
> --plaintext-file - \
> --ciphertext-file - \
> | base64

masukkan nilai yang diacak (dienkripsi dan dienkode base64) ke dalam variabel lingkungan Anda (dalam file yaml).

Beberapa kode pythonish untuk membantu Anda mulai mendekripsi.

kms_client = kms_v1.KeyManagementServiceClient()
name = kms_client.crypto_key_path_path("project", "global", "THEKEYRING", "THECRYPTOKEY")

twitter_app_key = kms_client.decrypt(name, base64.b64decode(os.environ.get("TWITTER_APP_KEY"))).plaintext
Anders Elton
sumber
3

Jawaban @Jason F berdasarkan penggunaan Google Datastore sudah dekat, tetapi kodenya agak ketinggalan jaman berdasarkan penggunaan sampel di dokumen perpustakaan . Berikut cuplikan yang berhasil untuk saya:

from google.cloud import datastore

client = datastore.Client('<your project id>')
key = client.key('<kind e.g settings>', '<entity name>') # note: entity name not property
# get by key for this entity
result = client.get(key)
print(result) # prints all the properties ( a dict). index a specific value like result['MY_SECRET_KEY'])

Sebagian terinspirasi oleh postingan Medium ini

kip2
sumber
2

Hanya ingin mencatat bagaimana saya memecahkan masalah ini di javascript / nodejs. Untuk pengembangan lokal saya menggunakan paket 'dotenv' npm yang memuat variabel lingkungan dari file .env ke dalam proses.env. Ketika saya mulai menggunakan GAE, saya mengetahui bahwa variabel lingkungan perlu disetel dalam file 'app.yaml'. Ya, saya tidak ingin menggunakan 'dotenv' untuk pengembangan lokal dan 'app.yaml' untuk GAE (dan menduplikasi variabel lingkungan saya di antara dua file), jadi saya menulis skrip kecil yang memuat variabel lingkungan app.yaml ke dalam proses .env, untuk pengembangan lokal. Semoga ini bisa membantu seseorang:

yaml_env.js:

(function () {
    const yaml = require('js-yaml');
    const fs = require('fs');
    const isObject = require('lodash.isobject')

    var doc = yaml.safeLoad(
        fs.readFileSync('app.yaml', 'utf8'), 
        { json: true }
    );

    // The .env file will take precedence over the settings the app.yaml file
    // which allows me to override stuff in app.yaml (the database connection string (DATABASE_URL), for example)
    // This is optional of course. If you don't use dotenv then remove this line:
    require('dotenv/config');

    if(isObject(doc) && isObject(doc.env_variables)) {
        Object.keys(doc.env_variables).forEach(function (key) {
            // Dont set environment with the yaml file value if it's already set
            process.env[key] = process.env[key] || doc.env_variables[key]
        })
    }
})()

Sekarang sertakan file ini sedini mungkin dalam kode Anda, dan Anda selesai:

require('../yaml_env')
gbruins
sumber
Apakah masih demikian? Karena saya menggunakan .envfile dengan variabel rahasia. Saya tidak menduplikasi mereka di app.yamlfile saya dan kode yang saya terapkan masih berfungsi. Saya khawatir apa yang terjadi pada .envfile di cloud. Apakah itu dienkripsi atau sesuatu? Bagaimana cara memastikan tidak ada yang mengakses .envvariabel file gcloud setelah diterapkan?
Gus
Ini tidak diperlukan sama sekali karena GAE secara otomatis menambahkan semua variabel yang ditentukan dalam file app.yaml ke lingkungan node. Pada dasarnya ini sama seperti dotenv dengan variabel yang didefinisikan dalam paket .env. Tapi saya bertanya-tanya bagaimana Anda harus mengatur CD karena Anda tidak dapat mendorong app.yaml dengan env vars ke VCS atau pipa ...
Jornve
1

Memperluas jawaban Martin

from google.appengine.ext import ndb

class Settings(ndb.Model):
    """
    Get sensitive data setting from DataStore.

    key:String -> value:String
    key:String -> Exception

    Thanks to: Martin Omander @ Stackoverflow
    https://stackoverflow.com/a/35261091/1463812
    """
    name = ndb.StringProperty()
    value = ndb.StringProperty()

    @staticmethod
    def get(name):
        retval = Settings.query(Settings.name == name).get()
        if not retval:
            raise Exception(('Setting %s not found in the database. A placeholder ' +
                             'record has been created. Go to the Developers Console for your app ' +
                             'in App Engine, look up the Settings record with name=%s and enter ' +
                             'its value in that record\'s value field.') % (name, name))
        return retval.value

    @staticmethod
    def set(name, value):
        exists = Settings.query(Settings.name == name).get()
        if not exists:
            s = Settings(name=name, value=value)
            s.put()
        else:
            exists.value = value
            exists.put()

        return True
JSBach
sumber
1

Ada paket pypi bernama gae_env yang memungkinkan Anda menyimpan variabel lingkungan appengine di Cloud Datastore. Di bawah tenda, ia juga menggunakan Memcache sehingga cepat

Pemakaian:

import gae_env

API_KEY = gae_env.get('API_KEY')

Jika ada nilai untuk kunci itu di datastore, itu akan dikembalikan. Jika tidak ada, rekaman placeholder __NOT_SET__akan dibuat dan ValueNotSetErrorakan dilempar. Pengecualian akan mengingatkan Anda untuk pergi ke Developers Console dan memperbarui catatan placeholder.


Mirip dengan jawaban Martin, berikut adalah cara memperbarui nilai untuk kunci di Datastore:

  1. Buka Bagian Datastore di konsol pengembang

  2. Pilih proyek Anda di bagian atas halaman jika belum dipilih.

  3. Di kotak dropdown Kind , pilih GaeEnvSettings.

  4. Kunci yang pengecualiannya dimunculkan akan memiliki nilai __NOT_SET__.

Pengaturan Anda, dibuat oleh kelas Pengaturan

Klik untuk mengedit

Masukkan nilai sebenarnya dan simpan


Buka halaman GitHub paket untuk info lebih lanjut tentang penggunaan / konfigurasi

Pangeran Odame
sumber